diff --git a/packages/joint-react/src/components/graph/__tests__/external-graph-undo.test.tsx b/packages/joint-react/src/components/graph/__tests__/external-graph-undo.test.tsx new file mode 100644 index 0000000000..b542d33279 --- /dev/null +++ b/packages/joint-react/src/components/graph/__tests__/external-graph-undo.test.tsx @@ -0,0 +1,305 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider, Paper } from '../..'; +import { useCell } from '../../../hooks/use-cell'; +import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model'; +import { DEFAULT_CELL_NAMESPACE } from '../../../store/graph-store'; +import { graphProjection } from '../../../store/graph-projection'; +import type { Computed, ElementRecord } from '../../../types/cell.types'; + +const PAPER_STYLE = { width: 400, height: 400 } as const; + +/** Content mounts recorded per cell id — the "did renderElement paint" probe. */ +const mounts: string[] = []; + +function SubscribingNode() { + const label = useCell( + (cell: Computed>) => cell.data.label + ); + mounts.push(label); + return {label}; +} +const renderSubscribing = () => ; + +function elementJSON(id: string, x = 0, y = 0): dia.Cell.JSON { + return { + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y }, + size: { width: 20, height: 20 }, + data: { label: `label-${id}` }, + }; +} + +function linkJSON(id: string, source: string, target: string): dia.Cell.JSON { + return { + id, + type: 'standard.Link', + source: { id: source }, + target: { id: target }, + }; +} + +function createExternalGraph(): dia.Graph { + return new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); +} + +/** + * CommandManager-style undo of a delete: re-add the removed cells from their + * stored JSON inside a plain batch — byte-identical to what was removed, + * which is exactly what makes `mergeCellRecord` hit its identity fast-path + * if a stale record survived the delete. + */ +function undoDelete(graph: dia.Graph, cells: dia.Cell.JSON[]): void { + graph.startBatch('undo'); + graph.addCells(cells); + graph.stopBatch('undo'); +} + +// Customer scenario (externally-owned graph, imperative mutations, undo via +// CommandManager): delete an element that has a link, then undo. The restored +// element must render its React content again and the restored link must be +// visible — not a positioned-but-empty ghost. +describe('external graph — undo of a delete (element with a link)', () => { + beforeEach(() => { + mounts.length = 0; + }); + + it('projection prunes the removed element AND its link from the container', async () => { + const graph = createExternalGraph(); + const projection = graphProjection({ graph }); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0), linkJSON('l1', 'e1', 'e2')]); + await act(async () => {}); // flush the scheduler microtask + projection.syncFromGraph(); + expect(projection.cells.getSize()).toBe(3); + + graph.getCell('e1').remove(); + await act(async () => {}); + + // The graph holds one cell; the container must agree — a stale record + // here turns the undo's re-add into a no-op update. + expect(graph.getCells().length).toBe(1); + expect(projection.cells.has('e1')).toBe(false); + expect(projection.cells.has('l1')).toBe(false); + expect(projection.cells.getSize()).toBe(1); + projection.destroy(); + }); + + it('a layout:update naming a removed cell must not resurrect its record', async () => { + // The genesis of the customer's stale records: `layout:update` entries + // (emitted by `setPaperViews` re-broadcasting view mounts, and by app + // layout pipelines applying ELK results) are written to the container + // WITHOUT checking the cell still lives in the graph. A delete that lands + // between computing such a batch and applying it resurrects the record — + // and from then on every undo re-add is a "no-op update" that never + // notifies membership, so the cell never renders again. + const graph = createExternalGraph(); + const projection = graphProjection({ graph }); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0), linkJSON('l1', 'e1', 'e2')]); + await act(async () => {}); + projection.syncFromGraph(); + + const elementCell = graph.getCell('e1'); + const linkCell = graph.getCell('l1'); + elementCell.remove(); + await act(async () => {}); + expect(projection.cells.getSize()).toBe(1); + + // Apply a stale layout batch that still references the removed cells + // (their models are detached but alive — exactly what a pending + // view-mount notification or an in-flight ELK result holds). + graph.trigger('layout:update', { + changes: new Map([ + ['e1', { type: 'change', data: elementCell }], + ['l1', { type: 'change', data: linkCell }], + ['e2', { type: 'change', data: graph.getCell('e2') }], + ]), + }); + await act(async () => {}); + + expect(projection.cells.has('e1')).toBe(false); + expect(projection.cells.has('l1')).toBe(false); + expect(projection.cells.getSize()).toBe(1); + projection.destroy(); + }); + + it('a delayed entry holding the OLD model cannot overwrite a re-added replacement', async () => { + // After undo installs a replacement model under the same id, a delayed + // `layout:update` entry may still reference the old detached model with + // stale attributes. The projection must snapshot the graph's CURRENT + // model, never the entry's reference. + const graph = createExternalGraph(); + const projection = graphProjection({ graph }); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0)]); + await act(async () => {}); + projection.syncFromGraph(); + + const oldModel = graph.getCell('e1'); + oldModel.remove(); + await act(async () => {}); + + // Undo-style re-add: a NEW model under the same id, moved elsewhere. + graph.addCell(elementJSON('e1', 300, 300)); + await act(async () => {}); + + graph.trigger('layout:update', { + changes: new Map([['e1', { type: 'change', data: oldModel }]]), + }); + await act(async () => {}); + + const record = projection.cells.get('e1') as { position?: { x: number; y: number } }; + expect(record?.position).toEqual({ x: 300, y: 300 }); + projection.destroy(); + }); + + it('a data-less remove entry for an already-removed cell adds no phantom id to the delta', async () => { + // The paper's view-unmount notification for a deleted cell arrives in a + // later `layout:update` batch, after the graph removal already reported + // the id. Removing "again" must not re-report the id in whatever + // incremental delta flushes next — consumers mirroring an external store + // would receive stale removals in unrelated batches. + const graph = createExternalGraph(); + const deltas: string[][] = []; + const projection = graphProjection({ + graph, + onIncrementalCellsChange: ({ removed }) => deltas.push([...removed].map(String)), + }); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0)]); + await act(async () => {}); + projection.syncFromGraph(); + + graph.getCell('e1').remove(); + await act(async () => {}); + expect(deltas.pop()).toContain('e1'); + + // View-unmount re-broadcast for the deleted cell (no data, inside batch), + // then an unrelated move that flushes the next delta. + graph.trigger('layout:update', { + changes: new Map([['e1', { type: 'remove' }]]), + }); + (graph.getCell('e2') as dia.Element).position(120, 0); + await act(async () => {}); + + const flushed = deltas.flat(); + expect(flushed).not.toContain('e1'); + projection.destroy(); + }); + + it('a data-less remove entry for a LIVE cell (view unmount) keeps its record', async () => { + // Paper view-unmount notifications re-broadcast through `layout:update` + // carry `{ type: 'remove' }` with no cell reference — and can name a cell + // the paper merely unmounted (viewport culling) while the graph still + // holds it. That must never delete the live cell's record. + const graph = createExternalGraph(); + const projection = graphProjection({ graph }); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0), linkJSON('l1', 'e1', 'e2')]); + await act(async () => {}); + projection.syncFromGraph(); + expect(projection.cells.getSize()).toBe(3); + + graph.trigger('layout:update', { + changes: new Map([['e1', { type: 'remove' }]]), + }); + await act(async () => {}); + + expect(projection.cells.has('e1')).toBe(true); + expect(projection.cells.getSize()).toBe(3); + projection.destroy(); + }); + + it('undo still paints after a stale layout:update raced the delete', async () => { + // Full customer symptom: stale record present → undo re-adds the + // byte-identical cell → mergeCellRecord identity fast-path → no version + // bump, no membership change → renderElement never called, link hidden. + const graph = createExternalGraph(); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0), linkJSON('l1', 'e1', 'e2')]); + + const { container } = render( + + + + ); + await waitFor(() => { + expect(container.textContent).toContain('label-e1'); + }); + + const elementCell = graph.getCell('e1'); + const linkCell = graph.getCell('l1'); + const removedJSON = [elementCell.toJSON(), linkCell.toJSON()]; + + await act(async () => { + elementCell.remove(); + // The race: a view-mount / layout notification for the removed cells + // flushes after the remove in the same microtask cascade. + graph.trigger('layout:update', { + changes: new Map([ + ['e1', { type: 'change', data: elementCell }], + ['l1', { type: 'change', data: linkCell }], + ]), + }); + }); + await waitFor(() => { + expect(container.textContent).not.toContain('label-e1'); + }); + + mounts.length = 0; + await act(async () => { + undoDelete(graph, [removedJSON[0], removedJSON[1]]); + }); + + await waitFor(() => { + expect(container.textContent).toContain('label-e1'); + }); + expect(mounts).toContain('label-e1'); + + await waitFor(() => { + const linkNode = container.querySelector('[model-id="l1"]') as HTMLElement | null; + expect(linkNode).not.toBeNull(); + expect(linkNode!.style.visibility).not.toBe('hidden'); + }); + }); + + it('re-renders the element and shows the link after delete + undo', async () => { + const graph = createExternalGraph(); + graph.addCells([elementJSON('e1'), elementJSON('e2', 100, 0), linkJSON('l1', 'e1', 'e2')]); + + const { container } = render( + + + + ); + + await waitFor(() => { + expect(container.textContent).toContain('label-e1'); + expect(container.textContent).toContain('label-e2'); + }); + + // Delete the element — JointJS removes its connected link with it. + const removedJSON = [graph.getCell('e1').toJSON(), graph.getCell('l1').toJSON()]; + await act(async () => { + graph.getCell('e1').remove(); + }); + await waitFor(() => { + expect(container.textContent).not.toContain('label-e1'); + }); + + // Undo: restore element first, then the link (CommandManager order). + mounts.length = 0; + await act(async () => { + undoDelete(graph, [removedJSON[0], removedJSON[1]]); + }); + + // The restored element paints again… + await waitFor(() => { + expect(container.textContent).toContain('label-e1'); + }); + expect(mounts).toContain('label-e1'); + + // …and the restored link is visible (not parked hidden forever). + await waitFor(() => { + const linkNode = container.querySelector('[model-id="l1"]') as HTMLElement | null; + expect(linkNode).not.toBeNull(); + expect(linkNode!.style.visibility).not.toBe('hidden'); + }); + }); +}); diff --git a/packages/joint-react/src/components/paper/__tests__/pending-links-delayed-content.test.tsx b/packages/joint-react/src/components/paper/__tests__/pending-links-delayed-content.test.tsx new file mode 100644 index 0000000000..9f0fd21f87 --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/pending-links-delayed-content.test.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider, Paper } from '../../..'; +import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model'; +import { PaperView } from '../../../mvc/paper'; +import { DEFAULT_CELL_NAMESPACE } from '../../../store/graph-store'; + +const PAPER_STYLE = { width: 400, height: 400 } as const; + +// Reveal setters captured per mounted node so the test can flip content +// through CHILD-ONLY state updates — the parent portal hook must not +// re-render for the link to become visible. +const reveals: Array<(ready: boolean) => void> = []; + +function DelayedNode() { + const [ready, setReady] = useState(false); + reveals.push(setReady); + return ready ? content : null; +} +const renderDelayed = () => ; + +function elementJSON(id: string, x: number): dia.Cell.JSON { + return { + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 20, height: 20 }, + }; +} + +// A link is parked with `visibility: hidden` while its endpoints' React +// content has not painted. Content that arrives LATER through a child-only +// update (local state, Suspense) mounts into the portal without re-rendering +// the portal hook and without any joint render — the link must still be +// revealed. +describe('pending links — endpoint content mounting after a delay', () => { + it('reveals the link once delayed endpoint content paints', async () => { + reveals.length = 0; + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + graph.addCells([ + elementJSON('e1', 0), + elementJSON('e2', 100), + { id: 'l1', type: 'standard.Link', source: { id: 'e1' }, target: { id: 'e2' } }, + ]); + + const { container } = render( + + + + ); + + // Both endpoints render null → the link parks hidden. + await waitFor(() => { + expect(reveals.length).toBeGreaterThanOrEqual(2); + const linkNode = container.querySelector('[model-id="l1"]') as HTMLElement | null; + expect(linkNode).not.toBeNull(); + expect(linkNode!.style.visibility).toBe('hidden'); + }); + + // Child-only updates: only the node components re-render. + await act(async () => { + for (const reveal of reveals) reveal(true); + }); + + await waitFor(() => { + const linkNode = container.querySelector('[model-id="l1"]') as HTMLElement | null; + expect(linkNode!.style.visibility).not.toBe('hidden'); + }); + }); + + it('parking many links stays linear in portal lookups', async () => { + // Regression guard for the O(N²) shape: observing endpoints must not + // re-walk every already-parked link (each walk pays portal lookups per + // link). With N chained links, linear bookkeeping needs a few lookups + // per link; the quadratic walk needs ~N²/2 and blows the budget. + reveals.length = 0; + const LINKS = 300; + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + const cells: dia.Cell.JSON[] = []; + for (let index = 0; index <= LINKS; index += 1) cells.push(elementJSON(`e${index}`, index * 30)); + for (let index = 0; index < LINKS; index += 1) { + cells.push({ + id: `l${index}`, + type: 'standard.Link', + source: { id: `e${index}` }, + target: { id: `e${index + 1}` }, + }); + } + graph.addCells(cells); + + const spy = jest.spyOn(PaperView.prototype, 'getCellViewPortalNode'); + const { container } = render( + + + + ); + await waitFor(() => { + const linkNode = container.querySelector('[model-id="l0"]') as HTMLElement | null; + expect(linkNode!.style.visibility).toBe('hidden'); + }); + + expect(spy.mock.calls.length).toBeLessThan(LINKS * 20); + spy.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/mvc/paper.ts b/packages/joint-react/src/mvc/paper.ts index 21a61b2651..a2ac0248ec 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -26,6 +26,15 @@ export class PaperView extends Paper { private readonly shouldPreserveHostElementOnRemove: boolean; private readonly portalSelector: PortalSelector | undefined; private pendingLinks: Set = new Set(); + // Portal nodes of parked links' not-ready endpoints. React mounts portal + // content outside joint's render cycle (child state, Suspense), so a DOM + // mutation is the only reliable "content painted" signal. + private portalObserver: MutationObserver | null = null; + private observedPortalNodes: Set = new Set(); + // Element id → number of parked links waiting on it. Lets a park and an + // element mount each do O(1) observation work instead of re-walking every + // pending link (which made a mount of N parked links O(N²)). + private pendingEndpointIds: Map = new Map(); constructor(options: PaperViewOptions) { const { onViewMountChange, portalSelector, id, ...paperOptions } = options; @@ -142,6 +151,35 @@ export class PaperView extends Paper { return this.isElementReady(endId); } + private bumpPendingEndpoint(endId: CellId | undefined, delta: 1 | -1): void { + if (!endId) return; + const count = (this.pendingEndpointIds.get(endId) ?? 0) + delta; + if (count <= 0) this.pendingEndpointIds.delete(endId); + else this.pendingEndpointIds.set(endId, count); + } + + /** + * Observe one parked endpoint's portal node, so content mounted by a + * child-only React update still triggers a recheck. O(1) per call — a + * park observes its own two endpoints, an element mount observes itself. + */ + private observeEndpoint(endId: CellId | undefined): void { + if (!endId) return; + const elementView = this.getElementView(endId); + if (!elementView?.el) return; + const portalNode = this.getCellViewPortalNode(elementView); + if (!portalNode || this.observedPortalNodes.has(portalNode)) return; + this.portalObserver ??= new MutationObserver(() => this.checkPendingLinks()); + this.portalObserver.observe(portalNode, { childList: true }); + this.observedPortalNodes.add(portalNode); + } + + private disconnectPortalObserver(): void { + this.portalObserver?.disconnect(); + this.observedPortalNodes.clear(); + this.pendingEndpointIds.clear(); + } + /** * Check pending links and show them if their source/target are ready. */ @@ -169,7 +207,12 @@ export class PaperView extends Paper { if (linkView?.el) { linkView.el.style.visibility = ''; } + const link = linkView?.model; + this.bumpPendingEndpoint(link?.source().id, -1); + this.bumpPendingEndpoint(link?.target().id, -1); } + + if (this.pendingLinks.size === 0) this.disconnectPortalObserver(); } public onViewMountChangeFlush() { @@ -196,7 +239,12 @@ export class PaperView extends Paper { } if (cell.isLink()) { - this.pendingLinks.delete(cellId); + if (this.pendingLinks.delete(cellId)) { + const link = cell as dia.Link; + this.bumpPendingEndpoint(link.source().id, -1); + this.bumpPendingEndpoint(link.target().id, -1); + if (this.pendingLinks.size === 0) this.disconnectPortalObserver(); + } this.viewChanges.set(cellId, { type: 'remove' }); this.onViewMountChangeFlush(); } @@ -215,7 +263,12 @@ export class PaperView extends Paper { if (view.model.isElement()) { this.viewChanges.set(cellId, { type: 'add', data: view.model }); this.onViewMountChangeFlush(); - this.checkPendingLinks(); + // Only when parked links wait on THIS element (a link can park before + // its endpoint's view exists) — keeps unrelated element mounts O(1). + if (this.pendingEndpointIds.has(cellId)) { + this.checkPendingLinks(); + if (this.pendingEndpointIds.has(cellId)) this.observeEndpoint(cellId); + } return; } @@ -227,7 +280,15 @@ export class PaperView extends Paper { if (!isSourceReady || !isTargetReady) { view.el.style.visibility = 'hidden'; - this.pendingLinks.add(cellId); + if (!this.pendingLinks.has(cellId)) { + this.pendingLinks.add(cellId); + const sourceId = link.source().id; + const targetId = link.target().id; + this.bumpPendingEndpoint(sourceId, 1); + this.bumpPendingEndpoint(targetId, 1); + this.observeEndpoint(sourceId); + this.observeEndpoint(targetId); + } } this.viewChanges.set(cellId, { type: 'add', data: view.model }); @@ -246,6 +307,7 @@ export class PaperView extends Paper { } public remove() { + this.disconnectPortalObserver(); // call CLEANUP_EVENT_NAME for any listeners that need to clean up before the paper is removed this.trigger(CLEANUP_EVENT_NAME); super.remove(); diff --git a/packages/joint-react/src/store/__tests__/graph-projection-edge-cases.test.ts b/packages/joint-react/src/store/__tests__/graph-projection-edge-cases.test.ts index 305ab51cbc..a45901c32e 100644 --- a/packages/joint-react/src/store/__tests__/graph-projection-edge-cases.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-projection-edge-cases.test.ts @@ -87,13 +87,17 @@ describe('graphProjection — incremental remove of element with connected links ]); await flush(); - // Build a synthetic change-set carrying just the element 'a' as a - // remove. Because the graph still owns the link 'l1' at the moment - // the LAYOUT_UPDATE_EVENT fires, the for-of over getConnectedLinks - // walks the link removal branch (lines 221-223). - const elementA = graph.getCell('a') as dia.Element; - const layoutChanges = new Map([['a', { type: 'remove' as const, data: elementA }]]); - graph.trigger('layout:update', { changes: layoutChanges }); + // Lose the link's own remove event (flagged `isUpdateFromReact`, which + // graph-changes skips) so its record lingers in the container — the + // "missed link removal" the element-remove sweep exists for. Removing + // the element afterwards must prune the stranded link record even + // though `getConnectedLinks` can no longer name it (both cells are out + // of the graph by then). + graph.getCell('l1').remove({ isUpdateFromReact: true } as dia.Cell.DisconnectableOptions); + await flush(); + expect(view.cells.has('l1')).toBe(true); + + (graph.getCell('a') as dia.Element).remove(); await flush(); expect(view.cells.has('a')).toBe(false); diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 0a52bf5c4a..85726b177e 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -125,13 +125,31 @@ export function graphProjection< graph, onElementsSizeChange, onChanges: ({ changes, isInsideBatch, deferCommit, isReset }) => { + // Elements removed in this batch — swept once after the loop for link + // records they may have stranded. + let removedElementIds: Set | undefined; + for (const [id, change] of changes) { const { data, type } = change; switch (type) { case 'add': case 'change': { + // An entry can outlive its cell: `layout:update` batches hold + // direct cell references, and a removal (or a remove + re-add of + // the same id) can land before the entry processes. Writing the + // entry's detached model would resurrect or overwrite the record + // with stale state, so always snapshot the graph's CURRENT model + // — and repair a lingering record when the cell is gone. + const cell = graph.getCell(id); + if (!cell) { + if (currentRecord(id) !== undefined) { + stageRemove(id); + if (trackChanges) removed!.add(id); + } + continue; + } const isAdd = type === 'add'; - stageWrite(data, isAdd); + stageWrite(cell, isAdd); if (trackChanges) { // Report every primary add/change in the incremental delta using // the final staged record — NOT gated on whether this stageWrite @@ -149,27 +167,57 @@ export function graphProjection< // or resized — its links' routes need re-snapshotting). Swept links // go to the container only, not the incremental callback — matching // the previous behaviour. - if (!isAdd && data.isElement()) { - for (const link of graph.getConnectedLinks(data)) stageWrite(link, false); + if (!isAdd && cell.isElement()) { + for (const link of graph.getConnectedLinks(cell)) stageWrite(link, false); } break; } case 'remove': { - if (!data) continue; + // Only a graph removal when the cell is actually gone — a paper + // view-unmount notification (no cell reference) can name a cell + // the paper merely unmounted, e.g. viewport culling. A cell with + // no record left (already removed) reports nothing — re-reporting + // ids in later deltas would feed consumers stale removals. + if (graph.getCell(id)) continue; + if (currentRecord(id) === undefined) continue; stageRemove(id); if (trackChanges) removed!.add(id); - if (data.isElement()) { - // Connected links are also removed by JointJS — mirror that. - for (const link of graph.getConnectedLinks(data)) { - stageRemove(link.id); - if (trackChanges) removed!.add(link.id); - } + if (data?.isElement()) { + removedElementIds ??= new Set(); + removedElementIds.add(id); } break; } } } + // Connected links normally arrive as their own `remove` entries, but a + // missed one must not strand a record — the element is already out of + // the graph adjacency, so `getConnectedLinks` cannot name its links. + // One pass per removal batch over the committed snapshot + pending + // stages; links the graph still holds are kept. + if (removedElementIds !== undefined) { + const elementIds = removedElementIds; + const referencesRemoved = (end: LinkJSONInit['source']): boolean => + typeof end === 'object' && end?.id !== undefined && elementIds.has(end.id); + const staleLinkIds: CellId[] = []; + const collectStaleLink = (item: Element | Link): void => { + const { id: linkId, source, target } = item as LinkJSONInit; + if (linkId === undefined) return; + // Cheap Set checks first — `getCell` only runs for real candidates. + if (!referencesRemoved(source) && !referencesRemoved(target)) return; + if (graph.getCell(linkId)) return; + staleLinkIds.push(linkId); + }; + for (const item of cells.getSnapshot()) collectStaleLink(item); + for (const item of pendingAdded.values()) collectStaleLink(item); + for (const item of pendingChanged.values()) collectStaleLink(item); + for (const linkId of staleLinkIds) { + stageRemove(linkId); + if (trackChanges) removed!.add(linkId); + } + } + // A bulk `reset` sends only `add`s for the surviving cells and no per-cell // `remove`s, so prune any committed cell the reset dropped — otherwise // reactive readers (`useCells`) keep counting ghost cells the canvas