Files
m3e-canvas/web/CanvasPanel.tsx
T
pastilhas 80529dd9e7 six defects, and a typecheck the repo could not pass
Found by reviewing the plugin rather than running it. Nothing was live — the
table does not exist and the plugin is not installed — so all of these were
waiting for the first person to use it.

── The second unnamed design could not be saved ──

`uniqueIndex(userId, title)`, and every design starts life called "Untitled":
`titleOf` returns it for a document with no title and the API defaults to it. So
the first saved, the second collided, and the insert threw under a panel that
could only say "Changes are not being saved." Renaming one design to match
another did the same.

Two designs may share a name, as files and notes and dashboards do. Nothing looks
a design up by title, so uniqueness bought nothing. The index is now ordinary and
on the columns the list query actually uses.

The lesson from `dashboards.id` that the schema comment cites is that a key must
be scoped to its owner. `userId` on the row does that; making the pair unique as
well imported the collision it was citing, one account at a time.

── New did not start a new design ──

It cleared `?doc=`, and no parameter already means "open what I had last",
answered with `/documents/latest`. So New handed back the design on screen and
pointed the next save at its id. There is a `new` sentinel now, in its own module
because both panels read it and neither owns it.

── Two ways the load effect raced the debounced save ──

Changing `?doc=` does not unmount the panel, so the unmount flush never ran for
it: an edit made within the debounce and navigated away from was written after
`latest.current` had been replaced, which is to say never.

And the first save of a new design puts its id in the URL, which lands in the
same effect — re-reading the row overwrote in-memory state with the copy the
server had just been given, discarding whatever was drawn during the round trip.

The effect now flushes what is pending before it reads, and does not re-read a
document it already holds. The editor is keyed on a load generation rather than
on `row?.id`, which could not express "blank again" and left the previous design
on screen with `rowId` cleared underneath it.

── The typecheck ──

354 of the 355 errors were upstream's tree measured against this platform's
settings, which it was never written for. `bunx tsgo` is how this repo is kept
clean and it could not be clean while this plugin existed. `exclude` does not
help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file
unchecked in the same deterministic pass that rewrites its imports. Types still
resolve; only the errors inside those files are silenced, which is what the
adapter already says the seam is for. Whole repo: 0.

── PROVENANCE.md understated what is done to the copy ──

It said "the one edit vendor.sh makes" and described the import rewrite. It also
runs apply-host.py, which places our own lib/host.ts and wires three export
paths. Four files are not upstream and the document whose job is making the copy
trustworthy did not say so. It lists all four now.

Also: web/host.ts imported a path only correct at its destination, so the file
could never typecheck where it is edited — apply-host.py rewrites that line when
placing it, and re-marks the placed copy, which it overwrites after vendor.sh has
marked it. PUT is no longer full-replace: omitting `title` renamed a design to
"Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a
document is bounded at 4 MB, measured in bytes, where the title was capped at 200
and the document at nothing.
2026-09-05 19:52:27 +01:00

222 lines
9.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { useClient } from "hooks/useClient";
import { Canvas, HostProvider, titleOf, type Doc, type HostAdapter } from "./adapter";
import { NEW_DOC } from "./doc-param";
import "./vendor/globals.css";
/**
* The editor, hosted.
*
* This file is the whole of Officer's contribution to the drawing half: it hands the vendored editor
* a place to keep documents and somewhere for the prompt to go, and otherwise stays out of the way.
*
* ── Saving ──
*
* Upstream calls `saveDoc` on every change — every drag, every colour, every keystroke in a label. That
* is right for `localStorage` and wrong for a database, so writes are debounced and the newest state is
* held in a ref rather than in the dependency list. Putting the document in a dependency array would
* rebuild the adapter on every stroke, and the editor takes the adapter from context at mount.
*
* ── Which document ──
*
* `?doc=<id>` in the URL, per `docs/navigation-audit.md`: selection is addressable, survives a reload,
* and can be linked to. Not a channel — a channel would make "which design am I looking at" a thing you
* cannot bookmark.
*/
const SAVE_DEBOUNCE_MS = 1200;
type DocumentRow = { id: number; title: string; doc: Partial<Doc>; ui: unknown | null };
export const CanvasPanel = () => {
const client = useClient();
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [params, setParams] = useSearchParams();
const requested = params.get("doc");
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
/*
What the editor is keyed on: one count of how many times a document has been ESTABLISHED here.
It was `row?.id ?? 'new'`, which cannot express the case it most needed to. After the first save
the panel deliberately does not re-read the row, so `row` stays null and the key stays `'new'` —
and clicking New a second time then changed nothing, leaving the previous design on screen with
`rowId` cleared underneath it, so the next save wrote a copy of it.
Bumped only where a document is actually put in place, never on the already-open path, so a save
does not remount the editor mid-edit.
*/
const [generation, setGeneration] = useState(0);
/** The newest document, outside React state — see the note above on why. */
const latest = useRef<{ doc: Partial<Doc> | null; ui: unknown }>({ doc: null, ui: null });
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowId = useRef<number | null>(null);
/*
`flush` is defined below and the load effect above needs it.
Through a ref, not the dependency list: `flush` changes identity when `setParams` does, and a load
effect that re-runs on that would re-read the document mid-edit — the exact thing the guard inside
it exists to prevent.
*/
const flushRef = useRef<() => Promise<void>>(async () => {});
// ── load, or start a new one ────────────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
/*
── Two things have to happen before this reads anything ──
WRITE OUT what is still in the timer. Changing `?doc=` does not unmount the panel, so the
unmount flush below never ran for it: an edit made within the debounce and then navigated away
from was written after `latest.current` had already been replaced with the next document, which
is to say never. Up to a full debounce of somebody's work, lost silently and only sometimes.
Then DO NOT re-read a document already held. The first save of a new design puts its id in the
URL, which lands right here — and re-reading overwrote `latest.current` with the copy the server
had just been given, discarding whatever was drawn during the round trip, and changed the
canvas `key` mid-edit so the editor remounted on top of it.
*/
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
void flushRef.current();
}
// Already open, and `latest.current` is newer than anything a read would return.
if (requested && rowId.current !== null && requested === String(rowId.current) && latest.current.doc) {
setReady(true);
return;
}
setReady(false);
(async () => {
try {
/*
`new` is a design that does not exist yet, and it is NOT the same as no parameter at all.
No parameter means "whatever I had open", answered with `/documents/latest`. Asking for a
blank canvas that way returned the most recently updated design instead, and pointed the
next save at its id — so New reopened the thing you were already on and then edited it.
*/
const found =
requested === NEW_DOC ? null
: requested ? await clientRef.current.get<DocumentRow>(`/m3e-canvas/documents/${requested}`)
: await clientRef.current.get<DocumentRow | null>("/m3e-canvas/documents/latest");
if (cancelled) return;
rowId.current = found?.id ?? null;
latest.current = { doc: found?.doc ?? null, ui: found?.ui ?? null };
// The editor reads the document once, at mount. This is what makes it mount again.
setGeneration((n) => n + 1);
setError(null);
} catch {
if (!cancelled) setError("Could not open that design.");
} finally {
// `ready` gates the editor's first render: mounting it before the document has arrived would
// let it start empty and then immediately save that emptiness over the real one.
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [requested]);
// ── saving ──────────────────────────────────────────────────────────────────────────────────────
const flush = useCallback(async () => {
const { doc, ui } = latest.current;
if (!doc) return;
const body = { title: titleOf(doc), doc, ui };
try {
if (rowId.current === null) {
const created = await clientRef.current.post<DocumentRow>("/m3e-canvas/documents", body);
rowId.current = created.id;
// Put the new id in the URL so a reload returns to this design rather than to "latest".
setParams((p) => { p.set("doc", String(created.id)); return p; }, { replace: true });
} else {
await clientRef.current.put(`/m3e-canvas/documents/${rowId.current}`, body);
}
setError(null);
} catch {
setError("Changes are not being saved.");
}
}, [setParams]);
flushRef.current = flush;
const schedule = useCallback(() => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
}, [flush]);
// A pending save must not be lost to an unmount — closing the panel is exactly when the last edit
// is most likely to be sitting in the timer.
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
void flush();
},
[flush],
);
// ── the handoff, which is the point of the whole plugin ─────────────────────────────────────────
const handoff = useCallback((prompt: string, doc: Doc | null) => {
// Fire and forget: the export must not wait on a session starting, and a failure here should not
// stop the clipboard from being a working fallback. Returning false does exactly that.
void clientRef.current
.post("/m3e-canvas/_handoff", { prompt, title: titleOf(doc ?? latest.current.doc) })
.catch(() => setError("Could not hand that to the agent — it is on your clipboard instead."));
return false;
}, []);
// Built once. The editor reads this from context at mount, so an identity change mid-edit would
// remount it and lose the in-progress design.
const host = useMemo<HostAdapter>(
() => ({
loadDoc: () => latest.current.doc,
saveDoc: (doc) => {
latest.current.doc = doc;
schedule();
},
loadUi: () => latest.current.ui,
saveUi: (ui) => {
latest.current.ui = ui;
schedule();
},
onExport: handoff,
}),
[schedule, handoff],
);
if (!ready) return <Centred>Opening…</Centred>;
return (
<div className="relative size-full">
{error ? (
<div className="absolute inset-x-0 top-0 z-50 bg-destructive/90 px-3 py-1.5 text-center text-xs text-destructive-foreground">
{error}
</div>
) : null}
<HostProvider value={host}>
<Canvas key={generation} />
</HostProvider>
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">{children}</div>
);