From 3694337fb957e423025cc777b7c0bffb10ff1780 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 24 Sep 2026 14:19:12 +0700 Subject: [PATCH 1/5] fix(joint-react): ensure useOnElementsMeasured fires only for measurement writes, not application resizes --- .../react-elements-measured-app-resize.md | 5 ++ .../use-on-elements-measured.test.tsx | 46 +++++++++++++++++++ .../src/hooks/use-on-elements-measured.ts | 5 +- .../src/store/__tests__/graph-changes.test.ts | 8 +++- .../joint-react/src/store/graph-changes.ts | 22 +++++++-- .../joint-react/src/store/graph-projection.ts | 7 ++- packages/joint-react/src/store/graph-store.ts | 11 ++++- 7 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 .changeset/react-elements-measured-app-resize.md diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md new file mode 100644 index 0000000000..804e1deeb0 --- /dev/null +++ b/.changeset/react-elements-measured-app-resize.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useOnElementsMeasured - fix to fire only for measurement writes, not for sizes the application sets itself diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx index b10d52ea14..4e02e010ee 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx @@ -126,4 +126,50 @@ describe('useOnElementsMeasured', () => { await waitFor(() => expect(callback).toHaveBeenCalled()); expect(callback.mock.calls[0][0].isInitial).toBe(true); }); + + // Regression (#3514): the store's `change:size` listener dropped the event + // options, so an application's own resize bumped `measureState` like a + // measurement write and woke every subscriber. + describe('application resizes vs measurement writes', () => { + function renderMeasuredProbe(callback: jest.Mock) { + let graphRef: dia.Graph | undefined; + function Probe() { + const store = useGraphStore(); + graphRef = store.graph; + useOnElementsMeasured('measured-effect-paper', callback); + return null; + } + renderHook(() => Probe(), { wrapper }); + return () => graphRef!.getCell('a') as dia.Element; + } + + it('does not fire when the application resizes an element', async () => { + const callback = jest.fn(); + const getElement = renderMeasuredProbe(callback); + await waitFor(() => expect(callback).toHaveBeenCalled()); + callback.mockClear(); + + act(() => { + getElement().resize(80, 80); + }); + await act(async () => flush()); + await act(async () => flush()); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('fires with isInitial=false for a measurement write', async () => { + const callback = jest.fn(); + const getElement = renderMeasuredProbe(callback); + await waitFor(() => expect(callback).toHaveBeenCalled()); + callback.mockClear(); + + act(() => { + getElement().set('size', { width: 80, height: 80 }, { autoSize: true }); + }); + + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + expect(callback.mock.calls[0][0].isInitial).toBe(false); + }); + }); }); diff --git a/packages/joint-react/src/hooks/use-on-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index d481409ad0..11962a7066 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -31,7 +31,10 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * Calls a callback when element sizes are measured or re-measured. * * Fires on the first measurement pass (at least one element has been sized) - * and again whenever an element is resized. + * and again whenever an element is re-measured to a different size. A size the + * application writes itself (`cell.resize()`, controlled `cells` sync) is not a + * measurement and does not fire it; listen to `change:size` with + * {@link useOnGraphEvents} to hear every size change. * * The callback receives {@link ElementsMeasuredParams}; check `isInitial` to * distinguish the first measurement from later ones. diff --git a/packages/joint-react/src/store/__tests__/graph-changes.test.ts b/packages/joint-react/src/store/__tests__/graph-changes.test.ts index 051ba6fe03..60f1bed96b 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -432,7 +432,13 @@ describe('graphChanges', () => { const cell = graph.getCell('a') as dia.Element; cell.set('size', { width: 120, height: 60 }, { fromMeasure: true } as object); - expect(onElementsSizeChange).toHaveBeenCalledWith('a', { width: 120, height: 60 }); + // The `change:size` options are forwarded so the store can tell a + // measurement write from an application resize (#3514). + expect(onElementsSizeChange).toHaveBeenCalledWith( + 'a', + { width: 120, height: 60 }, + expect.objectContaining({ fromMeasure: true }) + ); }); it('does not fire for links on reset', () => { diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index 22a6d2d1e1..8899e3af15 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -63,7 +63,15 @@ interface OnChangeOptions { interface Options { readonly graph: dia.Graph; readonly onChanges: (options: OnChangeOptions) => void; - readonly onElementsSizeChange?: (id: CellId, size: { width: number; height: number }) => void; + /** + * An element got a size. `changeOptions` are the options of the `change:size` + * event; `undefined` when the size arrives with the cell (`add` / `reset`). + */ + readonly onElementsSizeChange?: ( + id: CellId, + size: { width: number; height: number }, + changeOptions?: dia.Cell.Options + ) => void; } interface JointJSEventOptions { @@ -203,10 +211,14 @@ export function graphChanges(options: Options) { onChanges({ changes: layoutChanges, isInsideBatch: true, deferCommit: isDeferring() }); }); - controller.listenTo(graph, 'change:size', (cell: dia.Cell, newSize: dia.Size) => { - if (!onElementsSizeChange) return; - onElementsSizeChange(cell.id, newSize); - }); + controller.listenTo( + graph, + 'change:size', + (cell: dia.Cell, newSize: dia.Size, changeOptions: dia.Cell.Options = {}) => { + if (!onElementsSizeChange) return; + onElementsSizeChange(cell.id, newSize, changeOptions); + } + ); // Always-on batch tracking. A batch flagged with DEFER_COMMIT_BATCH_OPTION // defers its container commits until it closes, so a burst of edits (sync or diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 85726b177e..c7fda1c7a4 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -39,7 +39,12 @@ interface GraphProjectionState< > { readonly graph: dia.Graph; readonly onIncrementalCellsChange?: OnIncrementalCellsChange; - readonly onElementsSizeChange?: (id: CellId, size: dia.Size) => void; + /** See `graphChanges`: `changeOptions` is `undefined` for sizes arriving with an `add` / `reset`. */ + readonly onElementsSizeChange?: ( + id: CellId, + size: dia.Size, + changeOptions?: dia.Cell.Options + ) => void; } /* eslint-disable sonarjs/cognitive-complexity -- graph→container projection diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 176a27a8a0..75f1b03d7d 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -160,12 +160,21 @@ export class GraphStore< onIncrementalCellsChange: (changes) => { this.onIncrementalCellsChange?.(changes); }, - onElementsSizeChange: (id, size) => { + onElementsSizeChange: (id, size, changeOptions) => { + const wasAnyElementMeasured = elementsMeasured.size > 0; + // Bookkeeping follows every size change: `isInitial` and + // `useAreElementsMeasured` rest on it, whatever wrote the size. if (size.width > 0 && size.height > 0) { elementsMeasured.add(id); } else { elementsMeasured.delete(id); } + // Waking the subscribers does not (#3514): an application's own resize + // is not a measurement, so only measurement writes (`autoSize`), sizes + // arriving with the cell (`add` / `reset`) and the first element to get + // a size bump `measureState`. + const isApplicationResize = changeOptions !== undefined && !changeOptions[AUTO_SIZE_OPTION]; + if (isApplicationResize && wasAnyElementMeasured) return; simpleScheduler(onElementSizeChange); }, }); From 641a3617e3a89796fc914f2b4dd848533fd7759b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 29 Sep 2026 16:50:12 +0700 Subject: [PATCH 2/5] feat: enhance measurement handling in graph store and related hooks - Introduced a new measurement generation system in GraphStore to track resets and initial measurement passes. - Updated `useOnElementsMeasured` to deliver events based on settled changes, ensuring accurate measurement reporting. - Modified the measurement observer to handle cases where elements are added without sizes and to trigger events when they are measured. - Improved test coverage for measurement events, including scenarios for element resets and size changes. - Refactored size change handling to differentiate between application-resized elements and those measured by the observer. - Ensured that the `markElementRendered` function correctly settles elements that arrive without sizes. --- .../react-elements-measured-app-resize.md | 2 +- .changeset/react-elements-measured-reset.md | 5 + .../bench/baseline-post-refactor.json | 445 +++++++++++------- .../render-element/paper-element-item.tsx | 5 + .../use-on-elements-measured-events.test.tsx | 412 ++++++++++++++++ .../use-on-elements-measured.test.tsx | 11 +- .../src/hooks/use-on-elements-measured.ts | 48 +- .../src/store/__tests__/graph-changes.test.ts | 38 +- .../src/store/__tests__/graph-store.test.ts | 23 + .../store/create-elements-size-observer.ts | 9 + .../joint-react/src/store/graph-changes.ts | 23 +- .../joint-react/src/store/graph-projection.ts | 15 +- packages/joint-react/src/store/graph-store.ts | 85 +++- 13 files changed, 888 insertions(+), 233 deletions(-) create mode 100644 .changeset/react-elements-measured-reset.md create mode 100644 packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md index 804e1deeb0..8b0416b1af 100644 --- a/.changeset/react-elements-measured-app-resize.md +++ b/.changeset/react-elements-measured-app-resize.md @@ -2,4 +2,4 @@ "@joint/react": patch --- -useOnElementsMeasured - fix to fire only for measurement writes, not for sizes the application sets itself +useOnElementsMeasured - fix to fire once per settled change: not for application-written sizes, not while an added element waits to be measured diff --git a/.changeset/react-elements-measured-reset.md b/.changeset/react-elements-measured-reset.md new file mode 100644 index 0000000000..3d36367599 --- /dev/null +++ b/.changeset/react-elements-measured-reset.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useOnElementsMeasured - report `isInitial` again for the first pass after a graph reset (`resetCells()`) diff --git a/packages/joint-react/bench/baseline-post-refactor.json b/packages/joint-react/bench/baseline-post-refactor.json index 7a5c59c72b..f053fa411e 100644 --- a/packages/joint-react/bench/baseline-post-refactor.json +++ b/packages/joint-react/bench/baseline-post-refactor.json @@ -1,7 +1,7 @@ { "meta": { - "savedAt": "2026-06-26T12:56:20.613Z", - "gitSha": "9a741ac576b9b0336b96e4b121e826969ecfc036", + "savedAt": "2026-09-29T09:41:32.889Z", + "gitSha": "3694337fb957e423025cc777b7c0bffb10ff1780", "node": "v24.11.1", "platform": "darwin/arm64" }, @@ -9,160 +9,160 @@ "hooks/key-derivation/n=10": [ { "name": "key derivation (10)", - "opsPerSec": 9102033552.308687, - "mean": 0.00012776233369654266, - "stddev": 0.002638525657641091, + "opsPerSec": 8578763566.417811, + "mean": 0.00014526272141846027, + "stddev": 0.005573985050010314, "min": 0.00004099999978279811, - "max": 6.915790999999899, - "samples": 7827033 + "max": 8.95425000000023, + "samples": 6884079 } ], "hooks/full-iteration-selector/n=10": [ { "name": "full iteration + selector (10)", - "opsPerSec": 2535300828.319926, - "mean": 0.0006300311846347321, - "stddev": 0.008285664941017352, + "opsPerSec": 3034606829.6510706, + "mean": 0.0003601220298488279, + "stddev": 0.001040629385654209, "min": 0.000249999999141437, - "max": 3.906082999999853, - "samples": 1587224 + "max": 0.7629590000005919, + "samples": 2776837 } ], "hooks/single-id-get-selector/n=10": [ { "name": "single-ID get + selector (10)", - "opsPerSec": 9001583723.37333, - "mean": 0.00018175384538777985, - "stddev": 0.006982715983930184, + "opsPerSec": 11243839690.020199, + "mean": 0.00010536881241425959, + "stddev": 0.0014454404899599436, "min": 0, - "max": 9.703125, - "samples": 5501981 + "max": 1.705167000000074, + "samples": 9490475 } ], "hooks/shallow-map-comparison/n=10": [ { "name": "shallow Map equal (10)", - "opsPerSec": 5260348561.022442, - "mean": 0.00026741772974151634, - "stddev": 0.018586417085768542, - "min": 0.0001249999986612238, - "max": 35.464584000001196, - "samples": 3739468 + "opsPerSec": 5879262035.302982, + "mean": 0.0001839384632059221, + "stddev": 0.000981543044290958, + "min": 0.00008299999899463728, + "max": 0.5756670000009763, + "samples": 5436601 }, { "name": "shallow Map unequal (10)", - "opsPerSec": 1658734644.1832, - "mean": 0.0007927398140844134, - "stddev": 0.07175949929966026, - "min": 0.000499999998282874, - "max": 65.3604579999992, - "samples": 1261448 + "opsPerSec": 1763955694.1149502, + "mean": 0.0005990987302711665, + "stddev": 0.010599021555933674, + "min": 0.0004579999986162875, + "max": 13.473500000000058, + "samples": 1669174 } ], "hooks/key-derivation/n=100": [ { "name": "key derivation (100)", - "opsPerSec": 1774311969.0672424, - "mean": 0.0006912953496554602, - "stddev": 0.0042805118715596654, - "min": 0.00041599999894970097, - "max": 3.0222079999985, - "samples": 1446560 + "opsPerSec": 1446542980.8480725, + "mean": 0.0008441791997697341, + "stddev": 0.004736755195518718, + "min": 0.0005409999976109248, + "max": 0.7317079999993439, + "samples": 1184583 } ], "hooks/full-iteration-selector/n=100": [ { "name": "full iteration + selector (100)", - "opsPerSec": 507671227.5095935, - "mean": 0.002142448513894801, - "stddev": 0.0036657148157697955, - "min": 0.0017499999994470272, - "max": 0.9238330000007409, - "samples": 466757 + "opsPerSec": 535370371.2353534, + "mean": 0.0019278063559684938, + "stddev": 0.003499072007971766, + "min": 0.0017079999997804407, + "max": 0.4214589999974123, + "samples": 518725 } ], "hooks/single-id-get-selector/n=100": [ { "name": "single-ID get + selector (100)", - "opsPerSec": 9785151296.529823, - "mean": 0.00011706911128019761, - "stddev": 0.0005970687730137746, - "min": 0.00004099999932805076, - "max": 0.7452919999996084, - "samples": 8541963 + "opsPerSec": 10479381803.693872, + "mean": 0.00010137172043499846, + "stddev": 0.00011722880824566547, + "min": 0, + "max": 0.1224160000019765, + "samples": 9864685 } ], "hooks/shallow-map-comparison/n=100": [ { "name": "shallow Map equal (100)", - "opsPerSec": 619759976.4328091, - "mean": 0.0017797496249183477, - "stddev": 0.004928262145344146, - "min": 0.0014159999991534278, - "max": 1.6197920000049635, - "samples": 561877 + "opsPerSec": 608030761.4157907, + "mean": 0.0018511326076937787, + "stddev": 0.004543346095680457, + "min": 0.001374999999825377, + "max": 1.2347499999996217, + "samples": 540210 }, { "name": "shallow Map unequal (100)", - "opsPerSec": 214259776.51406443, - "mean": 0.004807214665830148, - "stddev": 0.008679019293239498, - "min": 0.004249999998137355, - "max": 3.7966250000026776, - "samples": 208021 + "opsPerSec": 211625125.88497972, + "mean": 0.00491350507809267, + "stddev": 0.029871937373887145, + "min": 0.004207999998470768, + "max": 13.429667000000336, + "samples": 203521 } ], "hooks/key-derivation/n=1000": [ { "name": "key derivation (1000)", - "opsPerSec": 158246163.84223586, - "mean": 0.007386272042366138, - "stddev": 0.012492956118353328, - "min": 0.00458299999445444, - "max": 0.7620830000014394, - "samples": 135387 + "opsPerSec": 127916435.2687544, + "mean": 0.008999619052750494, + "stddev": 0.016247125394453974, + "min": 0.005834000003233086, + "max": 1.0247080000044662, + "samples": 111123 } ], "hooks/full-iteration-selector/n=1000": [ { "name": "full iteration + selector (1000)", - "opsPerSec": 53454084.79351535, - "mean": 0.02129658160833463, - "stddev": 0.038570989492294296, - "min": 0.01641599999857135, - "max": 7.478792000001704, - "samples": 46956 + "opsPerSec": 55683956.56902837, + "mean": 0.018627164921936994, + "stddev": 0.013857703850232505, + "min": 0.016207999993639532, + "max": 2.0089999999981956, + "samples": 53686 } ], "hooks/single-id-get-selector/n=1000": [ { "name": "single-ID get + selector (1000)", - "opsPerSec": 9166420604.692993, - "mean": 0.0001251065802928049, - "stddev": 0.0007809810394888233, + "opsPerSec": 9222473972.877195, + "mean": 0.00011692201056906811, + "stddev": 0.00015297733473298693, "min": 0.00004099999932805076, - "max": 1.1806250000008731, - "samples": 7993185 + "max": 0.13900000000285218, + "samples": 8552710 } ], "hooks/shallow-map-comparison/n=1000": [ { "name": "shallow Map equal (1000)", - "opsPerSec": 45520810.90012255, - "mean": 0.02337060094885724, - "stddev": 0.01735478693567373, - "min": 0.019332999996549916, - "max": 2.1166249999951106, - "samples": 42789 + "opsPerSec": 45390462.59618543, + "mean": 0.022524016802548175, + "stddev": 0.008659029666390015, + "min": 0.01958300000114832, + "max": 0.6563330000062706, + "samples": 44398 }, { "name": "shallow Map unequal (1000)", - "opsPerSec": 19032585.013689395, - "mean": 0.05319225739362969, - "stddev": 0.01233989935991809, - "min": 0.04958299999998417, - "max": 0.5364170000029844, - "samples": 18800 + "opsPerSec": 18203133.84432556, + "mean": 0.05584196348000504, + "stddev": 0.01364897105149672, + "min": 0.04974999999831198, + "max": 0.4946669999990263, + "samples": 17908 } ], "container/set-existing/n=10": [ @@ -311,34 +311,34 @@ "controlled/updateGraph/n=10": [ { "name": "controlled/updateGraph/n=10", - "opsPerSec": 7200873.030667038, - "mean": 0.1476165065682614, - "stddev": 0.06462752538728161, - "min": 0.12708399999974063, - "max": 1.8675410000000738, - "samples": 6775 + "opsPerSec": 5695418.739507571, + "mean": 0.2416726201063293, + "stddev": 0.30129169915539616, + "min": 0.13020800000003874, + "max": 9.774875000000065, + "samples": 4138 } ], "controlled/position-change/n=100": [ { "name": "controlled/position-change/n=100", - "opsPerSec": 109897013.22483212, - "mean": 0.015529662453024722, - "stddev": 0.0523553713119434, - "min": 0.0059999999994033715, - "max": 6.114709000000403, - "samples": 64394 + "opsPerSec": 132405202.08311844, + "mean": 0.01068072950977208, + "stddev": 0.04603688340034601, + "min": 0.006040999999640917, + "max": 7.828208000000814, + "samples": 93630 } ], "controlled/updateGraph/n=100": [ { "name": "controlled/updateGraph/n=100", - "opsPerSec": 542543.6686074686, - "mean": 2.5535463137755285, - "stddev": 3.572476213316869, - "min": 1.2611249999999927, - "max": 57.444915999999466, - "samples": 392 + "opsPerSec": 681884.3000143886, + "mean": 1.5865391996830183, + "stddev": 0.6062178993579842, + "min": 1.2536250000002838, + "max": 7.847332999999708, + "samples": 631 } ], "controlled/position-change/n=1000": [ @@ -355,33 +355,33 @@ "controlled/updateGraph/n=1000": [ { "name": "controlled/updateGraph/n=1000", - "opsPerSec": 40238.18429834901, - "mean": 27.495591765625022, - "stddev": 9.060631782506034, - "min": 13.829125000000204, - "max": 53.331874999999854, + "opsPerSec": 55634.03703416929, + "mean": 19.816279953124933, + "stddev": 7.768406709778767, + "min": 13.565166999998837, + "max": 50.02358400000048, "samples": 64 } ], "controlled/position-change/n=10000": [ { "name": "controlled/position-change/n=10000", - "opsPerSec": 114634877.57925694, - "mean": 0.01591508894866051, - "stddev": 0.10630700839023834, - "min": 0.006250000000363798, - "max": 14.191624999999476, - "samples": 62834 + "opsPerSec": 142707947.62040362, + "mean": 0.00787472475568652, + "stddev": 0.03381218782882899, + "min": 0.0060839999987365445, + "max": 4.427625000000262, + "samples": 126989 } ], "controlled/updateGraph/n=10000": [ { "name": "controlled/updateGraph/n=10000", - "opsPerSec": 5866.66567428112, - "mean": 185.22899542187494, - "stddev": 89.41845182660455, - "min": 139.24441600000137, - "max": 784.1291249999995, + "opsPerSec": 6681.370305101578, + "mean": 152.36801039062524, + "stddev": 24.122299550672366, + "min": 133.7072079999998, + "max": 268.4775000000009, "samples": 64 } ], @@ -475,88 +475,187 @@ "graph-projection/position-change/n=10": [ { "name": "baseline (10)", - "opsPerSec": 161557116.3173571, - "mean": 0.006834966044235606, - "stddev": 0.007308573169337244, - "min": 0.005458000000089669, - "max": 0.9919170000000577, - "samples": 146308 + "opsPerSec": 150881164.65628707, + "mean": 0.007329107253578483, + "stddev": 0.005658675243558629, + "min": 0.0056670000003578025, + "max": 1.0740420000001905, + "samples": 136443 }, { "name": "graphProjection (10)", - "opsPerSec": 149230760.29084107, - "mean": 0.007903611879074289, - "stddev": 0.01947725138702822, - "min": 0.005957999999736785, - "max": 3.5417919999999867, - "samples": 126525 + "opsPerSec": 132944188.75600669, + "mean": 0.010158226791137589, + "stddev": 0.04905685720815395, + "min": 0.006124999999883585, + "max": 12.317125000000033, + "samples": 98443 }, { "name": "GraphStore (10)", - "opsPerSec": 121930647.74715658, - "mean": 0.014754530938330623, - "stddev": 0.09755133766428084, + "opsPerSec": 134179724.73545961, + "mean": 0.01105086828524303, + "stddev": 0.09112438912672015, "min": 0.005957999999736785, - "max": 15.573499999999513, - "samples": 67780 + "max": 14.83966700000019, + "samples": 90491 } ], "graph-projection/position-change/n=100": [ { "name": "baseline (100)", - "opsPerSec": 101417810.06484096, - "mean": 0.03332241076307389, - "stddev": 0.45364963030742494, - "min": 0.005874999999832653, - "max": 47.54591699999946, - "samples": 30010 + "opsPerSec": 152544466.83363914, + "mean": 0.007196951017647769, + "stddev": 0.00548889746492172, + "min": 0.005790999999589985, + "max": 0.5253749999992579, + "samples": 138948 }, { "name": "graphProjection (100)", - "opsPerSec": 82146180.94625443, - "mean": 0.03185789700541546, - "stddev": 0.23653690890482493, - "min": 0.006292000000030384, - "max": 24.838708000001134, - "samples": 31390 + "opsPerSec": 126664164.8764175, + "mean": 0.010646020780106543, + "stddev": 0.025594198306057955, + "min": 0.0062499999985448085, + "max": 2.306083999999828, + "samples": 93936 }, { "name": "GraphStore (100)", - "opsPerSec": 107824652.24534024, - "mean": 0.01614418052371774, - "stddev": 0.07231301767024387, + "opsPerSec": 137690163.6841106, + "mean": 0.009491097520927787, + "stddev": 0.04181861439693793, "min": 0.006124999999883585, - "max": 8.082916999999725, - "samples": 61942 + "max": 9.871334000001298, + "samples": 105362 } ], "graph-projection/position-change/n=1000": [ { "name": "baseline (1000)", - "opsPerSec": 111711781.03085949, - "mean": 0.016222944538840746, - "stddev": 0.09040577218414217, - "min": 0.005916999998589745, - "max": 14.222999999999956, - "samples": 62386 + "opsPerSec": 147319685.74764007, + "mean": 0.007909531471962383, + "stddev": 0.028424097856288598, + "min": 0.005832999999256572, + "max": 8.488209000001007, + "samples": 126430 }, { "name": "graphProjection (1000)", - "opsPerSec": 110544984.6762098, - "mean": 0.016250856089156912, - "stddev": 0.09428158912855929, - "min": 0.006457999999838648, - "max": 16.033792000000176, - "samples": 61552 + "opsPerSec": 134868970.16746384, + "mean": 0.008691299770552528, + "stddev": 0.049319439980473355, + "min": 0.006209000001035747, + "max": 9.952667000001384, + "samples": 115058 }, { "name": "GraphStore (1000)", - "opsPerSec": 116700306.81772408, - "mean": 0.013303929876136874, - "stddev": 0.050927563796028565, + "opsPerSec": 141405885.44167387, + "mean": 0.007755944041138492, + "stddev": 0.01777394347925715, "min": 0.006207999998878222, - "max": 5.420540999999503, - "samples": 75167 + "max": 3.365458000000217, + "samples": 128934 + } + ], + "container/batchSet-1-changed/n=100": [ + { + "name": "container/batchSet-1-changed/n=100", + "opsPerSec": 4135020528.4548955, + "mean": 0.00027312453111350595, + "stddev": 0.0018815518271704595, + "min": 0.0001249999995707185, + "max": 2.135915999999952, + "samples": 1830667 + } + ], + "container/batchSet-100-changed/n=100": [ + { + "name": "container/batchSet-100-changed/n=100", + "opsPerSec": 93806504.3061161, + "mean": 0.014892432944260228, + "stddev": 0.09069403326116109, + "min": 0.008624999999938154, + "max": 9.611875000000055, + "samples": 33584 + } + ], + "container/batchSet-mixed/n=100": [ + { + "name": "container/batchSet-mixed/n=100", + "opsPerSec": 59621359.28238026, + "mean": 0.020029507069897416, + "stddev": 0.02346604897075959, + "min": 0.01433400000041729, + "max": 2.1574579999996786, + "samples": 24965 + } + ], + "container/batchSet-1-changed/n=1000": [ + { + "name": "container/batchSet-1-changed/n=1000", + "opsPerSec": 3669744777.4481864, + "mean": 0.0003810449606498482, + "stddev": 0.005580757499422791, + "min": 0.0001249999995707185, + "max": 5.036583999999493, + "samples": 1323246 + } + ], + "container/batchSet-100-changed/n=1000": [ + { + "name": "container/batchSet-100-changed/n=1000", + "opsPerSec": 99503401.89220743, + "mean": 0.01106090443534731, + "stddev": 0.009701904811432342, + "min": 0.008874999999534339, + "max": 0.6752080000005662, + "samples": 45205 + } + ], + "container/batchSet-mixed/n=1000": [ + { + "name": "container/batchSet-mixed/n=1000", + "opsPerSec": 10610244.171916349, + "mean": 0.10217847834082247, + "stddev": 0.05247728389909422, + "min": 0.0852919999997539, + "max": 2.2160000000003492, + "samples": 4894 + } + ], + "container/batchSet-1-changed/n=10000": [ + { + "name": "container/batchSet-1-changed/n=10000", + "opsPerSec": 3602226314.0993004, + "mean": 0.00036328137878230226, + "stddev": 0.004018387742458943, + "min": 0.0001250000004802132, + "max": 1.882792000000336, + "samples": 1376344 + } + ], + "container/batchSet-100-changed/n=10000": [ + { + "name": "container/batchSet-100-changed/n=10000", + "opsPerSec": 101001634.67179693, + "mean": 0.011625845633372265, + "stddev": 0.021969720508986438, + "min": 0.008749999999054126, + "max": 3.3639169999987644, + "samples": 43008 + } + ], + "container/batchSet-mixed/n=10000": [ + { + "name": "container/batchSet-mixed/n=10000", + "opsPerSec": 715204.6381593617, + "mean": 1.5880766000001183, + "stddev": 0.9104198106318999, + "min": 1.1040420000008453, + "max": 11.17804200000137, + "samples": 315 } ] } diff --git a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 98420cb199..8c77af7460 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -50,6 +50,11 @@ function SVGElementItemComponent(props: ElementItemProps) { const graphStore = useGraphStore(); const { paper } = usePaper(); useLayoutEffect(() => { + // Runs after the portal subtree's layout effects, so a `useMeasureElement` + // inside `renderElement` has already registered with the size observer + // (in HTML overlay mode this item follows `HTMLElementItem`, which renders + // the user content, in sibling order). O(1), and a no-op on re-runs. + graphStore.markElementRendered(id); if (!paper) return; graphStore.clearViewForElementAndLinks({ cellId: id, 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..ad6cef2151 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -0,0 +1,412 @@ +/** + * 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 an element is still waiting to be measured, and it + * must not run several times for the same change. + * + * 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. + */ +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'; +import { AUTO_SIZE_OPTION } from '../../store/graph-store'; +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 }; + +/** Lets React effects and the scheduler's microtask batch settle. */ +const flush = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** + * Also waits for the paper's async render frame, in which a newly added + * element's view mounts, its portal content commits and a `useMeasureElement` + * inside it registers with the observer. `flush()` alone lands before that. + */ +const flushFrame = () => + act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** 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; + +/** Arrives without a size, but renders as a plain `` that nothing measures. */ +const unsized = (id: string): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + data: {}, + }) 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. */ +interface RecordedEvent { + readonly isInitial: boolean; +} + +interface Harness { + readonly graph: dia.Graph; + readonly events: RecordedEvent[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph(initialCells: CellRecord[]): Harness { + const events: RecordedEvent[] = []; + let graph: dia.Graph | undefined; + + function Probe() { + const { graph: currentGraph } = useGraphStore(); + graph = currentGraph; + useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { + events.push({ isInitial }); + }); + return null; + } + + render( + + + + + ); + + return { graph: graph as dia.Graph, events }; +} + +/** 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( + '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([plain('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 nothing measures is added', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(plain('b') as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + 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(pending('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([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers one event for two added elements that nothing measures', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + 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 wait to be measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([pending('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b', 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + // 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([plain('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'c'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('reports isInitial on the first event only', async () => { + const harness = renderGraph([plain('a')]); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + reportMeasurement(harness.graph, 'b'); + await flush(); + + act(() => { + harness.graph.addCell(plain('c') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + 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', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([plain('x'), plain('y')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); + + 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([pending('x'), pending('y')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(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([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.resetCells([plain('x')] as never); + }); + await flush(); + act(() => { + harness.graph.addCell(plain('y') as never); + }); + await flush(); + + const initial = harness.events.filter((event) => event.isInitial); + expect(initial).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); + }); +}); + +// 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. + it('delivers no event when the application resizes an element nothing measures', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + (harness.graph.getCell('a') as dia.Element).resize(70, 70); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + }); + + // 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); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + (harness.graph.getCell('b') as dia.Element).resize(70, 70); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// What ends the wait besides a measurement write: the element renders and +// nothing measures it, the observer measures it to the size it already has, +// or it leaves the graph. +describe('useOnElementsMeasured — how a waiting element settles', () => { + it('delivers one event once an unsized element that nothing measures has rendered', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(unsized('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + await flushFrame(); + + expect(harness.events).toHaveLength(1); + }); + + it('keeps waiting for an element that registered for measurement when it rendered', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flushFrame(); + expect(harness.events).toHaveLength(0); + + reportMeasurement(harness.graph, 'b'); + await flush(); + + expect(harness.events).toHaveLength(1); + }); + + it('delivers the batch once a waiting element is removed before it is measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + harness.graph.getCell('c').remove(); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx index 4e02e010ee..e1256086a0 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx @@ -3,6 +3,7 @@ import { renderHook, waitFor, act } from '@testing-library/react'; import { paperRenderElementWrapper } from '../../utils/test-wrappers'; import { useOnElementsMeasured } from '../use-on-elements-measured'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../../store/graph-store'; import { useGraphStore } from '../use-graph-store'; import type { CellRecord } from '../../types/cell.types'; import type { ElementsMeasuredParams } from '../use-on-elements-measured'; @@ -30,7 +31,7 @@ const wrapper = paperRenderElementWrapper({ /** * Wrapper with zero-size elements (ElementModel defaults). * Simulates the flowchart scenario where elements rely on - * ResizeObserver to set their real size via `fromMeasure`. + * ResizeObserver to set their real size via an `autoSize` write. */ const zeroSizeWrapper = paperRenderElementWrapper({ graphProviderProps: { @@ -91,12 +92,12 @@ describe('useOnElementsMeasured', () => { }); // Regression: ElementModel defaults to size {0,0}. The ResizeObserver - // pipeline sets the real size via `cell.set('size', ..., {fromMeasure: true})`. + // pipeline sets the real size via `cell.set('size', ..., { autoSize: true })`. // Previously, the `change:size` listener in graph-changes.ts skipped - // `fromMeasure` writes, so the measured-size never reached the tracking + // measurement writes, so the measured size never reached the tracking // logic and `useOnElementsMeasured` never fired for elements that relied // on DOM measurement (e.g. the flowchart demo). - it('fires callback when elements start at zero size and get measured via fromMeasure', async () => { + it('fires callback when elements start at zero size and get measured', async () => { const callback = jest.fn(); let graphRef: dia.Graph | undefined; @@ -120,7 +121,7 @@ describe('useOnElementsMeasured', () => { // Simulate ResizeObserver setting the real measured size. act(() => { const cell = graphRef!.getCell('zero-el') as dia.Element; - cell.set('size', { width: 100, height: 60 }, { fromMeasure: true } as object); + cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object); }); await waitFor(() => expect(callback).toHaveBeenCalled()); diff --git a/packages/joint-react/src/hooks/use-on-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index 11962a7066..0457e7d9f9 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -12,7 +12,10 @@ import { useLatestRef } from './use-latest-ref'; * @expand */ export interface ElementsMeasuredParams { - /** True on the first measurement pass (at least one element has been sized). */ + /** + * True on the first measurement pass (at least one element has been sized), + * and again on the first pass after a graph reset (`resetCells()`). + */ readonly isInitial: boolean; /** The paper this hook is bound to (the surrounding `` context, or the paper passed via `paperTarget`). */ readonly paper: dia.Paper; @@ -28,16 +31,20 @@ export interface ElementsMeasuredParams { export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; /** - * Calls a callback when element sizes are measured or re-measured. + * Calls a callback once element sizes are known, so a layout can run on them. * - * Fires on the first measurement pass (at least one element has been sized) - * and again whenever an element is re-measured to a different size. A size the - * application writes itself (`cell.resize()`, controlled `cells` sync) is not a - * measurement and does not fire it; listen to `change:size` with - * {@link useOnGraphEvents} to hear every size change. + * Delivers one event per settled change: the first pass (at least one element + * has a size), and each later addition or re-measurement, once no element is + * still waiting to be measured. An element added without a size waits until + * {@link useMeasureElement} (or {@link HTMLHost}) has measured it; a batch + * mixing sized and waiting elements is one event, delivered when the last one + * is measured. A size the application writes itself (`cell.resize()`, + * controlled `cells` sync) is not a measurement and never fires; listen to + * `change:size` with {@link useOnGraphEvents} to hear every size change. * - * The callback receives {@link ElementsMeasuredParams}; check `isInitial` to - * distinguish the first measurement from later ones. + * The callback receives {@link ElementsMeasuredParams}; `isInitial` is `true` + * for the first event after the hook mounts and again for the first event + * after a graph reset (`resetCells()`), which replaces the diagram. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -95,23 +102,26 @@ export function useOnElementsMeasured( const callbackRef = useLatestRef(callback); - const { measureState, graph } = useGraphStore(); - const wasMeasuredRef = useRef(false); + const graphStore = useGraphStore(); + const { measureState, graph } = graphStore; + // The `measureGeneration` this hook last reported; a generation it has not + // seen yet (mount, graph reset) makes the next event `isInitial`. + const reportedGenerationRef = useRef(-1); useLayoutEffect(() => { if (!paperStore) return; const { paper } = paperStore; // A new paper (or graph store) starts its own measurement history, so its // first pass reports `isInitial: true` again — e.g. after a dev-server hot // reload re-created the store, `transformToFitContent()` callers re-fit. - wasMeasuredRef.current = false; + reportedGenerationRef.current = -1; function handleChanges() { - const value = measureState.get(); - const isMeasured = value > 0; - const isInitial = isMeasured && !wasMeasuredRef.current; - if (isInitial) { - wasMeasuredRef.current = true; - } + // A graph reset clears `measureState`; the reset diagram's own first + // pass is reported once it settles. + if (measureState.get() === 0) return; + const { measureGeneration } = graphStore; + const isInitial = reportedGenerationRef.current !== measureGeneration; + reportedGenerationRef.current = measureGeneration; callbackRef.current({ isInitial, paper, graph }); // The user callback may have moved cells via cell.position()/cell.size(). // PaperView runs in async mode, so those updates would be queued for the @@ -129,5 +139,5 @@ export function useOnElementsMeasured( return () => { unsubscribe(); }; - }, [paperStore, measureState, graph, callbackRef]); + }, [paperStore, graphStore, measureState, graph, callbackRef]); } diff --git a/packages/joint-react/src/store/__tests__/graph-changes.test.ts b/packages/joint-react/src/store/__tests__/graph-changes.test.ts index 60f1bed96b..f431b34dc6 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -23,12 +23,16 @@ function setupWithSize() { const graph = createGraph(); const onChanges = jest.fn(); const onElementsSizeChange = jest.fn(); + const onElementRemove = jest.fn(); + const onReset = jest.fn(); const controller = graphChanges({ graph, onChanges, onElementsSizeChange, + onElementRemove, + onReset, }); - return { graph, onChanges, onElementsSizeChange, controller }; + return { graph, onChanges, onElementsSizeChange, onElementRemove, onReset, controller }; } function addElement(graph: dia.Graph, id: string, x = 10, y = 20, width = 100, height = 50) { @@ -417,7 +421,33 @@ describe('graphChanges', () => { ); }); - it('fires when ResizeObserver sets size via fromMeasure flag', () => { + it('fires onReset before the seed cells\' size notifications', () => { + const { graph, onElementsSizeChange, onReset } = setupWithSize(); + onReset.mockImplementation(() => { + expect(onElementsSizeChange).not.toHaveBeenCalled(); + }); + graph.resetCells([ + { id: 'a', type: 'element', position: { x: 0, y: 0 }, size: { width: 100, height: 50 } }, + ]); + + expect(onReset).toHaveBeenCalledTimes(1); + expect(onElementsSizeChange).toHaveBeenCalledTimes(1); + }); + + it('fires onElementRemove for elements only', () => { + const { graph, onElementRemove } = setupWithSize(); + addElement(graph, 'a'); + addElement(graph, 'b'); + addLink(graph, 'l1', 'a', 'b'); + + graph.getCell('l1').remove(); + expect(onElementRemove).not.toHaveBeenCalled(); + + graph.getCell('a').remove(); + expect(onElementRemove).toHaveBeenCalledWith('a'); + }); + + it('forwards the change:size options of a measurement write', () => { const { graph, onElementsSizeChange } = setupWithSize(); graph.resetCells([ { @@ -430,14 +460,14 @@ describe('graphChanges', () => { onElementsSizeChange.mockClear(); const cell = graph.getCell('a') as dia.Element; - cell.set('size', { width: 120, height: 60 }, { fromMeasure: true } as object); + cell.set('size', { width: 120, height: 60 }, { autoSize: true } as object); // The `change:size` options are forwarded so the store can tell a // measurement write from an application resize (#3514). expect(onElementsSizeChange).toHaveBeenCalledWith( 'a', { width: 120, height: 60 }, - expect.objectContaining({ fromMeasure: true }) + expect.objectContaining({ autoSize: true }) ); }); diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index d29c0052c4..eff7adc016 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -92,6 +92,29 @@ describe('GraphStore', () => { store.destroy(false); }); + it('bumps measureState once an unsized element is measured to the size it already has', async () => { + const initialCells: readonly CellRecord[] = [ + { id: 'a', type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } } as CellRecord, + ]; + const store = new GraphStore({ initialCells }); + // The application pre-sizes the waiting element: not a measurement. + (store.graph.getCell('a') as dia.Element).resize(120, 40); + await flush(); + expect(store.measureState.get()).toBe(0); + + // The observer measures the same size, so it writes nothing to the graph. + const node = document.createElement('div'); + store.setMeasuredNode({ id: 'a', node }); + const [callback] = (globalThis.ResizeObserver as jest.Mock).mock.calls.at(-1) as [ + ResizeObserverCallback, + ]; + const entry = { target: node, borderBoxSize: [{ inlineSize: 120, blockSize: 40 }] }; + callback([entry as unknown as ResizeObserverEntry], {} as ResizeObserver); + await flush(); + expect(store.measureState.get()).toBe(1); + store.destroy(false); + }); + it('seeds from dia.Cell instances in initialCells', () => { const element = new ElementModel({ id: 'dia-el', diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 8fd9f754d0..870c180aca 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -97,6 +97,12 @@ interface Options { readonly getElements: () => Map; /** Callback function called when a batch of elements needs to be updated */ readonly onBatchUpdate: (data: Record) => void; + /** + * An observed node was measured, whether or not the size differs from the + * model's (a measurement equal to the model size, or a zero size, writes + * nothing). Fires before `onBatchUpdate` for the same entries. + */ + readonly onElementMeasured?: (id: CellId) => void; } /** @@ -226,6 +232,7 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, getCellTransform, onBatchUpdate, + onElementMeasured, getElements, } = options; @@ -266,6 +273,8 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver ); if (!observedElement) continue; + onElementMeasured?.(observedElement.id); + if (!borderBoxSize || borderBoxSize.length === 0) { continue; } diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index 8899e3af15..e25fe70cf8 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -60,18 +60,29 @@ interface OnChangeOptions { readonly isReset?: boolean; } -interface Options { - readonly graph: dia.Graph; - readonly onChanges: (options: OnChangeOptions) => void; +/** + * Graph events the measurement bookkeeping in `GraphStore` listens to. Forwarded + * unchanged by `graphProjection`. + */ +export interface MeasurementListeners { /** * An element got a size. `changeOptions` are the options of the `change:size` * event; `undefined` when the size arrives with the cell (`add` / `reset`). */ readonly onElementsSizeChange?: ( id: CellId, - size: { width: number; height: number }, + size: dia.Size, changeOptions?: dia.Cell.Options ) => void; + /** An element left the graph (not fired by a `reset`; see {@link onReset}). */ + readonly onElementRemove?: (id: CellId) => void; + /** The graph was reset; fires before the seed cells' `onElementsSizeChange` calls. */ + readonly onReset?: () => void; +} + +interface Options extends MeasurementListeners { + readonly graph: dia.Graph; + readonly onChanges: (options: OnChangeOptions) => void; } interface JointJSEventOptions { @@ -86,7 +97,7 @@ interface JointJSEventOptions { * @returns Controller exposing updateGraph and destroy. */ export function graphChanges(options: Options) { - const { graph, onElementsSizeChange } = options; + const { graph, onElementsSizeChange, onElementRemove, onReset } = options; const changes = new Map>(); let batchDepth = 0; @@ -173,6 +184,7 @@ export function graphChanges(options: Options) { _collection: mvc.Collection, { isUpdateFromReact }: JointJSEventOptions ) => { + if (cell.isElement()) onElementRemove?.(cell.id); if (isUpdateFromReact) return; onCellEvent(cell, 'remove'); } @@ -184,6 +196,7 @@ export function graphChanges(options: Options) { if (eventOptions.isUpdateFromReact) return; isSyncedWithReact = true; changes.clear(); + onReset?.(); for (const cell of collection.models) { changes.set(cell.id, { type: 'add', data: cell }); // `reset` suppresses per-cell `add` events, so size notifications diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index c7fda1c7a4..02578ac446 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -1,6 +1,6 @@ import { type dia } from '@joint/core'; import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types'; -import { graphChanges, type UpdateGraphOptions } from './graph-changes'; +import { graphChanges, type MeasurementListeners, type UpdateGraphOptions } from './graph-changes'; import { asReadonlyContainer, createContainer, type ContainerChangeSet } from './state-container'; import { mergeCellRecord, toCellRecord } from '../state/data-mapping/cell-record-merge'; @@ -36,15 +36,9 @@ export type OnIncrementalCellsChange { +> extends MeasurementListeners { readonly graph: dia.Graph; readonly onIncrementalCellsChange?: OnIncrementalCellsChange; - /** See `graphChanges`: `changeOptions` is `undefined` for sizes arriving with an `add` / `reset`. */ - readonly onElementsSizeChange?: ( - id: CellId, - size: dia.Size, - changeOptions?: dia.Cell.Options - ) => void; } /* eslint-disable sonarjs/cognitive-complexity -- graph→container projection @@ -61,7 +55,8 @@ export function graphProjection< Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, >(options: GraphProjectionState) { - const { graph, onIncrementalCellsChange, onElementsSizeChange } = options; + const { graph, onIncrementalCellsChange, onElementsSizeChange, onElementRemove, onReset } = + options; const cells = createContainer(); @@ -129,6 +124,8 @@ export function graphProjection< const graphChangesController = graphChanges({ graph, onElementsSizeChange, + onElementRemove, + onReset, onChanges: ({ changes, isInsideBatch, deferCommit, isReset }) => { // Elements removed in this batch — swept once after the loop for link // records they may have stranded. diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 75f1b03d7d..e9f1d13846 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -109,12 +109,20 @@ export class GraphStore< public readonly graphProjection: GraphProjection; public readonly internalState: Atom; public readonly measureState: Atom = createAtom(0); + /** + * Incremented by every graph `reset`. `useOnElementsMeasured` reports + * `isInitial` for the first `measureState` bump of each generation. + */ + public measureGeneration = 0; public readonly graph: dia.Graph; public readonly autoSizeOrigin: AutoSizeOrigin; public paperStores = new Map(); public features: Record = {}; private observer: GraphStoreObserver; + /** Elements that arrived without a size and have not been measured yet. */ + private readonly unmeasuredElements = new Set(); + private readonly scheduleMeasurementDelivery: () => void; private onIncrementalCellsChange?: OnIncrementalCellsChange; // dev-only `change:size` listener that warns about resizing auto-sized elements. private warnAutoSizeResize?: (cell: dia.Cell, size: dia.Size, opt?: AutoSizeOptions) => void; @@ -148,12 +156,21 @@ export class GraphStore< graphFeaturesVersion: 1, }); - const elementsMeasured = new Set(); - const onElementSizeChange = () => { - if (elementsMeasured.size > 0) { - this.measureState.set((previous) => previous + 1); - } + // Measurement bookkeeping behind `measureState` (see `useOnElementsMeasured`): + // one bump per settled change. An element is outstanding from arriving + // without a size until a measurement write (`autoSize`), a render that + // nothing measures (`markElementRendered`), or its removal. Sizes the + // application writes are not measurements and never bump (#3514). + const sizedElements = new Set(); + let hasUndeliveredChange = false; + const deliverMeasurement = () => { + if (!hasUndeliveredChange || this.unmeasuredElements.size > 0) return; + // "Measured" means at least one element has a size (`isInitial` contract). + if (sizedElements.size === 0) return; + hasUndeliveredChange = false; + this.measureState.set((previous) => previous + 1); }; + this.scheduleMeasurementDelivery = () => simpleScheduler(deliverMeasurement); this.graphProjection = graphProjection({ graph: this.graph, @@ -161,25 +178,46 @@ export class GraphStore< this.onIncrementalCellsChange?.(changes); }, onElementsSizeChange: (id, size, changeOptions) => { - const wasAnyElementMeasured = elementsMeasured.size > 0; - // Bookkeeping follows every size change: `isInitial` and - // `useAreElementsMeasured` rest on it, whatever wrote the size. if (size.width > 0 && size.height > 0) { - elementsMeasured.add(id); + sizedElements.add(id); } else { - elementsMeasured.delete(id); + sizedElements.delete(id); } - // Waking the subscribers does not (#3514): an application's own resize - // is not a measurement, so only measurement writes (`autoSize`), sizes - // arriving with the cell (`add` / `reset`) and the first element to get - // a size bump `measureState`. - const isApplicationResize = changeOptions !== undefined && !changeOptions[AUTO_SIZE_OPTION]; - if (isApplicationResize && wasAnyElementMeasured) return; - simpleScheduler(onElementSizeChange); + if (changeOptions === undefined) { + // The size arrived with the cell (`add` / `reset`): an unsized element + // is waiting for its measurement, even if the application sizes it + // meanwhile (the measurement overwrites that write anyway). + if (!sizedElements.has(id)) this.unmeasuredElements.add(id); + } else if (changeOptions[AUTO_SIZE_OPTION]) { + this.unmeasuredElements.delete(id); + } else { + return; + } + hasUndeliveredChange = true; + this.scheduleMeasurementDelivery(); + }, + onElementRemove: (id) => { + sizedElements.delete(id); + if (this.unmeasuredElements.delete(id)) this.scheduleMeasurementDelivery(); + }, + onReset: () => { + // The reset replaces the diagram: its first settled pass is a new + // `isInitial` for `useOnElementsMeasured`, and until then nothing is + // measured for `useAreElementsMeasured`. + sizedElements.clear(); + this.unmeasuredElements.clear(); + hasUndeliveredChange = false; + this.measureGeneration += 1; + this.measureState.set(0); }, }); this.observer = createElementsSizeObserver({ + onElementMeasured: (id) => { + // Settles an element whose measurement equals the size it already has + // (the application pre-sized it): the observer then writes nothing. + if (this.unmeasuredElements.delete(id)) this.scheduleMeasurementDelivery(); + }, getElements: () => { // The observer only cares about element-typed cells. Build a Map on // demand from the unified cells container — cold path, called only @@ -441,6 +479,19 @@ export class GraphStore< }; public setMeasuredNode = (options: SetMeasuredNodeOptions) => this.observer.add(options); + + /** + * An element's React content committed. Called by the element portal item + * after its subtree's layout effects, so an element that registered for + * measurement (`useMeasureElement`) is still outstanding, while one that + * arrived without a size and nothing measures is settled now. + * @param id - the rendered element + */ + public markElementRendered = (id: CellId) => { + if (!this.unmeasuredElements.has(id) || this.observer.has(id)) return; + this.unmeasuredElements.delete(id); + this.scheduleMeasurementDelivery(); + }; public getPaperStore = (id: string) => { return this.paperStores.get(id); }; From e902506f2794935dd7d7278e2a420013c45648b5 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 30 Sep 2026 13:48:19 +0700 Subject: [PATCH 3/5] fix(joint-react): update renderElement to accept a label prop for better content rendering --- .../use-on-elements-measured-events.test.tsx | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 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 ad6cef2151..bd8d4b514c 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 @@ -51,8 +51,8 @@ const flushFrame = () => }); /** Elements flagged in their `data` render through a host that measures itself. */ -const renderElement = ({ measured }: { measured?: boolean }) => - measured ? node : ; +const renderElement = ({ measured, label = 'node' }: { measured?: boolean; label?: string }) => + measured ? {label} : ; /** Renders as a plain ``: nothing measures it, so it is settled on arrival. */ const plain = (id: string): CellRecord => @@ -410,3 +410,97 @@ describe('useOnElementsMeasured — how a waiting element settles', () => { expect(harness.events).toHaveLength(1); }); }); + +// The case the hook exists for in a live diagram: `renderElement` renders +// something else (a longer label, an expanded card), the node grows, the +// ResizeObserver reports the new size and the layout runs again. jsdom has no +// layout, so a local ResizeObserver mock delivers the entry the browser would. +describe('useOnElementsMeasured — the content of an element changes', () => { + class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instances.push(this); + } + observe(target: Element) { + this.observed.add(target); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.observed.clear(); + } + /** What the browser reports after layout: the node's new border box. */ + report(target: Element, width: number, height: number) { + const entry = { target, borderBoxSize: [{ inlineSize: width, blockSize: height }] }; + this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + } + + beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; + }); + + /** Mounts one measured element, lets it register, and measures it once. */ + async function mountMeasured() { + const harness = renderGraph([pending('b')]); + await flushFrame(); + // StrictMode mounts the store twice; only the live store's observer has + // the node registered by ``. + const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0); + if (!observer) throw new Error('no ResizeObserver has the measured node registered'); + const [node] = observer.observed; + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: true }]); + harness.events.length = 0; + + const element = harness.graph.getCell('b') as dia.Element; + return { harness, observer, node, element }; + } + + it('delivers one event when the re-rendered content measures larger', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + // The application changes what `renderElement` shows; the browser lays the + // node out larger and the observer reports it. + act(() => { + element.set('data', { measured: true, label: 'a much longer label' }); + }); + await flush(); + expect(node.textContent).toBe('a much longer label'); + expect(harness.events).toHaveLength(0); + + act(() => { + observer.report(node, 240, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + expect(element.size()).toEqual({ width: 240, height: 40 }); + }); + + it('delivers no event when the re-rendered content measures the same', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + act(() => { + element.set('data', { measured: true, label: 'same size' }); + }); + await flush(); + expect(node.textContent).toBe('same size'); + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + expect(element.size()).toEqual({ width: 120, height: 40 }); + }); +}); From d4f6835f8981ab2cf01085b4ed2db829bdabfea0 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 1 Oct 2026 15:30:20 +0700 Subject: [PATCH 4/5] refactor: enhance useOnElementsMeasured to handle zero-sized and culled elements - Updated the measurement handling logic in useOnElementsMeasured to account for elements that are not rendered by the paper or are zero-sized. - Introduced new tests to cover scenarios where elements are culled or removed before measurement. - Improved the flush function to ensure all rendering and measurement processes are settled before events are delivered. - Refactored GraphStore to manage outstanding elements more effectively, ensuring that elements not rendered by any paper do not block measurement events. - Added a settleUnrenderedElements method to clear outstanding elements that are not accounted for by any paper. - Enhanced documentation to clarify the behavior of the measurement system regarding application-written sizes and unmeasured elements. --- .../bench/baseline-post-refactor.json | 445 +++++++----------- .../use-on-elements-measured-events.test.tsx | 173 ++----- ...use-on-elements-measured-settling.test.tsx | 278 +++++++++++ .../src/hooks/use-on-elements-measured.ts | 15 +- .../__tests__/graph-store-features.test.ts | 2 + .../src/store/__tests__/graph-store.test.ts | 3 + packages/joint-react/src/store/graph-store.ts | 77 ++- packages/joint-react/src/store/paper-store.ts | 8 + 8 files changed, 567 insertions(+), 434 deletions(-) create mode 100644 packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx diff --git a/packages/joint-react/bench/baseline-post-refactor.json b/packages/joint-react/bench/baseline-post-refactor.json index f053fa411e..7a5c59c72b 100644 --- a/packages/joint-react/bench/baseline-post-refactor.json +++ b/packages/joint-react/bench/baseline-post-refactor.json @@ -1,7 +1,7 @@ { "meta": { - "savedAt": "2026-09-29T09:41:32.889Z", - "gitSha": "3694337fb957e423025cc777b7c0bffb10ff1780", + "savedAt": "2026-06-26T12:56:20.613Z", + "gitSha": "9a741ac576b9b0336b96e4b121e826969ecfc036", "node": "v24.11.1", "platform": "darwin/arm64" }, @@ -9,160 +9,160 @@ "hooks/key-derivation/n=10": [ { "name": "key derivation (10)", - "opsPerSec": 8578763566.417811, - "mean": 0.00014526272141846027, - "stddev": 0.005573985050010314, + "opsPerSec": 9102033552.308687, + "mean": 0.00012776233369654266, + "stddev": 0.002638525657641091, "min": 0.00004099999978279811, - "max": 8.95425000000023, - "samples": 6884079 + "max": 6.915790999999899, + "samples": 7827033 } ], "hooks/full-iteration-selector/n=10": [ { "name": "full iteration + selector (10)", - "opsPerSec": 3034606829.6510706, - "mean": 0.0003601220298488279, - "stddev": 0.001040629385654209, + "opsPerSec": 2535300828.319926, + "mean": 0.0006300311846347321, + "stddev": 0.008285664941017352, "min": 0.000249999999141437, - "max": 0.7629590000005919, - "samples": 2776837 + "max": 3.906082999999853, + "samples": 1587224 } ], "hooks/single-id-get-selector/n=10": [ { "name": "single-ID get + selector (10)", - "opsPerSec": 11243839690.020199, - "mean": 0.00010536881241425959, - "stddev": 0.0014454404899599436, + "opsPerSec": 9001583723.37333, + "mean": 0.00018175384538777985, + "stddev": 0.006982715983930184, "min": 0, - "max": 1.705167000000074, - "samples": 9490475 + "max": 9.703125, + "samples": 5501981 } ], "hooks/shallow-map-comparison/n=10": [ { "name": "shallow Map equal (10)", - "opsPerSec": 5879262035.302982, - "mean": 0.0001839384632059221, - "stddev": 0.000981543044290958, - "min": 0.00008299999899463728, - "max": 0.5756670000009763, - "samples": 5436601 + "opsPerSec": 5260348561.022442, + "mean": 0.00026741772974151634, + "stddev": 0.018586417085768542, + "min": 0.0001249999986612238, + "max": 35.464584000001196, + "samples": 3739468 }, { "name": "shallow Map unequal (10)", - "opsPerSec": 1763955694.1149502, - "mean": 0.0005990987302711665, - "stddev": 0.010599021555933674, - "min": 0.0004579999986162875, - "max": 13.473500000000058, - "samples": 1669174 + "opsPerSec": 1658734644.1832, + "mean": 0.0007927398140844134, + "stddev": 0.07175949929966026, + "min": 0.000499999998282874, + "max": 65.3604579999992, + "samples": 1261448 } ], "hooks/key-derivation/n=100": [ { "name": "key derivation (100)", - "opsPerSec": 1446542980.8480725, - "mean": 0.0008441791997697341, - "stddev": 0.004736755195518718, - "min": 0.0005409999976109248, - "max": 0.7317079999993439, - "samples": 1184583 + "opsPerSec": 1774311969.0672424, + "mean": 0.0006912953496554602, + "stddev": 0.0042805118715596654, + "min": 0.00041599999894970097, + "max": 3.0222079999985, + "samples": 1446560 } ], "hooks/full-iteration-selector/n=100": [ { "name": "full iteration + selector (100)", - "opsPerSec": 535370371.2353534, - "mean": 0.0019278063559684938, - "stddev": 0.003499072007971766, - "min": 0.0017079999997804407, - "max": 0.4214589999974123, - "samples": 518725 + "opsPerSec": 507671227.5095935, + "mean": 0.002142448513894801, + "stddev": 0.0036657148157697955, + "min": 0.0017499999994470272, + "max": 0.9238330000007409, + "samples": 466757 } ], "hooks/single-id-get-selector/n=100": [ { "name": "single-ID get + selector (100)", - "opsPerSec": 10479381803.693872, - "mean": 0.00010137172043499846, - "stddev": 0.00011722880824566547, - "min": 0, - "max": 0.1224160000019765, - "samples": 9864685 + "opsPerSec": 9785151296.529823, + "mean": 0.00011706911128019761, + "stddev": 0.0005970687730137746, + "min": 0.00004099999932805076, + "max": 0.7452919999996084, + "samples": 8541963 } ], "hooks/shallow-map-comparison/n=100": [ { "name": "shallow Map equal (100)", - "opsPerSec": 608030761.4157907, - "mean": 0.0018511326076937787, - "stddev": 0.004543346095680457, - "min": 0.001374999999825377, - "max": 1.2347499999996217, - "samples": 540210 + "opsPerSec": 619759976.4328091, + "mean": 0.0017797496249183477, + "stddev": 0.004928262145344146, + "min": 0.0014159999991534278, + "max": 1.6197920000049635, + "samples": 561877 }, { "name": "shallow Map unequal (100)", - "opsPerSec": 211625125.88497972, - "mean": 0.00491350507809267, - "stddev": 0.029871937373887145, - "min": 0.004207999998470768, - "max": 13.429667000000336, - "samples": 203521 + "opsPerSec": 214259776.51406443, + "mean": 0.004807214665830148, + "stddev": 0.008679019293239498, + "min": 0.004249999998137355, + "max": 3.7966250000026776, + "samples": 208021 } ], "hooks/key-derivation/n=1000": [ { "name": "key derivation (1000)", - "opsPerSec": 127916435.2687544, - "mean": 0.008999619052750494, - "stddev": 0.016247125394453974, - "min": 0.005834000003233086, - "max": 1.0247080000044662, - "samples": 111123 + "opsPerSec": 158246163.84223586, + "mean": 0.007386272042366138, + "stddev": 0.012492956118353328, + "min": 0.00458299999445444, + "max": 0.7620830000014394, + "samples": 135387 } ], "hooks/full-iteration-selector/n=1000": [ { "name": "full iteration + selector (1000)", - "opsPerSec": 55683956.56902837, - "mean": 0.018627164921936994, - "stddev": 0.013857703850232505, - "min": 0.016207999993639532, - "max": 2.0089999999981956, - "samples": 53686 + "opsPerSec": 53454084.79351535, + "mean": 0.02129658160833463, + "stddev": 0.038570989492294296, + "min": 0.01641599999857135, + "max": 7.478792000001704, + "samples": 46956 } ], "hooks/single-id-get-selector/n=1000": [ { "name": "single-ID get + selector (1000)", - "opsPerSec": 9222473972.877195, - "mean": 0.00011692201056906811, - "stddev": 0.00015297733473298693, + "opsPerSec": 9166420604.692993, + "mean": 0.0001251065802928049, + "stddev": 0.0007809810394888233, "min": 0.00004099999932805076, - "max": 0.13900000000285218, - "samples": 8552710 + "max": 1.1806250000008731, + "samples": 7993185 } ], "hooks/shallow-map-comparison/n=1000": [ { "name": "shallow Map equal (1000)", - "opsPerSec": 45390462.59618543, - "mean": 0.022524016802548175, - "stddev": 0.008659029666390015, - "min": 0.01958300000114832, - "max": 0.6563330000062706, - "samples": 44398 + "opsPerSec": 45520810.90012255, + "mean": 0.02337060094885724, + "stddev": 0.01735478693567373, + "min": 0.019332999996549916, + "max": 2.1166249999951106, + "samples": 42789 }, { "name": "shallow Map unequal (1000)", - "opsPerSec": 18203133.84432556, - "mean": 0.05584196348000504, - "stddev": 0.01364897105149672, - "min": 0.04974999999831198, - "max": 0.4946669999990263, - "samples": 17908 + "opsPerSec": 19032585.013689395, + "mean": 0.05319225739362969, + "stddev": 0.01233989935991809, + "min": 0.04958299999998417, + "max": 0.5364170000029844, + "samples": 18800 } ], "container/set-existing/n=10": [ @@ -311,34 +311,34 @@ "controlled/updateGraph/n=10": [ { "name": "controlled/updateGraph/n=10", - "opsPerSec": 5695418.739507571, - "mean": 0.2416726201063293, - "stddev": 0.30129169915539616, - "min": 0.13020800000003874, - "max": 9.774875000000065, - "samples": 4138 + "opsPerSec": 7200873.030667038, + "mean": 0.1476165065682614, + "stddev": 0.06462752538728161, + "min": 0.12708399999974063, + "max": 1.8675410000000738, + "samples": 6775 } ], "controlled/position-change/n=100": [ { "name": "controlled/position-change/n=100", - "opsPerSec": 132405202.08311844, - "mean": 0.01068072950977208, - "stddev": 0.04603688340034601, - "min": 0.006040999999640917, - "max": 7.828208000000814, - "samples": 93630 + "opsPerSec": 109897013.22483212, + "mean": 0.015529662453024722, + "stddev": 0.0523553713119434, + "min": 0.0059999999994033715, + "max": 6.114709000000403, + "samples": 64394 } ], "controlled/updateGraph/n=100": [ { "name": "controlled/updateGraph/n=100", - "opsPerSec": 681884.3000143886, - "mean": 1.5865391996830183, - "stddev": 0.6062178993579842, - "min": 1.2536250000002838, - "max": 7.847332999999708, - "samples": 631 + "opsPerSec": 542543.6686074686, + "mean": 2.5535463137755285, + "stddev": 3.572476213316869, + "min": 1.2611249999999927, + "max": 57.444915999999466, + "samples": 392 } ], "controlled/position-change/n=1000": [ @@ -355,33 +355,33 @@ "controlled/updateGraph/n=1000": [ { "name": "controlled/updateGraph/n=1000", - "opsPerSec": 55634.03703416929, - "mean": 19.816279953124933, - "stddev": 7.768406709778767, - "min": 13.565166999998837, - "max": 50.02358400000048, + "opsPerSec": 40238.18429834901, + "mean": 27.495591765625022, + "stddev": 9.060631782506034, + "min": 13.829125000000204, + "max": 53.331874999999854, "samples": 64 } ], "controlled/position-change/n=10000": [ { "name": "controlled/position-change/n=10000", - "opsPerSec": 142707947.62040362, - "mean": 0.00787472475568652, - "stddev": 0.03381218782882899, - "min": 0.0060839999987365445, - "max": 4.427625000000262, - "samples": 126989 + "opsPerSec": 114634877.57925694, + "mean": 0.01591508894866051, + "stddev": 0.10630700839023834, + "min": 0.006250000000363798, + "max": 14.191624999999476, + "samples": 62834 } ], "controlled/updateGraph/n=10000": [ { "name": "controlled/updateGraph/n=10000", - "opsPerSec": 6681.370305101578, - "mean": 152.36801039062524, - "stddev": 24.122299550672366, - "min": 133.7072079999998, - "max": 268.4775000000009, + "opsPerSec": 5866.66567428112, + "mean": 185.22899542187494, + "stddev": 89.41845182660455, + "min": 139.24441600000137, + "max": 784.1291249999995, "samples": 64 } ], @@ -475,187 +475,88 @@ "graph-projection/position-change/n=10": [ { "name": "baseline (10)", - "opsPerSec": 150881164.65628707, - "mean": 0.007329107253578483, - "stddev": 0.005658675243558629, - "min": 0.0056670000003578025, - "max": 1.0740420000001905, - "samples": 136443 + "opsPerSec": 161557116.3173571, + "mean": 0.006834966044235606, + "stddev": 0.007308573169337244, + "min": 0.005458000000089669, + "max": 0.9919170000000577, + "samples": 146308 }, { "name": "graphProjection (10)", - "opsPerSec": 132944188.75600669, - "mean": 0.010158226791137589, - "stddev": 0.04905685720815395, - "min": 0.006124999999883585, - "max": 12.317125000000033, - "samples": 98443 + "opsPerSec": 149230760.29084107, + "mean": 0.007903611879074289, + "stddev": 0.01947725138702822, + "min": 0.005957999999736785, + "max": 3.5417919999999867, + "samples": 126525 }, { "name": "GraphStore (10)", - "opsPerSec": 134179724.73545961, - "mean": 0.01105086828524303, - "stddev": 0.09112438912672015, + "opsPerSec": 121930647.74715658, + "mean": 0.014754530938330623, + "stddev": 0.09755133766428084, "min": 0.005957999999736785, - "max": 14.83966700000019, - "samples": 90491 + "max": 15.573499999999513, + "samples": 67780 } ], "graph-projection/position-change/n=100": [ { "name": "baseline (100)", - "opsPerSec": 152544466.83363914, - "mean": 0.007196951017647769, - "stddev": 0.00548889746492172, - "min": 0.005790999999589985, - "max": 0.5253749999992579, - "samples": 138948 + "opsPerSec": 101417810.06484096, + "mean": 0.03332241076307389, + "stddev": 0.45364963030742494, + "min": 0.005874999999832653, + "max": 47.54591699999946, + "samples": 30010 }, { "name": "graphProjection (100)", - "opsPerSec": 126664164.8764175, - "mean": 0.010646020780106543, - "stddev": 0.025594198306057955, - "min": 0.0062499999985448085, - "max": 2.306083999999828, - "samples": 93936 + "opsPerSec": 82146180.94625443, + "mean": 0.03185789700541546, + "stddev": 0.23653690890482493, + "min": 0.006292000000030384, + "max": 24.838708000001134, + "samples": 31390 }, { "name": "GraphStore (100)", - "opsPerSec": 137690163.6841106, - "mean": 0.009491097520927787, - "stddev": 0.04181861439693793, + "opsPerSec": 107824652.24534024, + "mean": 0.01614418052371774, + "stddev": 0.07231301767024387, "min": 0.006124999999883585, - "max": 9.871334000001298, - "samples": 105362 + "max": 8.082916999999725, + "samples": 61942 } ], "graph-projection/position-change/n=1000": [ { "name": "baseline (1000)", - "opsPerSec": 147319685.74764007, - "mean": 0.007909531471962383, - "stddev": 0.028424097856288598, - "min": 0.005832999999256572, - "max": 8.488209000001007, - "samples": 126430 + "opsPerSec": 111711781.03085949, + "mean": 0.016222944538840746, + "stddev": 0.09040577218414217, + "min": 0.005916999998589745, + "max": 14.222999999999956, + "samples": 62386 }, { "name": "graphProjection (1000)", - "opsPerSec": 134868970.16746384, - "mean": 0.008691299770552528, - "stddev": 0.049319439980473355, - "min": 0.006209000001035747, - "max": 9.952667000001384, - "samples": 115058 + "opsPerSec": 110544984.6762098, + "mean": 0.016250856089156912, + "stddev": 0.09428158912855929, + "min": 0.006457999999838648, + "max": 16.033792000000176, + "samples": 61552 }, { "name": "GraphStore (1000)", - "opsPerSec": 141405885.44167387, - "mean": 0.007755944041138492, - "stddev": 0.01777394347925715, + "opsPerSec": 116700306.81772408, + "mean": 0.013303929876136874, + "stddev": 0.050927563796028565, "min": 0.006207999998878222, - "max": 3.365458000000217, - "samples": 128934 - } - ], - "container/batchSet-1-changed/n=100": [ - { - "name": "container/batchSet-1-changed/n=100", - "opsPerSec": 4135020528.4548955, - "mean": 0.00027312453111350595, - "stddev": 0.0018815518271704595, - "min": 0.0001249999995707185, - "max": 2.135915999999952, - "samples": 1830667 - } - ], - "container/batchSet-100-changed/n=100": [ - { - "name": "container/batchSet-100-changed/n=100", - "opsPerSec": 93806504.3061161, - "mean": 0.014892432944260228, - "stddev": 0.09069403326116109, - "min": 0.008624999999938154, - "max": 9.611875000000055, - "samples": 33584 - } - ], - "container/batchSet-mixed/n=100": [ - { - "name": "container/batchSet-mixed/n=100", - "opsPerSec": 59621359.28238026, - "mean": 0.020029507069897416, - "stddev": 0.02346604897075959, - "min": 0.01433400000041729, - "max": 2.1574579999996786, - "samples": 24965 - } - ], - "container/batchSet-1-changed/n=1000": [ - { - "name": "container/batchSet-1-changed/n=1000", - "opsPerSec": 3669744777.4481864, - "mean": 0.0003810449606498482, - "stddev": 0.005580757499422791, - "min": 0.0001249999995707185, - "max": 5.036583999999493, - "samples": 1323246 - } - ], - "container/batchSet-100-changed/n=1000": [ - { - "name": "container/batchSet-100-changed/n=1000", - "opsPerSec": 99503401.89220743, - "mean": 0.01106090443534731, - "stddev": 0.009701904811432342, - "min": 0.008874999999534339, - "max": 0.6752080000005662, - "samples": 45205 - } - ], - "container/batchSet-mixed/n=1000": [ - { - "name": "container/batchSet-mixed/n=1000", - "opsPerSec": 10610244.171916349, - "mean": 0.10217847834082247, - "stddev": 0.05247728389909422, - "min": 0.0852919999997539, - "max": 2.2160000000003492, - "samples": 4894 - } - ], - "container/batchSet-1-changed/n=10000": [ - { - "name": "container/batchSet-1-changed/n=10000", - "opsPerSec": 3602226314.0993004, - "mean": 0.00036328137878230226, - "stddev": 0.004018387742458943, - "min": 0.0001250000004802132, - "max": 1.882792000000336, - "samples": 1376344 - } - ], - "container/batchSet-100-changed/n=10000": [ - { - "name": "container/batchSet-100-changed/n=10000", - "opsPerSec": 101001634.67179693, - "mean": 0.011625845633372265, - "stddev": 0.021969720508986438, - "min": 0.008749999999054126, - "max": 3.3639169999987644, - "samples": 43008 - } - ], - "container/batchSet-mixed/n=10000": [ - { - "name": "container/batchSet-mixed/n=10000", - "opsPerSec": 715204.6381593617, - "mean": 1.5880766000001183, - "stddev": 0.9104198106318999, - "min": 1.1040420000008453, - "max": 11.17804200000137, - "samples": 315 + "max": 5.420540999999503, + "samples": 75167 } ] } 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 bd8d4b514c..ac86c8f011 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 @@ -33,26 +33,22 @@ import type { dia } from '@joint/core'; const PAPER_ID = 'events-paper'; const PAPER_STYLE = { width: 100, height: 100 }; -/** Lets React effects and the scheduler's microtask batch settle. */ -const flush = () => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - /** - * Also waits for the paper's async render frame, in which a newly added - * element's view mounts, its portal content commits and a `useMeasureElement` - * inside it registers with the observer. `flush()` alone lands before that. + * Lets everything settle: React effects, the scheduler's microtask batch, the + * paper's render frame (`requestAnimationFrame`) in which a newly added + * element's view mounts, and the commit after it in which the element's portal + * content mounts and registers with the observer. A microtask, or a plain + * `setTimeout`, lands before that frame, while the portal does not exist yet. */ -const flushFrame = () => +const flush = () => act(async () => { await new Promise((resolve) => requestAnimationFrame(() => resolve())); await new Promise((resolve) => setTimeout(resolve, 0)); }); /** Elements flagged in their `data` render through a host that measures itself. */ -const renderElement = ({ measured, label = 'node' }: { measured?: boolean; label?: string }) => - measured ? {label} : ; +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 => @@ -64,22 +60,27 @@ const plain = (id: string): CellRecord => data: {}, }) as CellRecord; -/** Arrives without a size, but renders as a plain `` that nothing measures. */ -const unsized = (id: string): 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: {}, + data: { measured: true }, }) as CellRecord; -/** Renders through ``: registers for measurement and waits for a size. */ -const pending = (id: string): 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 }, - data: { measured: true }, + size: { width: 0, height: 0 }, + data: {}, }) as CellRecord; /** One delivered event, reduced to what these tests assert on. */ @@ -344,6 +345,9 @@ describe('useOnElementsMeasured — sizes written by the application', () => { expect(harness.events).toHaveLength(0); act(() => { + // A size other than the measured one: writing the measured size itself + // would be a no-op `set()` in JointJS, with no `change:size` for anything + // to observe (the browser's ResizeObserver covers that case instead). (harness.graph.getCell('b') as dia.Element).resize(70, 70); }); await flush(); @@ -357,150 +361,45 @@ describe('useOnElementsMeasured — sizes written by the application', () => { }); }); -// What ends the wait besides a measurement write: the element renders and -// nothing measures it, the observer measures it to the size it already has, -// or it leaves the graph. -describe('useOnElementsMeasured — how a waiting element settles', () => { - it('delivers one event once an unsized element that nothing measures has rendered', async () => { - const harness = renderGraph([plain('a')]); - await settleAndClear(harness); +// 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')]); - act(() => { - harness.graph.addCell(unsized('b') as never); - }); + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); await flush(); - expect(harness.events).toHaveLength(0); - - await flushFrame(); expect(harness.events).toHaveLength(1); + expect(harness.events[0].isInitial).toBe(true); }); - it('keeps waiting for an element that registered for measurement when it rendered', async () => { + it('delivers the batch that adds a zero-sized element', async () => { const harness = renderGraph([plain('a')]); await settleAndClear(harness); act(() => { - harness.graph.addCell(pending('b') as never); + harness.graph.addCells([plain('b'), anchor('anchor')] as never); }); - await flushFrame(); - expect(harness.events).toHaveLength(0); - - reportMeasurement(harness.graph, 'b'); await flush(); expect(harness.events).toHaveLength(1); }); - it('delivers the batch once a waiting element is removed before it is measured', async () => { - const harness = renderGraph([plain('a')]); + 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.addCells([plain('b'), pending('c')] as never); + harness.graph.addCell(pending('b') as never); }); await flush(); expect(harness.events).toHaveLength(0); - act(() => { - harness.graph.getCell('c').remove(); - }); + reportMeasurement(harness.graph, 'b'); await flush(); expect(harness.events).toHaveLength(1); }); }); - -// The case the hook exists for in a live diagram: `renderElement` renders -// something else (a longer label, an expanded card), the node grows, the -// ResizeObserver reports the new size and the layout runs again. jsdom has no -// layout, so a local ResizeObserver mock delivers the entry the browser would. -describe('useOnElementsMeasured — the content of an element changes', () => { - class TestResizeObserver { - static readonly instances: TestResizeObserver[] = []; - readonly observed = new Set(); - private readonly callback: ResizeObserverCallback; - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - TestResizeObserver.instances.push(this); - } - observe(target: Element) { - this.observed.add(target); - } - unobserve(target: Element) { - this.observed.delete(target); - } - disconnect() { - this.observed.clear(); - } - /** What the browser reports after layout: the node's new border box. */ - report(target: Element, width: number, height: number) { - const entry = { target, borderBoxSize: [{ inlineSize: width, blockSize: height }] }; - this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); - } - } - - beforeEach(() => { - TestResizeObserver.instances.length = 0; - globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; - }); - - /** Mounts one measured element, lets it register, and measures it once. */ - async function mountMeasured() { - const harness = renderGraph([pending('b')]); - await flushFrame(); - // StrictMode mounts the store twice; only the live store's observer has - // the node registered by ``. - const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0); - if (!observer) throw new Error('no ResizeObserver has the measured node registered'); - const [node] = observer.observed; - - act(() => { - observer.report(node, 120, 40); - }); - await flush(); - expect(harness.events).toEqual([{ isInitial: true }]); - harness.events.length = 0; - - const element = harness.graph.getCell('b') as dia.Element; - return { harness, observer, node, element }; - } - - it('delivers one event when the re-rendered content measures larger', async () => { - const { harness, observer, node, element } = await mountMeasured(); - - // The application changes what `renderElement` shows; the browser lays the - // node out larger and the observer reports it. - act(() => { - element.set('data', { measured: true, label: 'a much longer label' }); - }); - await flush(); - expect(node.textContent).toBe('a much longer label'); - expect(harness.events).toHaveLength(0); - - act(() => { - observer.report(node, 240, 40); - }); - await flush(); - - expect(harness.events).toEqual([{ isInitial: false }]); - expect(element.size()).toEqual({ width: 240, height: 40 }); - }); - - it('delivers no event when the re-rendered content measures the same', async () => { - const { harness, observer, node, element } = await mountMeasured(); - - act(() => { - element.set('data', { measured: true, label: 'same size' }); - }); - await flush(); - expect(node.textContent).toBe('same size'); - act(() => { - observer.report(node, 120, 40); - }); - await flush(); - - expect(harness.events).toHaveLength(0); - expect(element.size()).toEqual({ width: 120, height: 40 }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx new file mode 100644 index 0000000000..326e43c1fd --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx @@ -0,0 +1,278 @@ +/** + * Scenarios beyond the #3520 specification in `use-on-elements-measured-events`: + * an element the paper does not render, a waiting element that is removed, and + * a content change that re-measures. Same harness and helpers as the spec, + * except that `flush()` also awaits the paper's render frame, in which a newly + * added element's view mounts and its portal content registers a measurer. + */ +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'; +import type { CellRecord } from '../../types/cell.types'; +import type { PaperProps } from '../../components/paper/paper.types'; +import type { dia } from '@joint/core'; + +const PAPER_ID = 'events-paper'; +const PAPER_STYLE = { width: 100, height: 100 }; + +/** + * Lets everything settle: React effects, the scheduler's microtask batch, the + * paper's render frame (`requestAnimationFrame`) in which a newly added + * element's view mounts, and the commit after it in which the element's portal + * content mounts and registers with the observer. A microtask, or a plain + * `setTimeout`, lands before that frame, while the portal does not exist yet. + */ +const flush = () => + act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +/** Elements flagged in their `data` render through a host that measures itself. */ +const renderElement = ({ measured, label = 'node' }: { measured?: boolean; label?: string }) => + measured ? {label} : ; + +/** 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; + +/** 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; + +/** + * 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; +} + +interface Harness { + readonly graph: dia.Graph; + readonly events: RecordedEvent[]; +} + +/** Renders a graph with one hook instance mounted beside the paper. */ +function renderGraph(initialCells: CellRecord[], paperProps: Partial = {}): Harness { + const events: RecordedEvent[] = []; + let graph: dia.Graph | undefined; + + function Probe() { + const { graph: currentGraph } = useGraphStore(); + graph = currentGraph; + useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { + events.push({ isInitial }); + }); + return null; + } + + render( + + + + + ); + + return { graph: graph as dia.Graph, events }; +} + +/** 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; +} + +// An element the paper never renders: culled by `cellVisibility` (or by the +// viewport). Nothing can measure it while it is unmounted, so it is settled +// with the size it has, whatever that is; when it mounts and registers a +// measurer, it becomes outstanding then. +const hideAnchor: PaperProps['cellVisibility'] = ({ model }) => model.id !== 'anchor'; + +describe('useOnElementsMeasured — an element the paper does not render', () => { + it('delivers the seed pass with a culled zero-sized element in the graph', async () => { + const harness = renderGraph([plain('a'), anchor('anchor')], { cellVisibility: hideAnchor }); + + await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); + + it('delivers the batch that adds a culled zero-sized element', async () => { + const harness = renderGraph([plain('a')], { cellVisibility: hideAnchor }); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), anchor('anchor')] as never); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// What else ends the wait: the waiting element leaves the graph. +describe('useOnElementsMeasured — a waiting element is removed', () => { + it('delivers the batch once the waiting element is removed before it is measured', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCells([plain('b'), pending('c')] as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + act(() => { + harness.graph.getCell('c').remove(); + }); + await flush(); + + expect(harness.events).toHaveLength(1); + }); +}); + +// The case the hook exists for in a live diagram: `renderElement` renders +// something else (a longer label, an expanded card), the node grows, the +// ResizeObserver reports the new size and the layout runs again. jsdom has no +// layout, so a local ResizeObserver mock delivers the entry the browser would. +describe('useOnElementsMeasured — the content of an element changes', () => { + class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + TestResizeObserver.instances.push(this); + } + observe(target: Element) { + this.observed.add(target); + } + unobserve(target: Element) { + this.observed.delete(target); + } + disconnect() { + this.observed.clear(); + } + /** What the browser reports after layout: the node's new border box. */ + report(target: Element, width: number, height: number) { + const entry = { target, borderBoxSize: [{ inlineSize: width, blockSize: height }] }; + this.callback([entry as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + } + + beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; + }); + + /** Mounts one measured element, lets it register, and measures it once. */ + async function mountMeasured() { + const harness = renderGraph([pending('b')]); + await flush(); + // StrictMode mounts the store twice; only the live store's observer has + // the node registered by ``. + const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0); + if (!observer) throw new Error('no ResizeObserver has the measured node registered'); + const [node] = observer.observed; + + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + expect(harness.events).toEqual([{ isInitial: true }]); + harness.events.length = 0; + + const element = harness.graph.getCell('b') as dia.Element; + return { harness, observer, node, element }; + } + + it('delivers one event when the re-rendered content measures larger', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + // The application changes what `renderElement` shows; the browser lays the + // node out larger and the observer reports it. + act(() => { + element.set('data', { measured: true, label: 'a much longer label' }); + }); + await flush(); + expect(node.textContent).toBe('a much longer label'); + expect(harness.events).toHaveLength(0); + + act(() => { + observer.report(node, 240, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + expect(element.size()).toEqual({ width: 240, height: 40 }); + }); + + it('delivers no event when the re-rendered content measures the same', async () => { + const { harness, observer, node, element } = await mountMeasured(); + + act(() => { + element.set('data', { measured: true, label: 'same size' }); + }); + await flush(); + expect(node.textContent).toBe('same size'); + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toHaveLength(0); + expect(element.size()).toEqual({ width: 120, height: 40 }); + }); + + it('measures an element the application pre-sized to what it will measure', async () => { + const harness = renderGraph([pending('b')]); + await flush(); + const observer = TestResizeObserver.instances.find((instance) => instance.observed.size > 0); + if (!observer) throw new Error('no ResizeObserver has the measured node registered'); + const [node] = observer.observed; + + act(() => { + (harness.graph.getCell('b') as dia.Element).resize(120, 40); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + // Same size as the model: the observer writes nothing, yet the element is + // measured now and the pass is delivered. + act(() => { + observer.report(node, 120, 40); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: true }]); + }); +}); diff --git a/packages/joint-react/src/hooks/use-on-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index 0457e7d9f9..0715133e97 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -35,12 +35,15 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * * Delivers one event per settled change: the first pass (at least one element * has a size), and each later addition or re-measurement, once no element is - * still waiting to be measured. An element added without a size waits until - * {@link useMeasureElement} (or {@link HTMLHost}) has measured it; a batch - * mixing sized and waiting elements is one event, delivered when the last one - * is measured. A size the application writes itself (`cell.resize()`, - * controlled `cells` sync) is not a measurement and never fires; listen to - * `change:size` with {@link useOnGraphEvents} to hear every size change. + * still waiting. An added element waits until the paper has rendered it; if + * its content measures itself ({@link useMeasureElement}, {@link HTMLHost}), + * until that measurement arrives. A batch mixing plain and measured elements + * is one event, delivered when the last one is measured. An element the paper + * does not render (viewport culling, `cellVisibility`) and a zero-sized element + * nothing measures do not hold the event back. A size the application writes + * itself (`cell.resize()`, controlled `cells` sync) is not a measurement and + * never fires; listen to `change:size` with {@link useOnGraphEvents} to hear + * every size change. * * The callback receives {@link ElementsMeasuredParams}; `isInitial` is `true` * for the first event after the hook mounts and again for the first event diff --git a/packages/joint-react/src/store/__tests__/graph-store-features.test.ts b/packages/joint-react/src/store/__tests__/graph-store-features.test.ts index ec9fe025be..5557aea252 100644 --- a/packages/joint-react/src/store/__tests__/graph-store-features.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store-features.test.ts @@ -426,6 +426,8 @@ describe('GraphStore.clearViewForElementAndLinks', () => { const fakePaper = { getCellView: jest.fn().mockReturnValue(fakeElementView), remove: jest.fn(), + // `destroy()` unsubscribes the store's `render:done` listener. + off: jest.fn(), } as unknown as dia.Paper; // Forcefully redirect paperStore.paper to point at the mock so the diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index eff7adc016..142f41d60b 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -97,6 +97,9 @@ describe('GraphStore', () => { { id: 'a', type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 } } as CellRecord, ]; const store = new GraphStore({ initialCells }); + await flush(); + // Nothing has a size yet, so the seed pass is not delivered. + expect(store.measureState.get()).toBe(0); // The application pre-sizes the waiting element: not a measurement. (store.graph.getCell('a') as dia.Element).resize(120, 40); await flush(); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index e9f1d13846..22d745f31f 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -13,6 +13,7 @@ import { LINK_MODEL_TYPE, LinkModel } from '../mvc/link-model'; import { isElementType, isLinkType } from '../utils/cell-type'; import { clearConnectedLinkViews } from './clear-view'; import { LAYOUT_UPDATE_EVENT } from './graph-changes'; +import { isPaperView } from '../mvc/paper'; import { createAtom, type Atom } from './state-container'; import type { IncrementalChange } from '../state/incremental.types'; import type { Feature } from '../types/feature.types'; @@ -120,8 +121,8 @@ export class GraphStore< public features: Record = {}; private observer: GraphStoreObserver; - /** Elements that arrived without a size and have not been measured yet. */ - private readonly unmeasuredElements = new Set(); + /** Elements added to the graph that no paper has accounted for yet (see the constructor). */ + private readonly outstandingElements = new Set(); private readonly scheduleMeasurementDelivery: () => void; private onIncrementalCellsChange?: OnIncrementalCellsChange; // dev-only `change:size` listener that warns about resizing auto-sized elements. @@ -157,14 +158,20 @@ export class GraphStore< }); // Measurement bookkeeping behind `measureState` (see `useOnElementsMeasured`): - // one bump per settled change. An element is outstanding from arriving - // without a size until a measurement write (`autoSize`), a render that - // nothing measures (`markElementRendered`), or its removal. Sizes the - // application writes are not measurements and never bump (#3514). + // one bump per settled change. An element is outstanding from arriving in + // the graph until it is accounted for: its React content committed without + // registering a measurer (`markElementRendered`), no paper renders it + // (`settleUnrenderedElements`, after a paper's render pass), or, when it did + // register one (`useMeasureElement`), the observer measured it. A zero size + // is never read as "waiting": it is a legal final size (a layout anchor). + // Sizes the application writes are not measurements and never bump (#3514). const sizedElements = new Set(); let hasUndeliveredChange = false; const deliverMeasurement = () => { - if (!hasUndeliveredChange || this.unmeasuredElements.size > 0) return; + if (!hasUndeliveredChange) return; + // Papers account for outstanding elements; without one nothing renders + // or measures, so there is nothing to wait for. + if (this.outstandingElements.size > 0 && this.paperStores.size > 0) return; // "Measured" means at least one element has a size (`isInitial` contract). if (sizedElements.size === 0) return; hasUndeliveredChange = false; @@ -184,12 +191,11 @@ export class GraphStore< sizedElements.delete(id); } if (changeOptions === undefined) { - // The size arrived with the cell (`add` / `reset`): an unsized element - // is waiting for its measurement, even if the application sizes it - // meanwhile (the measurement overwrites that write anyway). - if (!sizedElements.has(id)) this.unmeasuredElements.add(id); + // The size arrived with the cell (`add` / `reset`): outstanding until + // a paper has rendered it, whatever the size says. + this.outstandingElements.add(id); } else if (changeOptions[AUTO_SIZE_OPTION]) { - this.unmeasuredElements.delete(id); + this.outstandingElements.delete(id); } else { return; } @@ -198,14 +204,14 @@ export class GraphStore< }, onElementRemove: (id) => { sizedElements.delete(id); - if (this.unmeasuredElements.delete(id)) this.scheduleMeasurementDelivery(); + if (this.outstandingElements.delete(id)) this.scheduleMeasurementDelivery(); }, onReset: () => { // The reset replaces the diagram: its first settled pass is a new // `isInitial` for `useOnElementsMeasured`, and until then nothing is // measured for `useAreElementsMeasured`. sizedElements.clear(); - this.unmeasuredElements.clear(); + this.outstandingElements.clear(); hasUndeliveredChange = false; this.measureGeneration += 1; this.measureState.set(0); @@ -216,7 +222,7 @@ export class GraphStore< onElementMeasured: (id) => { // Settles an element whose measurement equals the size it already has // (the application pre-sized it): the observer then writes nothing. - if (this.unmeasuredElements.delete(id)) this.scheduleMeasurementDelivery(); + if (this.outstandingElements.delete(id)) this.scheduleMeasurementDelivery(); }, getElements: () => { // The observer only cares about element-typed cells. Build a Map on @@ -483,15 +489,48 @@ export class GraphStore< /** * An element's React content committed. Called by the element portal item * after its subtree's layout effects, so an element that registered for - * measurement (`useMeasureElement`) is still outstanding, while one that - * arrived without a size and nothing measures is settled now. + * measurement (`useMeasureElement`) stays outstanding until measured, while + * one that nothing measures is settled now. * @param id - the rendered element */ public markElementRendered = (id: CellId) => { - if (!this.unmeasuredElements.has(id) || this.observer.has(id)) return; - this.unmeasuredElements.delete(id); + if (!this.outstandingElements.has(id) || this.observer.has(id)) return; + this.outstandingElements.delete(id); this.scheduleMeasurementDelivery(); }; + + /** + * A paper finished a render pass (`render:done`): every view it mounts for + * this pass is in the DOM now. An outstanding element that no paper renders + * (viewport culling, `cellVisibility`, a hidden group) is not going to be + * measured, so it is settled with the size it has; one with a mounted view + * waits for its content to commit (`markElementRendered`). O(outstanding), + * which is empty between changes. + */ + public settleUnrenderedElements = () => { + let didSettle = false; + for (const id of this.outstandingElements) { + if (this.observer.has(id) || this.isRenderedByAnyPaper(id)) continue; + this.outstandingElements.delete(id); + didSettle = true; + } + if (didSettle) this.scheduleMeasurementDelivery(); + }; + + /** + * Whether some paper mounted the element's view and will render React + * content into it, which is the only way a measurer gets registered: the + * paper has a `renderElement` and the view has a portal node. + */ + private isRenderedByAnyPaper(id: CellId): boolean { + for (const paperStore of this.paperStores.values()) { + if (!paperStore.renderElement) continue; + const view = paperStore.getElementView(id); + if (!view?.el.isConnected || !isPaperView(view.paper)) continue; + if (view.paper.getCellViewPortalNode(view)) return true; + } + return false; + } public getPaperStore = (id: string) => { return this.paperStores.get(id); }; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index b34af21f15..9ddf68b5da 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -104,6 +104,8 @@ export class PaperStore { /** Link changes pending flush, populated by clearView, flushed in afterRender. */ private pendingLinkChanges: Map> = new Map(); + /** The graph store's `render:done` listener, kept to unsubscribe an adopted paper. */ + private readonly settleUnrenderedElements: () => void; constructor(options: PaperStoreOptions) { const { @@ -178,6 +180,11 @@ export class PaperStore { // prop-update effect runs (or when the store is used without the hook). this.nativeCellVisibility = this.paper.options.cellVisibility; + // After each render pass the graph store learns which added elements no + // paper renders, so they do not hold `useOnElementsMeasured` open. + this.settleUnrenderedElements = graphStore.settleUnrenderedElements; + this.paper.on('render:done', this.settleUnrenderedElements); + if (transform !== undefined) { this.paper.matrix(toSVGMatrix(transform)); } @@ -266,6 +273,7 @@ export class PaperStore { * Should be called when the paper is being removed from the graph store. */ public destroy = () => { + this.paper.off('render:done', this.settleUnrenderedElements); // An adopted paper is owned by its creator (e.g. ``), which // removes it itself — removing it here would kill a paper still in use, // breaking the next adoption (notably under React StrictMode remounts). From 5ea92216eddfb5599cf42e1890348552d8b152ad Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 1 Oct 2026 16:46:06 +0700 Subject: [PATCH 5/5] fix(joint-react): ensure useOnElementsMeasured fires once per settled change without pending measurements --- .changeset/react-elements-measured-app-resize.md | 2 +- .changeset/react-elements-measured-reset.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/react-elements-measured-reset.md diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md index 8b0416b1af..9ed255334c 100644 --- a/.changeset/react-elements-measured-app-resize.md +++ b/.changeset/react-elements-measured-app-resize.md @@ -2,4 +2,4 @@ "@joint/react": patch --- -useOnElementsMeasured - fix to fire once per settled change: not for application-written sizes, not while an added element waits to be measured +useOnElementsMeasured - fix to fire exactly once per settled change, when no element is still waiting to be measured diff --git a/.changeset/react-elements-measured-reset.md b/.changeset/react-elements-measured-reset.md deleted file mode 100644 index 3d36367599..0000000000 --- a/.changeset/react-elements-measured-reset.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": patch ---- - -useOnElementsMeasured - report `isInitial` again for the first pass after a graph reset (`resetCells()`)