Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,16 @@ export default defineConfig([
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
// Omit-via-rest (`const { drop, ...keep } = obj`) is the intended pattern.
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
// Syntax the compiler cannot lower, so the enclosing function silently loses its memoization.
// Warn, not error: the hooks that predate the rule are their own cleanup scope.
'react-hooks/todo': 'warn',
// Syntax the compiler cannot lower, so the whole hook silently loses its memoization.
'react-hooks/todo': 'error',
},
},
{
files: ['**/*.test.ts', '**/*.test.tsx', 'src/test/**'],
files: ['**/*.test.ts', '**/*.test.tsx', 'src/test/**', 'tests/**/*.ts', 'e2e/**/*.ts'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"licenses:check": "node scripts/check-licenses.mjs",
"attribution:gen": "node scripts/gen-attribution.mjs",
"attribution:check": "node scripts/gen-attribution.mjs --check",
Expand Down
9 changes: 2 additions & 7 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tryFinally } from "../../lib/tryFinally";
import {
forwardRef,
useImperativeHandle,
Expand Down Expand Up @@ -1827,13 +1828,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
}}
onTransform={onTransform}
boundBoxFunc={boundBoxFunc}
onTransformEnd={() => {
try {
onTransformEnd();
} finally {
transformActiveRef.current = false;
}
}}
onTransformEnd={() => tryFinally(onTransformEnd, () => { transformActiveRef.current = false; })}
borderStroke={colors.selection}
anchorStroke={colors.selection}
anchorFill="#ffffff"
Expand Down
90 changes: 48 additions & 42 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { editBounds, moduleScaledProps, wireBounds, type Range } from "@zplab/core/types/propSpec";
import { tryFinally } from "../../../lib/tryFinally";
import { useRef, useEffect, useLayoutEffect } from "react";
import { flushSync } from "react-dom";
import type Konva from "konva";
Expand Down Expand Up @@ -553,16 +554,19 @@ export function useKonvaTransformer({
node?.scaleX(1);
node?.scaleY(1);
const temporal = useLabelStore.temporal.getState();
try {
if (id && commit) {
updateObject(id, commit.restore);
tryFinally(
() => {
if (id && commit) {
updateObject(id, commit.restore);
temporal.resume();
updateObject(id, commit.final);
}
},
() => {
temporal.resume();
updateObject(id, commit.final);
}
} finally {
temporal.resume();
cleanupTransformState();
}
cleanupTransformState();
},
);
}

const onTransformStart = () => {
Expand Down Expand Up @@ -1568,40 +1572,42 @@ export function useKonvaTransformer({
blockJustify?: BlockJustify;
};
const temporal = useLabelStore.temporal.getState();
// try/finally so a throw from flushSync/getClientRect can't leave undo
// recording globally paused (resume is idempotent).
// A throw from flushSync or getClientRect must not leave undo recording paused.
temporal.pause();
try {
// Konva left the node at the drag position; the font-only commit does
// not change x/y so react-konva won't reset it. Move it back to the
// model origin so the re-measure reflects the block at obj.x/y.
node.x(objectsOffsetX + dotsToPx(obj.x, scale, dpmm));
node.y(labelOffsetY + dotsToPx(obj.y, scale, dpmm));
flushSync(() => updateObject(singleId, { props: propChanges }));
const newRect = stageRect(node, stageRef.current);
const edges = activeEdgesRef.current;
const justify = fp.blockJustify ?? "L";
const anchorArgs = { rotation: fp.rotation, justify, edges, centeredStacking: centeredScaling };
const startA = blockGlyphAnchorPoint({ rect: start, ...anchorArgs });
const newA = blockGlyphAnchorPoint({ rect: newRect, ...anchorArgs });
const model = modelPositionFromRenderedTopLeft(
obj,
pxToDots(node.x() + (startA.x - newA.x) - objectsOffsetX, scale, dpmm),
pxToDots(node.y() + (startA.y - newA.y) - labelOffsetY, scale, dpmm),
);
// Restore pre-drag state while paused, resume, then apply the final as
// one tracked change so undo records pre-drag -> final once.
updateObject(singleId, {
x: obj.x,
y: obj.y,
props: { fontWidth: fp.fontWidth, fontHeight: fp.fontHeight },
});
temporal.resume();
updateObject(singleId, { x: model.x, y: model.y, props: propChanges });
} finally {
temporal.resume();
cleanupTransformState();
}
tryFinally(
() => {
// Konva left the node at the drag position; the font-only commit does
// not change x/y so react-konva won't reset it. Move it back to the
// model origin so the re-measure reflects the block at obj.x/y.
node.x(objectsOffsetX + dotsToPx(obj.x, scale, dpmm));
node.y(labelOffsetY + dotsToPx(obj.y, scale, dpmm));
flushSync(() => updateObject(singleId, { props: propChanges }));
const newRect = stageRect(node, stageRef.current);
const edges = activeEdgesRef.current;
const justify = fp.blockJustify ?? "L";
const anchorArgs = { rotation: fp.rotation, justify, edges, centeredStacking: centeredScaling };
const startA = blockGlyphAnchorPoint({ rect: start, ...anchorArgs });
const newA = blockGlyphAnchorPoint({ rect: newRect, ...anchorArgs });
const model = modelPositionFromRenderedTopLeft(
obj,
pxToDots(node.x() + (startA.x - newA.x) - objectsOffsetX, scale, dpmm),
pxToDots(node.y() + (startA.y - newA.y) - labelOffsetY, scale, dpmm),
);
// Restore pre-drag state while paused, resume, then apply the final as
// one tracked change so undo records pre-drag -> final once.
updateObject(singleId, {
x: obj.x,
y: obj.y,
props: { fontWidth: fp.fontWidth, fontHeight: fp.fontHeight },
});
temporal.resume();
updateObject(singleId, { x: model.x, y: model.y, props: propChanges });
},
() => {
temporal.resume();
cleanupTransformState();
},
);
return;
}
updateObject(singleId, { ...pos, props: propChanges });
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/useMcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from "../lib/mcpServer";
import { isDesktopShell } from "../lib/platform";

// Module level, since the compiler cannot lower an import expression inside the hook.
const tauriEvent = () => import("@tauri-apps/api/event");
const tauriCore = () => import("@tauri-apps/api/core");

/** Register the sidecar listeners, then have Rust flush the events it
* queued while none existed (see event_buffer in mcp.rs). */
export function useMcpBridge(): void {
Expand All @@ -24,10 +28,7 @@ export function useMcpBridge(): void {
// teardown withdraws its own and never the one that replaced it.
let attachedSession: string | null = null;
void (async () => {
const [{ listen }, { invoke }] = await Promise.all([
import("@tauri-apps/api/event"),
import("@tauri-apps/api/core"),
]);
const [{ listen }, { invoke }] = await Promise.all([tauriEvent(), tauriCore()]);
const fns = await Promise.all([
listen<[number, string]>("mcp://open-draft", (e) => {
void respondToOpenDraft(e.payload[0], e.payload[1]).catch(() => undefined);
Expand Down
10 changes: 6 additions & 4 deletions src/hooks/useNativeMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ let latest: MenuData | null = null;
let installed: InstalledMenu | null = null;
let updateGen = 0;
let queue: Promise<void> = Promise.resolve();
// A function, since the compiler cannot lower ++ on a module variable inside the hook.
const nextUpdateGen = (): number => ++updateGen;
/** The OS toggles a clicked CheckMenuItem's checkmark itself; after a jump we
* re-assert every step's checkmark so that stray toggle can't linger. */
let checkmarkDirty = false;
Expand All @@ -104,7 +106,7 @@ if (import.meta.hot) {
import.meta.hot.dispose(() => {
// Invalidate queued rebuilds and drain the in-flight update before closing,
// so a mid-await patch/rebuild can't touch freed handles.
updateGen += 1;
nextUpdateGen();
queue = queue.then(() => {
if (installed) closeAll(installed.resources);
installed = null;
Expand Down Expand Up @@ -384,7 +386,7 @@ async function patchMenu(inst: InstalledMenu, d: MenuData): Promise<void> {
// for the next unrelated signature change.
if (installed) closeAll(installed.resources);
installed = null;
const gen = ++updateGen;
const gen = nextUpdateGen();
queue = queue.then(() => runUpdate(gen)).catch(() => undefined);
}
}
Expand Down Expand Up @@ -434,12 +436,12 @@ export function useNativeMenu(

useEffect(() => {
if (!isDesktopShell) return;
const gen = ++updateGen;
const gen = nextUpdateGen();
queue = queue.then(() => runUpdate(gen)).catch(() => undefined);
// Invalidate a queued-but-unstarted update on re-run/unmount (StrictMode
// remounts included); the re-run queues a fresh one.
return () => {
updateGen += 1;
nextUpdateGen();
};
}, [signature]);
}
4 changes: 1 addition & 3 deletions src/hooks/useServerDbConnectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,8 @@ export function useServerDbConnectActions(driver: 'postgres' | 'mysql') {
if (draftEpoch.current === epoch) setTables(listed);
} catch (e) {
if (draftEpoch.current === epoch) setListError(String(e));
} finally {
setBusy(false);
}
})();
})().finally(() => setBusy(false));
};

/** Run a load attempt via `run` (the wizard marks a success as its own load)
Expand Down
9 changes: 9 additions & 0 deletions src/lib/tryFinally.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** The React Compiler cannot lower a try/finally inside a hook, so the finally lives here.
* `body` is a closure, so a return inside it leaves only the closure. */
export function tryFinally(body: () => void, cleanup: () => void): void {
try {
body();
} finally {
cleanup();
}
}
Loading