From 050176fd5e7102ebbd093818dff8c1f0eb287be3 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Thu, 24 Sep 2026 17:14:11 +0200 Subject: [PATCH 1/6] test(joint-react): specify how many events useOnElementsMeasured delivers Describes the behaviour the hook needs for its stated purpose, running a layout once element sizes are known: one settled change delivers exactly one event, where settled means every element in the graph has a size. Four of the eight cases fail today. All four involve an element added without a size: the event arrives while the element is still at its `{0,0}` default, and a second one arrives after it is measured. A layout driven by this hook therefore runs twice, the first time on an element with no size. No fix here. The tests are the specification. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx new file mode 100644 index 0000000000..35554a64bf --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -0,0 +1,214 @@ +/** + * Specification for how many events `useOnElementsMeasured` delivers. + * + * The hook exists so an application can run a layout once element sizes are + * known. That only works if one settled change delivers exactly one event: a + * layout must not run while a newly added element is still at its `{0,0}` + * default, and it must not run several times for the same change. + * + * "Settled" means every element in the graph has a size. An element added with + * a size is settled on arrival; one added without a size settles when the + * measurement pipeline writes its measured size, which these tests simulate + * with an `autoSize` write, the same option `GraphStore` uses. + * + * The probe is mounted beside ``, not inside `renderElement`, so exactly + * one instance of the hook is under test. Mounting it inside `renderElement` + * would create one instance per element and count their separate events. + * + * These tests describe intended behaviour and several of them fail today. + */ +import { render, waitFor, act } from '@testing-library/react'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { Paper } from '../../components/paper/paper'; +import { useOnElementsMeasured } from '../use-on-elements-measured'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../../store/graph-store'; +import type { ElementsMeasuredParams } from '../use-on-elements-measured'; +import type { CellRecord } from '../../types/cell.types'; +import type { dia } from '@joint/core'; + +const PAPER_ID = 'events-paper'; +const PAPER_STYLE = { width: 100, height: 100 }; +const renderElement = () => ; + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); + +/** An element that arrives with a size: nothing to measure. */ +const sized = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + }) as CellRecord; + +/** An element that arrives without a size: `ElementModel` defaults it to `{0,0}`. */ +const unsized = (id: string): CellRecord => + ({ id, type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } }) as CellRecord; + +interface Harness { + readonly graph: dia.Graph; + readonly events: ElementsMeasuredParams[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph(initialCells: CellRecord[]): Harness { + const events: ElementsMeasuredParams[] = []; + let graph: dia.Graph | undefined; + + function Probe() { + const { graph: currentGraph } = useGraphStore(); + graph = currentGraph; + useOnElementsMeasured(PAPER_ID, (params) => { + events.push(params); + }); + return null; + } + + render( + + + + + ); + + return { graph: graph as dia.Graph, events }; +} + +/** Simulates the measurement pipeline writing a measured size. */ +function measure(graph: dia.Graph, ...ids: string[]) { + act(() => { + for (const id of ids) { + (graph.getCell(id) as dia.Element).set( + 'size', + { width: 120, height: 40 }, + { [AUTO_SIZE_OPTION]: true } as object + ); + } + }); +} + +/** Waits for the seed pass, then clears it so a test counts only its own events. */ +async function settleAndClear(harness: Harness) { + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + harness.events.length = 0; +} + +describe('useOnElementsMeasured — one event per settled change', () => { + it('delivers one event for the seed pass', async () => { + const harness = renderGraph([sized('a')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('delivers one event when an element that needs no measuring is added', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(sized('b') as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers no event while an added element is still unmeasured', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(unsized('b') as never); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + }); + + it('delivers one event once the added element has been measured', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(unsized('b') as never); + }); + await flush(); + measure(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for two added elements that both arrive with a size', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([sized('b'), sized('c')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for two added elements that both need measuring', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([unsized('b'), unsized('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + measure(harness.graph, 'b', 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for a mixed batch, once the unmeasured element is measured', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([sized('b'), unsized('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + measure(harness.graph, 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('reports isInitial on the first event only', async () => { + const harness = renderGraph([sized('a')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + act(() => { + harness.graph.addCell(unsized('b') as never); + }); + await flush(); + measure(harness.graph, 'b'); + await flush(); + + act(() => { + harness.graph.addCell(sized('c') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); +}); From 20a7707c397b239c32189ec3b756c28a52e211ba Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Thu, 24 Sep 2026 17:44:20 +0200 Subject: [PATCH 2/6] test(joint-react): specify isInitial across a graph reset A reset replaces the diagram, so the next pass is that diagram's first one: a consumer that fits the paper on `isInitial` has new contents to fit. Today the flag never comes back, because the hook only restarts its history when the effect re-runs, and a reset leaves the paper and the graph identity untouched. Also records only the asserted part of each payload, so a failure prints two booleans rather than the whole paper. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index 35554a64bf..77e19e6759 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -24,7 +24,6 @@ import { useOnElementsMeasured } from '../use-on-elements-measured'; import { useGraphStore } from '../use-graph-store'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; import { AUTO_SIZE_OPTION } from '../../store/graph-store'; -import type { ElementsMeasuredParams } from '../use-on-elements-measured'; import type { CellRecord } from '../../types/cell.types'; import type { dia } from '@joint/core'; @@ -47,21 +46,29 @@ const sized = (id: string): CellRecord => const unsized = (id: string): CellRecord => ({ id, type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } }) as CellRecord; +/** + * One delivered event, reduced to what these tests assert on. Keeping the full + * payload would print the whole `paper` on any failure. + */ +interface RecordedEvent { + readonly isInitial: boolean; +} + interface Harness { readonly graph: dia.Graph; - readonly events: ElementsMeasuredParams[]; + readonly events: RecordedEvent[]; } /** Renders a graph with one hook instance mounted beside the paper. */ function renderGraph(initialCells: CellRecord[]): Harness { - const events: ElementsMeasuredParams[] = []; + const events: RecordedEvent[] = []; let graph: dia.Graph | undefined; function Probe() { const { graph: currentGraph } = useGraphStore(); graph = currentGraph; - useOnElementsMeasured(PAPER_ID, (params) => { - events.push(params); + useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { + events.push({ isInitial }); }); return null; } @@ -212,3 +219,56 @@ describe('useOnElementsMeasured — one event per settled change', () => { expect(harness.events[0].isInitial).toBe(true); }); }); + +describe('useOnElementsMeasured — a graph reset starts a new measurement history', () => { + // Resetting the graph replaces the diagram, so the next pass is that + // diagram's first one: a consumer that fits the paper on `isInitial` has a + // new set of contents to fit. + it('reports isInitial again after the graph is reset with sized elements', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([sized('x'), sized('y')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('reports isInitial again after a reset, once the new elements are measured', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([unsized('x'), unsized('y')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + measure(harness.graph, 'x', 'y'); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('reports isInitial once per reset, not on later changes', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([sized('x')] as never); + }); + await flush(); + act(() => { + harness.graph.addCell(sized('y') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); +}); From 6d51f670c4e43d479e616e4b48d4aecdcc3d05a7 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Thu, 24 Sep 2026 17:50:56 +0200 Subject: [PATCH 3/6] test(joint-react): specify events for sizes written by the application Whether a size written by the application deserves an event depends on what it does to the graph, not on who wrote it. Resizing an element that already has a size changes nothing about readiness, so it must stay silent: that is the re-entrant layout of #3514. Sizing an element that had none is the write that makes the graph settled, so it is the event a consumer is waiting for. Pinning both keeps a fix from satisfying one by breaking the other. Suppressing every application resize, as #3518 does, silences the second one and leaves the graph fully sized with nobody told. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index 77e19e6759..23e7e11c9d 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -272,3 +272,47 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo expect(harness.events[0].isInitial).toBe(true); }); }); + +describe('useOnElementsMeasured — sizes written by the application', () => { + // An application may size elements itself rather than leave it to the + // measurement pipeline, for instance by measuring its own text and calling + // `resize()`. Whether such a write is worth an event depends on what it does + // to the graph, not on who wrote it. + + // #3514: a layout that resizes cells must not re-enter its own callback. The + // graph was settled before the write and is settled after it, so nothing + // about readiness changed. + it('delivers no event when the application resizes an element that already has a size', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + (harness.graph.getCell('a') as dia.Element).resize(70, 70); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + }); + + // The mirror image: this write is what makes the graph settled, so it is the + // event a consumer is waiting for. Suppressing every application resize + // loses it, and the graph is then fully sized with nobody told. + it('delivers one event when the application sizes an element that had none', async () => { + const harness = renderGraph([sized('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(unsized('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + harness.events.length = 0; + act(() => { + (harness.graph.getCell('b') as dia.Element).resize(120, 40); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); From e1a068a015d564be533159ffd7f9636fc3cb5016 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Thu, 24 Sep 2026 18:47:11 +0200 Subject: [PATCH 4/6] test(joint-react): make "waiting to be measured" real in the spec The harness rendered every element as a plain ``, which nothing measures. Elements it called unmeasured were therefore never pending, just zero-sized, and the cases expecting no event on add were asserting against an element that had nothing to wait for. Elements now render by what they are. A plain one renders as a `` and is settled on arrival whatever its size. A pending one renders through ``, which calls `useMeasureElement` and registers the node with the store's observer, so it genuinely stays outstanding until a measured size arrives. Adds the mixed batch this makes expressible: a plain element and one that waits, added together, are one event, delivered when the second is measured, not one event each. Flushing now waits a macrotask rather than a microtask, so a newly added element's portal has mounted and registered before anything is asserted. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 162 ++++++++++-------- 1 file changed, 89 insertions(+), 73 deletions(-) diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index 23e7e11c9d..9fd09eb331 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -1,25 +1,32 @@ /** - * Specification for how many events `useOnElementsMeasured` delivers. + * Specification for when `useOnElementsMeasured` delivers an event. * * The hook exists so an application can run a layout once element sizes are * known. That only works if one settled change delivers exactly one event: a - * layout must not run while a newly added element is still at its `{0,0}` - * default, and it must not run several times for the same change. + * layout must not run while an element is still waiting to be measured, and it + * must not run several times for the same change. * - * "Settled" means every element in the graph has a size. An element added with - * a size is settled on arrival; one added without a size settles when the - * measurement pipeline writes its measured size, which these tests simulate - * with an `autoSize` write, the same option `GraphStore` uses. + * An element is pending only when something is actually going to measure it. + * Here that is real: a `plain()` element renders as an SVG `` and nothing + * ever measures it, so it is settled the moment it is added, whatever its size. + * A `pending()` element renders through ``, which calls + * `useMeasureElement` and registers the node with the store's observer, so it + * stays outstanding until a measured size arrives. `measured()` writes that + * size with the `autoSize` option, exactly as the observer pipeline does — + * jsdom's `ResizeObserver` is a no-op mock, so it never reports on its own. * * The probe is mounted beside ``, not inside `renderElement`, so exactly * one instance of the hook is under test. Mounting it inside `renderElement` * would create one instance per element and count their separate events. * - * These tests describe intended behaviour and several of them fail today. + * These tests describe intended behaviour. Several fail today, because the + * store bumps its measure state whenever any element has a size rather than + * when nothing is left outstanding. */ import { render, waitFor, act } from '@testing-library/react'; import { GraphProvider } from '../../components/graph/graph-provider'; import { Paper } from '../../components/paper/paper'; +import { HTMLHost } from '../../components/html-host'; import { useOnElementsMeasured } from '../use-on-elements-measured'; import { useGraphStore } from '../use-graph-store'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; @@ -29,27 +36,41 @@ import type { dia } from '@joint/core'; const PAPER_ID = 'events-paper'; const PAPER_STYLE = { width: 100, height: 100 }; -const renderElement = () => ; -const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +/** + * Lets everything settle: React effects, the scheduler's microtask batch, and + * the commit in which a newly added element's portal mounts and registers with + * the observer. A microtask alone lands before that portal exists. + */ +const flush = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); -/** An element that arrives with a size: nothing to measure. */ -const sized = (id: string): CellRecord => +/** Elements flagged in their `data` render through a host that measures itself. */ +const renderElement = ({ measured }: { measured?: boolean }) => + measured ? node : ; + +/** Renders as a plain ``: nothing measures it, so it is settled on arrival. */ +const plain = (id: string): CellRecord => ({ id, type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 }, size: { width: 50, height: 50 }, + data: {}, }) as CellRecord; -/** An element that arrives without a size: `ElementModel` defaults it to `{0,0}`. */ -const unsized = (id: string): CellRecord => - ({ id, type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } }) as CellRecord; +/** Renders through ``: registers for measurement and waits for a size. */ +const pending = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + data: { measured: true }, + }) as CellRecord; -/** - * One delivered event, reduced to what these tests assert on. Keeping the full - * payload would print the whole `paper` on any failure. - */ +/** One delivered event, reduced to what these tests assert on. */ interface RecordedEvent { readonly isInitial: boolean; } @@ -83,8 +104,8 @@ function renderGraph(initialCells: CellRecord[]): Harness { return { graph: graph as dia.Graph, events }; } -/** Simulates the measurement pipeline writing a measured size. */ -function measure(graph: dia.Graph, ...ids: string[]) { +/** The write the measurement pipeline makes once a node has been measured. */ +function reportMeasurement(graph: dia.Graph, ...ids: string[]) { act(() => { for (const id of ids) { (graph.getCell(id) as dia.Element).set( @@ -105,7 +126,7 @@ async function settleAndClear(harness: Harness) { describe('useOnElementsMeasured — one event per settled change', () => { it('delivers one event for the seed pass', async () => { - const harness = renderGraph([sized('a')]); + const harness = renderGraph([plain('a')]); await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); await flush(); @@ -114,24 +135,24 @@ describe('useOnElementsMeasured — one event per settled change', () => { expect(harness.events[0].isInitial).toBe(true); }); - it('delivers one event when an element that needs no measuring is added', async () => { - const harness = renderGraph([sized('a')]); + it('delivers one event when an element nothing measures is added', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCell(sized('b') as never); + harness.graph.addCell(plain('b') as never); }); await flush(); expect(harness.events).toHaveLength(1); }); - it('delivers no event while an added element is still unmeasured', async () => { - const harness = renderGraph([sized('a')]); + it('delivers no event while an added element is waiting to be measured', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCell(unsized('b') as never); + harness.graph.addCell(pending('b') as never); }); await flush(); @@ -139,78 +160,81 @@ describe('useOnElementsMeasured — one event per settled change', () => { }); it('delivers one event once the added element has been measured', async () => { - const harness = renderGraph([sized('a')]); + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCell(unsized('b') as never); + harness.graph.addCell(pending('b') as never); }); await flush(); - measure(harness.graph, 'b'); + reportMeasurement(harness.graph, 'b'); await flush(); expect(harness.events).toHaveLength(1); }); - it('delivers one event for two added elements that both arrive with a size', async () => { - const harness = renderGraph([sized('a')]); + it('delivers one event for two added elements that nothing measures', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCells([sized('b'), sized('c')] as never); + harness.graph.addCells([plain('b'), plain('c')] as never); }); await flush(); expect(harness.events).toHaveLength(1); }); - it('delivers one event for two added elements that both need measuring', async () => { - const harness = renderGraph([sized('a')]); + it('delivers one event for two added elements that both wait to be measured', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCells([unsized('b'), unsized('c')] as never); + harness.graph.addCells([pending('b'), pending('c')] as never); }); await flush(); expect(harness.events).toHaveLength(0); - measure(harness.graph, 'b', 'c'); + reportMeasurement(harness.graph, 'b', 'c'); await flush(); expect(harness.events).toHaveLength(1); }); - it('delivers one event for a mixed batch, once the unmeasured element is measured', async () => { - const harness = renderGraph([sized('a')]); + // The mixed diagram: one element is settled on arrival, the other is not. + // The event belongs to the batch, not to either element, so it waits for the + // one that is still outstanding and then fires once. + it('delivers one event for a batch mixing a plain element with one that waits', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCells([sized('b'), unsized('c')] as never); + harness.graph.addCells([plain('b'), pending('c')] as never); }); await flush(); expect(harness.events).toHaveLength(0); - measure(harness.graph, 'c'); + reportMeasurement(harness.graph, 'c'); await flush(); expect(harness.events).toHaveLength(1); }); it('reports isInitial on the first event only', async () => { - const harness = renderGraph([sized('a')]); + const harness = renderGraph([plain('a')]); await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); await flush(); act(() => { - harness.graph.addCell(unsized('b') as never); + harness.graph.addCell(pending('b') as never); }); await flush(); - measure(harness.graph, 'b'); + reportMeasurement(harness.graph, 'b'); await flush(); act(() => { - harness.graph.addCell(sized('c') as never); + harness.graph.addCell(plain('c') as never); }); await flush(); @@ -224,12 +248,12 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo // Resetting the graph replaces the diagram, so the next pass is that // diagram's first one: a consumer that fits the paper on `isInitial` has a // new set of contents to fit. - it('reports isInitial again after the graph is reset with sized elements', async () => { - const harness = renderGraph([sized('a')]); + it('reports isInitial again after the graph is reset', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.resetCells([sized('x'), sized('y')] as never); + harness.graph.resetCells([plain('x'), plain('y')] as never); }); await flush(); @@ -237,17 +261,17 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo expect(harness.events[0].isInitial).toBe(true); }); - it('reports isInitial again after a reset, once the new elements are measured', async () => { - const harness = renderGraph([sized('a')]); + it('reports isInitial after a reset, once the new elements are measured', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.resetCells([unsized('x'), unsized('y')] as never); + harness.graph.resetCells([pending('x'), pending('y')] as never); }); await flush(); expect(harness.events).toHaveLength(0); - measure(harness.graph, 'x', 'y'); + reportMeasurement(harness.graph, 'x', 'y'); await flush(); expect(harness.events).toHaveLength(1); @@ -255,15 +279,15 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo }); it('reports isInitial once per reset, not on later changes', async () => { - const harness = renderGraph([sized('a')]); + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.resetCells([sized('x')] as never); + harness.graph.resetCells([plain('x')] as never); }); await flush(); act(() => { - harness.graph.addCell(sized('y') as never); + harness.graph.addCell(plain('y') as never); }); await flush(); @@ -274,16 +298,10 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo }); describe('useOnElementsMeasured — sizes written by the application', () => { - // An application may size elements itself rather than leave it to the - // measurement pipeline, for instance by measuring its own text and calling - // `resize()`. Whether such a write is worth an event depends on what it does - // to the graph, not on who wrote it. - - // #3514: a layout that resizes cells must not re-enter its own callback. The - // graph was settled before the write and is settled after it, so nothing - // about readiness changed. - it('delivers no event when the application resizes an element that already has a size', async () => { - const harness = renderGraph([sized('a')]); + // #3514: a layout that resizes cells must not re-enter its own callback. + // Nothing was outstanding before the write and nothing is after it. + it('delivers no event when the application resizes an element nothing measures', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { @@ -294,20 +312,18 @@ describe('useOnElementsMeasured — sizes written by the application', () => { expect(harness.events).toHaveLength(0); }); - // The mirror image: this write is what makes the graph settled, so it is the - // event a consumer is waiting for. Suppressing every application resize - // loses it, and the graph is then fully sized with nobody told. - it('delivers one event when the application sizes an element that had none', async () => { - const harness = renderGraph([sized('a')]); + // The element was waiting to be measured, so the event was owed from the add. + // Whoever supplies the size discharges it, the observer or application code. + it('delivers one event when the application sizes an element that was waiting', async () => { + const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCell(unsized('b') as never); + harness.graph.addCell(pending('b') as never); }); await flush(); expect(harness.events).toHaveLength(0); - harness.events.length = 0; act(() => { (harness.graph.getCell('b') as dia.Element).resize(120, 40); }); From 22f2e558665f49353f6dba6129e12bec5fdfe6dc Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Thu, 24 Sep 2026 18:52:02 +0200 Subject: [PATCH 5/6] test(joint-react): a size written by the application never fires A resize does not discharge a pending measurement. The element stays registered, the measured size overwrites the write, and the library already warns about exactly this in `warnResizeOnAutoSizedElement`. So an element waiting to be measured stays outstanding whatever size it happens to hold: what the hook waits on is the measurement, not the presence of a size. That makes both application cases agree. Either the element is settled already, so nothing about readiness changed, or it is waiting, so the measurement is still owed. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index 9fd09eb331..809254cc54 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -297,6 +297,10 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo }); }); +// A size written by the application never produces an event. Either the +// element is settled already, in which case nothing about readiness changed, or +// it is waiting to be measured, in which case the measurement is still owed and +// will overwrite the write anyway. describe('useOnElementsMeasured — sizes written by the application', () => { // #3514: a layout that resizes cells must not re-enter its own callback. // Nothing was outstanding before the write and nothing is after it. @@ -312,9 +316,12 @@ describe('useOnElementsMeasured — sizes written by the application', () => { expect(harness.events).toHaveLength(0); }); - // The element was waiting to be measured, so the event was owed from the add. - // Whoever supplies the size discharges it, the observer or application code. - it('delivers one event when the application sizes an element that was waiting', async () => { + // A resize does not discharge a pending measurement. The element is still + // registered, the measured size will overwrite this one, and the library + // already warns about it (`warnResizeOnAutoSizedElement`). So the element is + // outstanding until it is measured, whatever size it happens to hold: what + // the hook waits on is the measurement, not the presence of a size. + it('delivers no event when the application sizes an element that is waiting', async () => { const harness = renderGraph([plain('a')]); await settleAndClear(harness); @@ -329,6 +336,11 @@ describe('useOnElementsMeasured — sizes written by the application', () => { }); await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + expect(harness.events).toHaveLength(1); }); }); From 65c95b3bc7a3f805f2a837857c4a0e22d2fe9f59 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Wed, 30 Sep 2026 14:11:26 +0200 Subject: [PATCH 6/6] test(joint-react): a permanently zero-sized element must not block delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An element can be zero-sized on purpose — a layout anchor marks a position without drawing anything, and its size is its real size, not a size it is waiting for. Nothing ever measures it, so nothing ever reports a size for it, and a gate that waits for one never opens again. Three cases: the seed pass, the batch that adds such an element, and the batches that follow it. Co-Authored-By: Claude Opus 5 (1M context) --- .../use-on-elements-measured-events.test.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index 809254cc54..857baf2611 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -70,6 +70,20 @@ const pending = (id: string): CellRecord => data: { measured: true }, }) as CellRecord; +/** + * Zero-sized on purpose and never measured: a layout anchor, the shape a + * `scalable`-free diagram uses to mark a position without drawing anything. + * Its size is its real size, not a size it is waiting for. + */ +const anchor = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 0, height: 0 }, + data: {}, + }) as CellRecord; + /** One delivered event, reduced to what these tests assert on. */ interface RecordedEvent { readonly isInitial: boolean; @@ -344,3 +358,46 @@ describe('useOnElementsMeasured — sizes written by the application', () => { expect(harness.events).toHaveLength(1); }); }); + +// An element can be zero-sized for good, rather than briefly on its way to a +// measurement. Nothing will ever give it a size, so treating it as outstanding +// holds every later batch open and the hook stops firing altogether. +describe('useOnElementsMeasured — an element that stays zero-sized', () => { + it('delivers the seed pass with a zero-sized element in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + it('delivers the batch that adds a zero-sized element', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), anchor('anchor')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('keeps delivering later batches once a zero-sized element is in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +});