Skip to content

fix(ui): load the diagram engine lazily, and keep the canvas controls out of the diagram; ui 0.41.1 - #1562

Merged
backnotprop merged 10 commits into
mainfrom
fix/diagram-lazy-closure
Sep 17, 2026
Merged

backnotprop merged 10 commits into
mainfrom
fix/diagram-lazy-closure

Conversation

@backnotprop

@backnotprop backnotprop commented Sep 17, 2026

Copy link
Copy Markdown
Owner

@plannotator/ui 0.41.1. Two fixes over 0.41.0, no API change: every export named in the HANDOFF's "Diagram engine (0.41.0)" section still resolves from the same path, and a host upgrading changes nothing.

1. The diagram engine rode every document read

Reported from Workspaces (a chunked build). Chain, verified here before touching anything:

Viewer.tsx:44-45  →  GraphvizBlock / MermaidBlock       (static)
DiagramBlock.tsx:10-11 →  DiagramPopout, DiagramViewer  (static, viewer used inline)
DiagramViewer.tsx:9    →  DiagramSourcePane             (static)
DiagramSourcePane.tsx:1-3 → @codemirror/{commands,state,view}

So any host that statically imports Viewer shipped the canvas, the overlay, the finders, the popout and CodeMirror on every document read, including markdown with no diagram — and since no host passes onSave for a fence, an editor that can never open. (The reporter's suggested one-lazy-around-DiagramPopout fix is not sufficient: the inline DiagramViewer still reaches DiagramSourcePane.)

Three edges are now React.lazy:

  • ViewerMermaidBlock / GraphvizBlock, one chunk each over a shared DiagramBlock chunk (chosen over one shared DiagramBlock chunk so each wrapper keeps its React.memo comparator and its test hooks; the no-diagram closure is identical either way). Suspense fallback is the block's own pending state — the source fence under "Rendering diagram…", data-mermaid-pending and role="status" intact — lifted into components/diagram/DiagramPending.tsx and rendered inside the same boxes, so nothing flashes and nothing jumps.
  • DiagramBlockDiagramPopout, mounted when Expand is pressed, fallback null.
  • DiagramViewerDiagramSourcePane, mounted only when the pane opens, fallback a box carrying the pane's own class list (never null, which would collapse the split).

svgContentSize moved to its own dependency-free module (it was dragging the whole canvas into DiagramBlock) and is re-exported from DiagramCanvas and the barrel, so both published paths are unchanged. Every other static importer was checked; the barrel is the only other one and it is not in Viewer's closure.

2. The canvas controls commented on the part behind them (owner report)

The canvas resolves a click over everything under the pointer (elementsFromPoint, node → edge → cluster) because the edge hit layer sits above the nodes. The chrome painted over the canvas is not in the svg, so the walk skipped it (wrapper.contains(el) is false for a control) and continued to the part underneath: pressing Zoom out over a node opened the composer on that node, and a press on the strip could start a pan.

A pointer event whose composed path contains a control surface now resolves no target, opens no composer and starts no pan (components/diagram/diagramControls.ts). Controls are marked data-diagram-control (the zoom strip, the popout header); button, [role=toolbar], inputs, the composer and the source pane count without marking. Release-over-a-control after a press that began on the canvas is handled too — targetUnder answering null would otherwise read as "comment on the whole diagram".

Numbers

Viewer's document-read closure (the thing the report is about — the entry chunk plus its static imports, minified, gzip -9):

raw gzip
main 2,300,456 980,254
branch 1,761,572 829,399
delta -538,884 -150,855 (-147.3 KB)

Portal build (apps/portal, the repo's one chunked build). Its entry already carries CodeMirror for the markdown editor, so it only sheds the diagram half:

entry chunk gzip -9
before 4,183,276 1,270,985
after 4,116,807 1,248,071
delta -66,469 -22,914

New lazy chunks (portal, raw / gzip): DiagramBlock 62,566 / 21,194 · DiagramSourcePane 4,348 / 1,878 · DiagramPopout 1,502 / 835 · MermaidBlock 571 / 320 · GraphvizBlock 506 / 302.

Single-file builds are byte-neutral (they inline everything, which is why they never showed the regression): apps/hook/dist/review.html 18,280,891 → 18,280,891, byte-identical; apps/hook/dist/index.html 24,661,766 → 24,664,307 (+2,541 B, +0.01%).

The test, and that it fails on main

packages/ui/components/Viewer.diagramClosure.test.ts bundles components/Viewer.tsx with the chunking bundler the portal build uses (Vite/rollup, write: false) and walks the entry chunk's static imports only — rollup reports imports and dynamicImports separately, so a lazy edge is a chunk boundary by construction rather than by parsing. It asserts the entry closure contains none of @codemirror/, cm-editor, DiagramSourcePane, DiagramPopout, data-diagram-canvas, getScreenCTM; that each of those is reachable off-entry (a lazy edge that loads nothing would pass the first half and break every diagram); and that the fence's pending state stays in the entry chunk. It runs in the plain bun test sweep (not DOM-gated), so no allowlist entry is needed.

On origin/main's sources with this test file added: 7 fail, 2 pass.

(fail) does not statically reach @codemirror/ (the Source pane editor)
(fail) does not statically reach cm-editor
(fail) does not statically reach DiagramSourcePane
(fail) does not statically reach data-diagram-canvas
(fail) does not statically reach getScreenCTM
(fail) still reaches the diagram engine through dynamic chunks
 2 pass  7 fail

On the branch: 9 pass, 0 fail.

The control fix has its own DOM test in DiagramViewer.test.tsx (a press at a point where a node sits under the zoom strip opens nothing; 1 px outside it the node opens the composer). Without the guard it fails Expected: 0 / Received: 1; with it, 20 pass in that file.

Verify

  • bun run typecheck — clean
  • bun test packages/ui/utils packages/core packages/shared — 1968 pass, 0 fail
  • DOM_TESTS=1 bun test --isolate packages/ui/components packages/ui/hooks packages/editor — 1014 pass, 0 fail
  • bun test scripts/dom-test-allowlist.test.ts, bun test tests/entry-assets.test.ts (after a fresh build) — pass
  • bun run --cwd packages/ui smoke:package — "Verified @plannotator/ui@0.41.1 packs, resolves …, and installs externally with @plannotator/core@0.25.4"
  • build:viewer + check:manifest — in sync
  • builds: review, hook, opencode, portal

Headless Chromium on the built plan editor (scratchpad/diagram-engine/fixture.md: two Mermaid fences + one dot fence), single-file build and the same App served as a chunked build (portal dist with /api proxied to the annotate server):

  • all three diagrams render inline, [data-diagram-pending] 0 after render, in both builds;
  • a node click opens the composer — On Reviewer (node U) · line 9;
  • a press on the zoom strip opens nothing (elementsFromPoint there reports button topmost, exactly the shape the unit test stubs);
  • the popout opens with its svg and closes on Escape — and on the chunked build DiagramPopout-*.js is requested only at that moment;
  • chunked, no diagram: document with no fence → diagram chunks requested: [];
  • chunked, with diagrams: ["MermaidBlock-*.js","DiagramBlock-*.js","GraphvizBlock-*.js"] — and DiagramSourcePane-*.js never, because no host passes onSave.

Withdrawn: "diagram comments lost on reload"

An earlier revision of this PR reported a pre-existing data-loss bug — a diagram comment not surviving a reload, after reload: {"badges":0,"marks":0,"cards":0} on this branch and on origin/main. That report was wrong, and it was my probe, not the app. lazyprobe/reload.mjs only clicked through first-run dialogs (Continue|Got it|Start reviewing|Done); the "Draft Recovered" modal answers to Restore/Dismiss, so it was never answered and the probe then counted an un-restored session. Its own screenshot shows the modal still open over the document, reading "Found 1 annotation from just now."

Driven end to end headless on the built plan editor (scratchpad/diagram-engine/fixture.md, isolated PLANNOTATOR_DATA_DIR) — comment on flowchart node D, comment on sequence message H->>S, reload, Restore, 10/10:

  • draft POST carries both anchors (flowchart/node/D, sequence/edge/msg-1);
  • after reload: 2 cards listed, 2 badges, one per diagram, on the right targets, no "Unanchored" chip;
  • export carries both location lines: Diagram node Approve? (D), line 9 and Diagram edge plan (stdin JSON) (msg-1), line 24.

The same probe with the one-line fix applied (wait for the modal, then answer it) flips from {"badges":0,"marks":0,"cards":0,"draftModalOpen":true} to {"badges":1,"marks":1,"cards":1,"draftModalOpen":false} against an unchanged binary — the whole delta was the un-answered modal. The #1560 parity driver's reload step was fixed the same way: it now answers the modal, waits for it to detach, and asserts the card count and the badge count explicitly instead of letting a waitFor throw out of the run.

The mount-order window the lazy wrappers open is real, so it was proved rather than assumed: on the chunked portal build with MermaidBlock/GraphvizBlock/DiagramBlock chunks delayed 8 s and Restore clicked while all three fences were still [data-diagram-pending], the card is present immediately (cards: 1, badges: 0, pending: 3) and the badge arrives with the chunk (cards: 1, badges: 1, marks: 1), chip-free throughout.

Two tests pin it (no product change was needed):

  • components/Viewer.diagramLazyRestore.test.tsx (new, allowlisted) — Viewer + AnnotationPanel with the row seeded before the engine arrives, engine held open on a gated runtime loader: the row is listed, nothing reports it unanchored while the block is pending, the badge and mark appear on arrival. Negative control: keying Viewer's "no diagram in this document" report on what has MOUNTED rather than on the parse fails it.
  • hooks/useAnnotationHighlighter.diagramSkip.test.tsx — extended with the shape that has no quote and no blockId (a whole-diagram or label-less anchor, or an agent-posted row), which is exactly what the text pass would otherwise call unrestorable. Negative control: dropping the diagramAnchor skip fails both cases.

For hosts

Nothing to change. DiagramViewer imported directly still gives a working viewer — its pane simply arrives one chunk later. The diagram barrel additionally exports DiagramPending / DiagramInlineSource / DiagramBlockPending and isDiagramControlEvent / DIAGRAM_CONTROL_SELECTOR; host chrome drawn inside the canvas should carry data-diagram-control. Core is unchanged at 0.25.4, so 0.41.1 publishes alone.

The document's fence block needs the rendered diagram's intrinsic size to
size its inline box, and nothing else of the canvas. Importing it from
DiagramCanvas dragged the zoom/pan surface, its viewport hook and lucide
into every closure that touched a diagram block. DiagramCanvas re-exports
it, so components/diagram/DiagramCanvas and the barrel still resolve it.
The pane is CodeMirror, and a viewer that can never open one -- every
fence in a Plannotator document today, since no host passes onSave --
carried the whole editor anyway. React.lazy behind the existing
hasPane && sourceOpen condition; the Suspense fallback is a box with the
pane's own class list rather than null, so the split it opens into is
already the right size and never collapses back onto the canvas.

A host that imports DiagramViewer directly still gets a working pane; it
arrives one chunk later.
The full-size popout is only ever reached by pressing Expand. React.lazy
with a null fallback: the overlay has simply not opened yet, so nothing
in the document flow moves.

The block's pending state (the source fence under "Rendering diagram…",
data-mermaid-pending and all) moves to its own dependency-free module,
because it is about to become the Suspense fallback the document shows
while the block's own chunk loads -- one pending state for both waits.
Viewer imported MermaidBlock and GraphvizBlock statically, so every host
that reads a document shipped the renderer slot, the canvas, the comment
overlay, the finders and the projection whether or not the document had
a fence. Both blocks are now React.lazy, one chunk each over a shared
DiagramBlock chunk.

The Suspense fallback is the block's own pending state in the block's own
boxes (DiagramBlockPending), so the source fence paints with the document
and the two waits -- the block chunk, then the engine -- read as one.
Bundles Viewer with the chunking bundler the portal build uses and walks
the entry chunk's static imports only (rollup reports imports and
dynamicImports separately, so a lazy edge is a chunk boundary by
construction). Asserts the entry closure reaches no CodeMirror, Source
pane, popout, canvas or projection, that each of those is still reachable
off-entry, and that the fence's pending state stays in the entry chunk.

Single-file builds inline everything and can never show this regression,
which is why it needs its own check.
…on the part behind it

The zoom strip, the composer and the popout header are painted over the
canvas and are not in the svg, so the elementsFromPoint walk (node, then
edge, then cluster) stepped straight past them to whatever part sat
underneath: pressing Zoom out over a node opened the composer on that
node, and a press on the strip could start a pan.

A pointer event whose composed path contains a control surface now
resolves no target, opens no composer and starts no pan. Controls are
marked with data-diagram-control; buttons, toolbars, inputs, the
composer and the source pane count without marking.

Released over a control after a press that began on the canvas is
handled too: targetUnder answering null would otherwise read as
'comment on the whole diagram'.
Lazy diagram engine and the canvas-control fix. Core stays at 0.25.4:
nothing under packages/core moved, so 0.41.1 publishes alone.

Every export named in the 0.41.0 notes still resolves from the same path;
the diagram barrel additionally exports the new pending components and
the control predicate.
Making MermaidBlock / GraphvizBlock lazy opens a window in which the
document has painted and the draft has restored but no diagram exists in
the DOM. A comment on a diagram part has no text to fall back on, so
anything that drops or mis-reports it in that window is data loss.

The test mounts Viewer (real lazy edges) beside AnnotationPanel with the
row already seeded, holds the engine open on a gated runtime loader, and
asserts the panel lists the row, nothing reports it unanchored while the
block is still pending, and the badge and mark appear once the engine
arrives -- no second restore pass, no reload.

Negative control: making Viewer's "no diagram in this document" report key
on what has MOUNTED rather than on the parse fails it on the unanchored
assertion.
…m row

A comment on a whole diagram (or a label-less part) carries an empty
originalText, and one posted by an agent that named no fence carries
blockId ''. That is exactly the shape the text restore pass treats as
unrestorable: findTextInDOM('') matches nothing and the blockId names
nothing. The diagramAnchor skip has to come first, or such a comment
returns from a reload wearing the "Unanchored" chip while the diagram
overlay is showing it perfectly well.

Negative control: dropping the skip reports both diagram rows as attempted.
A report of diagram comments lost across a reload did not hold -- the probe
behind it never answered the "Draft Recovered" modal and then counted an
un-restored session -- but the window it pointed at is real and is widened
by the lazy block wrappers. Name the three properties that keep it safe and
the tests that now pin each of them.
@backnotprop
backnotprop merged commit 3a497cf into main Sep 17, 2026
28 checks passed
@backnotprop
backnotprop deleted the fix/diagram-lazy-closure branch September 17, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant