From 3694337fb957e423025cc777b7c0bffb10ff1780 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 24 Sep 2026 14:19:12 +0700 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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()`) From 4e7e7de43e008e9c1f5fcc333a4a9de243f27ec3 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 2 Oct 2026 19:44:46 +0700 Subject: [PATCH 6/9] feat(joint-react): measurement handling in joint-react - Removed the export of create-elements-size-observer from index.ts. - Introduced a new measurement.ts file to manage element size measurements. - Updated paper-store.ts to integrate with the new measurement system. - Modified state-container.ts to adjust import paths for selector utilities. - Enhanced scheduler tests to handle callback errors and cascading callbacks. - Replaced useOnElementsMeasured with useOnCellsChange in various examples and stories to reflect the new measurement approach. - Added warnings for selector misuse and cascading callbacks in dev-warnings.ts. - Updated examples to utilize the new selectMeasuredState selector for layout adjustments based on element sizes. --- .changeset/react-on-cells-change.md | 6 + .changeset/react-store-callbacks.md | 5 + .../graph-provider-hot-reload.test.tsx | 14 +- .../src/components/graph/graph-provider.tsx | 2 +- .../__tests__/paper-element-item.test.tsx | 32 +- .../render-element/paper-element-item.tsx | 12 +- ...s.test.tsx => measurement-events.test.tsx} | 24 +- ...test.tsx => measurement-settling.test.tsx} | 84 ++- .../__tests__/use-measure-element.test.tsx | 28 +- .../__tests__/use-on-cells-change.test.tsx | 578 ++++++++++++++ .../use-on-elements-measured.test.tsx | 116 +-- packages/joint-react/src/hooks/index.ts | 1 + .../src/hooks/use-are-elements-measured.ts | 21 - packages/joint-react/src/hooks/use-cells.ts | 100 ++- .../src/hooks/use-create-portal-paper.tsx | 16 +- .../src/hooks/use-measure-element.tsx | 22 +- .../src/hooks/use-on-cells-change.ts | 140 ++++ .../src/hooks/use-on-elements-measured.ts | 56 +- packages/joint-react/src/index.ts | 19 +- packages/joint-react/src/internal.ts | 3 +- .../__tests__/selector-utils.test.ts | 0 .../__tests__/source-selector.test.ts | 30 + ...{index.test.ts => store-selectors.test.ts} | 4 +- packages/joint-react/src/selectors/index.ts | 27 +- .../src/selectors/measurement-selectors.ts | 105 +++ .../{utils => selectors}/selector-utils.ts | 6 +- .../src/selectors/source-selector.ts | 47 ++ .../src/selectors/store-selectors.ts | 19 + .../state/data-mapping/cell-record-merge.ts | 2 +- .../src/store/__tests__/clear-view.test.ts | 123 --- .../create-elements-size-observer.test.ts | 608 --------------- .../src/store/__tests__/graph-changes.test.ts | 131 ---- .../__tests__/graph-store-features.test.ts | 10 +- .../src/store/__tests__/graph-store.test.ts | 55 +- .../src/store/__tests__/measurement.test.ts | 709 ++++++++++++++++++ packages/joint-react/src/store/clear-view.ts | 74 -- .../store/create-elements-size-observer.ts | 383 ---------- .../joint-react/src/store/graph-changes.ts | 46 +- .../joint-react/src/store/graph-projection.ts | 18 +- packages/joint-react/src/store/graph-store.ts | 234 +----- packages/joint-react/src/store/index.ts | 1 - packages/joint-react/src/store/measurement.ts | 396 ++++++++++ packages/joint-react/src/store/paper-store.ts | 12 +- .../joint-react/src/store/state-container.ts | 2 +- .../src/utils/__tests__/scheduler.test.ts | 71 ++ .../joint-react/src/utils/dev-warnings.ts | 36 + packages/joint-react/src/utils/scheduler.ts | 35 +- .../automatic-layout-storage/code.tsx | 7 +- .../automatic-layout-storage/story.tsx | 2 +- .../examples/automatic-layout/code.tsx | 9 +- .../examples/automatic-layout/story.tsx | 2 +- .../examples/collapsible-subtrees/code.tsx | 20 +- .../examples/dynamic-status-icons/code.tsx | 16 +- .../examples/dynamic-status-icons/story.tsx | 2 +- .../examples/element-controls/code.tsx | 18 +- .../examples/element-controls/story.tsx | 2 +- .../stories/examples/flowchart/code.tsx | 45 +- .../stories/examples/introduction/code.tsx | 17 +- 58 files changed, 2673 insertions(+), 1930 deletions(-) create mode 100644 .changeset/react-on-cells-change.md create mode 100644 .changeset/react-store-callbacks.md rename packages/joint-react/src/hooks/__tests__/{use-on-elements-measured-events.test.tsx => measurement-events.test.tsx} (92%) rename packages/joint-react/src/hooks/__tests__/{use-on-elements-measured-settling.test.tsx => measurement-settling.test.tsx} (76%) create mode 100644 packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx delete mode 100644 packages/joint-react/src/hooks/use-are-elements-measured.ts create mode 100644 packages/joint-react/src/hooks/use-on-cells-change.ts rename packages/joint-react/src/{utils => selectors}/__tests__/selector-utils.test.ts (100%) create mode 100644 packages/joint-react/src/selectors/__tests__/source-selector.test.ts rename packages/joint-react/src/selectors/__tests__/{index.test.ts => store-selectors.test.ts} (93%) create mode 100644 packages/joint-react/src/selectors/measurement-selectors.ts rename packages/joint-react/src/{utils => selectors}/selector-utils.ts (95%) create mode 100644 packages/joint-react/src/selectors/source-selector.ts create mode 100644 packages/joint-react/src/selectors/store-selectors.ts delete mode 100644 packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts create mode 100644 packages/joint-react/src/store/__tests__/measurement.test.ts delete mode 100644 packages/joint-react/src/store/create-elements-size-observer.ts create mode 100644 packages/joint-react/src/store/measurement.ts diff --git a/.changeset/react-on-cells-change.md b/.changeset/react-on-cells-change.md new file mode 100644 index 0000000000..98e65952fa --- /dev/null +++ b/.changeset/react-on-cells-change.md @@ -0,0 +1,6 @@ +--- +"@joint/react": minor +--- + +useOnCellsChange - add the hook, with the `selectMeasuredState`, `selectIsMeasured` and `selectElementsSizes` selectors +`useOnElementsMeasured` is deprecated in favor of it. diff --git a/.changeset/react-store-callbacks.md b/.changeset/react-store-callbacks.md new file mode 100644 index 0000000000..0103451a05 --- /dev/null +++ b/.changeset/react-store-callbacks.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + + - fix a store callback that throws or changes the graph breaking later updates diff --git a/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx b/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx index 112e9a3dcb..316638df26 100644 --- a/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx +++ b/packages/joint-react/src/components/graph/__tests__/graph-provider-hot-reload.test.tsx @@ -46,7 +46,8 @@ import type * as ElementModelModule from '../../../mvc/element-model'; import type * as LinkModelModule from '../../../mvc/link-model'; import type * as UseGraphStoreModule from '../../../hooks/use-graph-store'; import type * as UseCellIdsModule from '../../../hooks/use-cell-ids'; -import type * as UseOnElementsMeasuredModule from '../../../hooks/use-on-elements-measured'; +import type * as UseOnCellsChangeModule from '../../../hooks/use-on-cells-change'; +import type * as SelectorsModule from '../../../selectors'; import type * as GraphProviderModule from '../graph-provider'; import type * as PaperModule from '../../paper/paper'; @@ -88,8 +89,11 @@ const linkModelModule: typeof LinkModelModule = require('../../../mvc/link-model const { useGraphStore }: typeof UseGraphStoreModule = require('../../../hooks/use-graph-store'); const { useCellIds }: typeof UseCellIdsModule = require('../../../hooks/use-cell-ids'); const { - useOnElementsMeasured, -}: typeof UseOnElementsMeasuredModule = require('../../../hooks/use-on-elements-measured'); + useOnCellsChange, +}: typeof UseOnCellsChangeModule = require('../../../hooks/use-on-cells-change'); +const { + selectMeasuredState, +}: typeof SelectorsModule = require('../../../selectors'); const graphProviderV1: typeof GraphProviderModule = require('../graph-provider'); const paperV1: typeof PaperModule = require('../../paper/paper'); @@ -191,8 +195,8 @@ function Probe() { mountSequence += 1; return mountSequence; }); - useOnElementsMeasured(({ isInitial }) => { - measuredCalls.push(isInitial); + useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => { + if (version) measuredCalls.push(!previousMeasuredState); }); const ids = useCellIds(); return h( diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 7556ab9dcc..a08a752b74 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -3,7 +3,7 @@ import React, { memo, useLayoutEffect, useRef } from 'react'; import { useImperativeApi } from '../../hooks/use-imperative-api'; import { GraphStoreContext } from '../../context'; import { GraphStore } from '../../store'; -import type { AutoSizeOrigin } from '../../store/graph-store'; +import type { AutoSizeOrigin } from '../../store/measurement'; import type { OnIncrementalCellsChange } from '../../store/graph-projection'; import type { ElementJSONInit, LinkJSONInit, CellInput } from '../../types/cell.types'; diff --git a/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx b/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx index 8acc8e395d..4556108a9c 100644 --- a/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx +++ b/packages/joint-react/src/components/paper/render-element/__tests__/paper-element-item.test.tsx @@ -10,7 +10,9 @@ import { CellIdContext, GraphStoreContext, PaperStoreContext } from '../../../.. import { ELEMENT_MODEL_TYPE } from '../../../../mvc/element-model'; import type { CellRecord, CellId } from '../../../../types/cell.types'; -const RenderEmpty: ComponentType> = () => ; +const RenderEmpty: ComponentType> = () => ( + +); const CELLS: readonly CellRecord[] = [ { @@ -27,7 +29,11 @@ const CELLS: readonly CellRecord[] = [ * can re-mount SVG / HTML element items with `portalElement={null}` for the * defensive guard branches. */ -function StoreCapture({ onCapture }: { readonly onCapture: (graph: unknown, paper: unknown) => void }) { +function StoreCapture({ + onCapture, +}: { + readonly onCapture: (graph: unknown, paper: unknown) => void; +}) { const graphStore = useContext(GraphStoreContext); const paperStore = useContext(PaperStoreContext); if (graphStore && paperStore) onCapture(graphStore, paperStore); @@ -63,11 +69,7 @@ describe('paper-element-item exports', () => { value={capturedPaper as React.ContextType} > - + @@ -101,11 +103,7 @@ describe('paper-element-item exports', () => { value={capturedPaper as React.ContextType} > - + @@ -143,18 +141,16 @@ describe('paper-element-item exports', () => { value={capturedPaper as React.ContextType} > - + ); // Placeholder wrapper should still be created with id and zero geometry. - const wrapper = portalTarget.querySelector('div[model-id="missing-cell-id"]') as HTMLDivElement | null; + const wrapper = portalTarget.querySelector( + 'div[model-id="missing-cell-id"]' + ) as HTMLDivElement | null; expect(wrapper).toBeTruthy(); expect(wrapper?.style.width).toBe('0px'); expect(wrapper?.style.height).toBe('0px'); 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 8c77af7460..f5bffd4a7a 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 @@ -27,20 +27,18 @@ export interface ElementItemProps { readonly renderElement: ComponentType>; /** The DOM element to portal into. */ readonly portalElement: SVGElement | HTMLElement | null; - /** Whether all auto-sized elements have been measured. */ - readonly areElementsMeasured: boolean; } /** * SVG element portal component. Subscribes only to the element's `data` * slice, position and size are handled by JointJS's view transform and - * never cause a React re-render here. Clears cached views after - * measurement to force re-render with correct dimensions. + * never cause a React re-render here. Clears the cached view once its content + * is committed, so links resolve against the rendered magnets. * @param props - render/portal props * @internal */ function SVGElementItemComponent(props: ElementItemProps) { - const { renderElement: RenderElement, portalElement, areElementsMeasured } = props; + const { renderElement: RenderElement, portalElement } = props; const id = useCellId(); // Subscribe to just this element's `data` slice (missing-tolerant — the portal // can mount before the record lands in the store, and briefly after removal). @@ -54,13 +52,13 @@ function SVGElementItemComponent(props: ElementItemProps) { // 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); + graphStore.measurement.markRendered(id); if (!paper) return; graphStore.clearViewForElementAndLinks({ cellId: id, paper, }); - }, [id, graphStore, areElementsMeasured, paper]); + }, [id, graphStore, paper]); if (!portalElement) { return null; diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx similarity index 92% rename from packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx rename to packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx index ac86c8f011..28de36de40 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx @@ -1,5 +1,8 @@ /** - * Specification for when `useOnElementsMeasured` delivers an event. + * Specification for when the measurement version (`selectMeasuredState`) + * changes, written as the events a `useOnCellsChange` subscriber receives. It + * was written for the former `useOnElementsMeasured` hook; the cases and their + * expectations are unchanged. * * 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 @@ -23,10 +26,11 @@ 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 { useOnCellsChange } from '../use-on-cells-change'; import { useGraphStore } from '../use-graph-store'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; -import { AUTO_SIZE_OPTION } from '../../store/graph-store'; +import { AUTO_SIZE_OPTION } from '../../store/measurement'; +import { selectMeasuredState } from '../../selectors'; import type { CellRecord } from '../../types/cell.types'; import type { dia } from '@joint/core'; @@ -101,8 +105,10 @@ function renderGraph(initialCells: CellRecord[]): Harness { function Probe() { const { graph: currentGraph } = useGraphStore(); graph = currentGraph; - useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { - events.push({ isInitial }); + // An event is a change to a non-zero version; it is the initial one when + // the version before it was `0` (nothing measured) or the hook just mounted. + useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => { + if (version) events.push({ isInitial: !previousMeasuredState }); }); return null; } @@ -137,7 +143,7 @@ async function settleAndClear(harness: Harness) { harness.events.length = 0; } -describe('useOnElementsMeasured — one event per settled change', () => { +describe('selectMeasuredState — one event per settled change', () => { it('delivers one event for the seed pass', async () => { const harness = renderGraph([plain('a')]); @@ -257,7 +263,7 @@ describe('useOnElementsMeasured — one event per settled change', () => { }); }); -describe('useOnElementsMeasured — a graph reset starts a new measurement history', () => { +describe('selectMeasuredState — 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. @@ -314,7 +320,7 @@ describe('useOnElementsMeasured — a graph reset starts a new measurement histo // 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', () => { +describe('selectMeasuredState — 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 () => { @@ -364,7 +370,7 @@ describe('useOnElementsMeasured — sizes written by the application', () => { // 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', () => { +describe('selectMeasuredState — 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')]); diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx similarity index 76% rename from packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx rename to packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx index 326e43c1fd..7a16f69c4b 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx @@ -1,5 +1,5 @@ /** - * Scenarios beyond the #3520 specification in `use-on-elements-measured-events`: + * Scenarios beyond the #3520 specification in `measurement-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 @@ -9,8 +9,9 @@ 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 { useOnCellsChange } from '../use-on-cells-change'; import { useGraphStore } from '../use-graph-store'; +import { selectMeasuredState } from '../../selectors'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; import type { CellRecord } from '../../types/cell.types'; import type { PaperProps } from '../../components/paper/paper.types'; @@ -87,8 +88,10 @@ function renderGraph(initialCells: CellRecord[], paperProps: Partial function Probe() { const { graph: currentGraph } = useGraphStore(); graph = currentGraph; - useOnElementsMeasured(PAPER_ID, ({ isInitial }) => { - events.push({ isInitial }); + // An event is a change to a non-zero version; it is the initial one when + // the version before it was `0` (nothing measured) or the hook just mounted. + useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => { + if (version) events.push({ isInitial: !previousMeasuredState }); }); return null; } @@ -116,7 +119,7 @@ async function settleAndClear(harness: Harness) { // measurer, it becomes outstanding then. const hideAnchor: PaperProps['cellVisibility'] = ({ model }) => model.id !== 'anchor'; -describe('useOnElementsMeasured — an element the paper does not render', () => { +describe('selectMeasuredState — 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 }); @@ -140,7 +143,7 @@ describe('useOnElementsMeasured — an element the paper does not render', () => }); // What else ends the wait: the waiting element leaves the graph. -describe('useOnElementsMeasured — a waiting element is removed', () => { +describe('selectMeasuredState — 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); @@ -160,11 +163,54 @@ describe('useOnElementsMeasured — a waiting element is removed', () => { }); }); +// Regression: an element that stopped measuring before it was measured stayed +// "waiting" forever and held back every later event. +describe('selectMeasuredState — a waiting element stops measuring', () => { + it('delivers the batch, and later changes, once nothing measures the element any more', async () => { + const harness = renderGraph([plain('a')]); + await settleAndClear(harness); + + act(() => { + harness.graph.addCell(pending('b') as never); + }); + await flush(); + expect(harness.events).toHaveLength(0); + + // Its content switches to a plain shape: the measuring node unmounts. + act(() => { + harness.graph.getCell('b').set('data', {}); + }); + await flush(); + expect(harness.events).toHaveLength(1); + + act(() => { + harness.graph.addCell(plain('c') as never); + }); + await flush(); + expect(harness.events).toHaveLength(2); + }); +}); + +// A removal changes what a layout has to arrange, so it is a settled change too. +describe('selectMeasuredState — a settled element is removed', () => { + it('delivers one event for the removal', async () => { + const harness = renderGraph([plain('a'), plain('b')]); + await settleAndClear(harness); + + act(() => { + harness.graph.getCell('b').remove(); + }); + await flush(); + + expect(harness.events).toEqual([{ isInitial: false }]); + }); +}); + // 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', () => { +describe('selectMeasuredState — the content of an element changes', () => { class TestResizeObserver { static readonly instances: TestResizeObserver[] = []; readonly observed = new Set(); @@ -194,15 +240,25 @@ describe('useOnElementsMeasured — the content of an element changes', () => { globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; }); + /** + * The observer holding the node `` registered. The paper observes + * its own host with another `ResizeObserver`, so the instance is found by + * the node: the measured one lives inside the element's `foreignObject`. + */ + function findMeasuredNode() { + for (const observer of TestResizeObserver.instances) { + for (const node of observer.observed) { + if (node.closest('foreignObject')) return { observer, node }; + } + } + throw new Error('no ResizeObserver has the measured node registered'); + } + /** 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; + const { observer, node } = findMeasuredNode(); act(() => { observer.report(node, 120, 40); @@ -256,9 +312,7 @@ describe('useOnElementsMeasured — the content of an element changes', () => { 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; + const { observer, node } = findMeasuredNode(); act(() => { (harness.graph.getCell('b') as dia.Element).resize(120, 40); diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx index e17973f75e..1ed53e8ac0 100644 --- a/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-measure-element.test.tsx @@ -28,6 +28,7 @@ import { LINK_MODEL_TYPE } from '../../mvc/link-model'; import type { CellRecord } from '../../types/cell.types'; let capturedGraph: dia.Graph | null = null; +let capturedStore: ReturnType | null = null; let capturedPaper: dia.Paper | null = null; const initialCells: readonly CellRecord[] = [ @@ -53,8 +54,10 @@ const initialCells: readonly CellRecord[] = [ function Probe() { const nodeRef = useRef(null); - const { graph } = useGraphStore(); + const store = useGraphStore(); + const { graph } = store; const { paper } = usePaper(); + capturedStore = store; capturedGraph = graph; capturedPaper = paper; const size = useMeasureElement(nodeRef); @@ -193,6 +196,29 @@ describe('useMeasureElement', () => { }); }); + // Regression: the paper can route the links before React commits a measured + // size, so the measured element clears its view once that size is committed. + // This used to be a sweep of every element on a global "measured" flag. + it('clears the view of a measured element when its measured size commits', async () => { + renderProbe(); + await waitFor(() => expect(capturedPaper?.findViewByModel('el')).toBeDefined()); + const clearView = jest.spyOn(capturedStore!, 'clearViewForElementAndLinks'); + const element = capturedGraph!.getCell('el') as dia.Element; + + await act(async () => { + element.position(5, 5); + await Promise.resolve(); + }); + expect(clearView).not.toHaveBeenCalled(); + + await act(async () => { + element.set('size', { width: 120, height: 60 }, { autoSize: true }); + await Promise.resolve(); + }); + const clearedIds = clearView.mock.calls.map(([options]) => options.cellId); + expect(clearedIds).toEqual(['el']); + }); + describe('with a real ResizeObserver mock', () => { // Local ResizeObserver mock so we can deliver entries — the package-level // setup in `__mocks__/jest-setup.ts` installs a stub that never fires. diff --git a/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx new file mode 100644 index 0000000000..b878c89566 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-on-cells-change.test.tsx @@ -0,0 +1,578 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { useOnCellsChange } from '../use-on-cells-change'; +import { useCells } from '../use-cells'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { DEFAULT_CELL_NAMESPACE } from '../../store/graph-store'; +import { + selectElementsSizes, + selectIsMeasured, + selectMeasuredState, +} from '../../selectors'; +import type { CellRecord } from '../../types/cell.types'; + +const element = (id: string, x = 0): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + +const initialCells: readonly CellRecord[] = [element('a'), element('b', 50)]; + +function wrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; +} + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +/** Runs a graph mutation and drains the store's microtask commit. */ +const commit = (run: () => void) => + act(async () => { + run(); + await flush(); + }); + +const selectCount = (cells: readonly unknown[]) => cells.length; +const noop = () => {}; +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const selectX = (cell: { readonly position?: { readonly x: number } } | undefined) => + cell?.position?.x; + +/** Mounts the hook under test next to a handle on the graph, counting renders. */ +function mount(useHook: () => void) { + let renderCount = 0; + const { result, unmount } = renderHook( + () => { + renderCount += 1; + useHook(); + return useGraphStore().graph; + }, + { wrapper } + ); + const getElement = (id: string) => result.current.getCell(id) as dia.Element; + return { graph: () => result.current, getElement, renders: () => renderCount, unmount }; +} + +/** The calls made since mount, without StrictMode's repeated mount call. */ +const changesOf = (onChange: jest.Mock) => + onChange.mock.calls.filter(([, previous]) => previous !== undefined); + +describe('useOnCellsChange', () => { + it('calls back on mount with the current value and no previous one', () => { + const onChange = jest.fn(); + mount(() => useOnCellsChange(selectCount, onChange)); + + expect(onChange).toHaveBeenCalledWith(2); + expect(changesOf(onChange)).toHaveLength(0); + }); + + it('calls back with the new and the previous value when the selection changes', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectCount, onChange)); + + await commit(() => graph().addCell(element('c'))); + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([ + [3, 2], + [2, 3], + ]); + }); + + it('does not call back, or re-render, for a commit that leaves the selection unchanged', async () => { + const onChange = jest.fn(); + const { getElement, renders } = mount(() => useOnCellsChange(selectCount, onChange)); + const rendersBefore = renders(); + + await commit(() => getElement('a').position(30, 30)); + + expect(changesOf(onChange)).toHaveLength(0); + expect(renders()).toBe(rendersBefore); + }); + + it('never re-renders the component, unlike useCells', async () => { + const onChange = jest.fn(); + const watching = mount(() => useOnCellsChange(selectCount, onChange)); + const reading = mount(() => useCells(selectCount)); + const watchingBefore = watching.renders(); + const readingBefore = reading.renders(); + + await commit(() => watching.graph().addCell(element('c'))); + await commit(() => reading.graph().addCell(element('c'))); + + expect(changesOf(onChange)).toEqual([[3, 2]]); + expect(watching.renders()).toBe(watchingBefore); + expect(reading.renders()).toBeGreaterThan(readingBefore); + }); + + it('watches one cell by id, ignoring the others', async () => { + const onChange = jest.fn(); + const { getElement } = mount(() => useOnCellsChange('a', selectX, onChange)); + expect(onChange).toHaveBeenCalledWith(0); + + await commit(() => getElement('b').position(70, 0)); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => getElement('a').position(30, 0)); + expect(changesOf(onChange)).toEqual([[30, 0]]); + }); + + it('passes undefined to the selector for a missing cell', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange('late', selectX, onChange)); + expect(onChange.mock.calls[0]).toEqual([undefined]); + + await commit(() => graph().addCell(element('late', 5))); + // The cell appeared: from `undefined` to its x. + expect(onChange.mock.calls.at(-1)).toEqual([5, undefined]); + }); + + it('watches several cells by ids', async () => { + const onChange = jest.fn(); + const ids = ['a', 'missing'] as const; + const { graph } = mount(() => useOnCellsChange(ids, selectCount, onChange)); + expect(onChange).toHaveBeenCalledWith(1); + + await commit(() => graph().addCell(element('c'))); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => graph().addCell(element('missing'))); + expect(changesOf(onChange)).toEqual([[2, 1]]); + }); + + it('uses the custom equality to decide what a change is', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectCount, onChange, isSameParity)); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(changesOf(onChange)).toHaveLength(0); + + await commit(() => graph().addCell(element('e'))); + expect(changesOf(onChange)).toEqual([[5, 2]]); + }); + + // Regression: the equality function was a dependency of the subscription, so + // an inline one re-subscribed on every render and repeated the mount call. + it('does not call back again when re-rendered with an inline isEqual', () => { + const onChange = jest.fn(); + const { rerender } = renderHook( + () => useOnCellsChange(selectCount, onChange, (a, b) => a === b), + { wrapper } + ); + const callsAfterMount = onChange.mock.calls.length; + + rerender(); + rerender(); + + expect(onChange).toHaveBeenCalledTimes(callsAfterMount); + }); + + it('calls the latest callback without subscribing again', async () => { + const first = jest.fn(); + const second = jest.fn(); + let onChange = first; + const { result, rerender } = renderHook( + () => { + useOnCellsChange(selectCount, onChange); + return useGraphStore().graph; + }, + { wrapper } + ); + const mountCalls = first.mock.calls.length; + + onChange = second; + rerender(); + // A new callback is not a new subscription: no mount call for it. + expect(first).toHaveBeenCalledTimes(mountCalls); + expect(second).not.toHaveBeenCalled(); + + await commit(() => result.current.addCell(element('c'))); + expect(first).toHaveBeenCalledTimes(mountCalls); + expect(second.mock.calls).toEqual([[3, 2]]); + }); + + // Regression: the callback runs inside the store's notification. An error + // thrown there used to stop the store for good, and to skip the other + // subscribers of that change. + it('survives a callback that throws, and still surfaces the error', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + let shouldThrow = false; + const throwOnDemand = () => { + if (shouldThrow) throw new Error('boom from a callback'); + }; + const { result } = renderHook( + () => { + useOnCellsChange(selectCount, throwOnDemand); + return { count: useCells(selectCount), graph: useGraphStore().graph }; + }, + { wrapper } + ); + + shouldThrow = true; + await commit(() => result.current.graph.addCell(element('c'))); + // The other subscriber of the same change was still notified. + expect(result.current.count).toBe(3); + expect(() => jest.runOnlyPendingTimers()).toThrow('boom from a callback'); + + shouldThrow = false; + await commit(() => result.current.graph.addCell(element('d'))); + expect(result.current.count).toBe(4); + jest.useRealTimers(); + }); + + // Regression: the projection cleared its pending changes only after notifying + // its subscribers, so a change a callback made to the graph during that + // notification was dropped and the store no longer matched the graph. + it('keeps the store in sync when the callback changes the graph', async () => { + const { result } = renderHook( + () => { + const { graph } = useGraphStore(); + useOnCellsChange(selectCount, (count) => { + if (count !== 3) return; + graph.addCell(element('added-by-callback')); + (graph.getCell('a') as dia.Element).position(77, 0); + }); + return { graph, count: useCells(selectCount), x: useCells('a', selectX) }; + }, + { wrapper } + ); + + await commit(() => result.current.graph.addCell(element('c'))); + + expect(result.current.graph.getCells()).toHaveLength(4); + expect(result.current.count).toBe(4); + expect(result.current.x).toBe(77); + }); + + // Regression: a callback that changes what it listens to re-triggered itself + // inside one endless microtask and froze the page. + it('does not freeze when the callback keeps changing the cells', async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const LAST_CALL = 400; + let calls = 0; + const { result } = renderHook( + () => { + const { graph } = useGraphStore(); + useOnCellsChange(selectCount, (count) => { + calls += 1; + if (calls < LAST_CALL) graph.addCell(element(`extra-${count}`)); + }); + return { graph, count: useCells(selectCount) }; + }, + { wrapper } + ); + await act(async () => flush()); + + // The flush handed control back long before the callback stopped by itself. + expect(calls).toBeLessThan(LAST_CALL); + expect(error).toHaveBeenCalledWith(expect.stringContaining('keeps scheduling')); + + // The rest runs over later tasks, and the store ends in sync with the graph. + for (let task = 0; task < 50 && calls < LAST_CALL; task += 1) { + await act(async () => { + jest.runOnlyPendingTimers(); + await flush(); + }); + } + expect(calls).toBe(LAST_CALL); + expect(result.current.count).toBe(result.current.graph.getCells().length); + + error.mockRestore(); + jest.useRealTimers(); + }); + + it('stops calling back after unmount', async () => { + const onChange = jest.fn(); + const { graph, unmount } = mount(() => useOnCellsChange(selectCount, onChange)); + const liveGraph = graph(); + unmount(); + onChange.mockClear(); + + await commit(() => liveGraph.addCell(element('c'))); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('selectMeasuredState', () => { + it('is 0 until the sizes settle, then changes once per settled change', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectMeasuredState, onChange)); + await act(async () => flush()); + // No paper renders these elements, so the seed settles on its own. + expect(changesOf(onChange)).toEqual([[1, 0]]); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(changesOf(onChange)).toEqual([ + [1, 0], + [2, 1], + ]); + }); + + it('re-renders a useCells reader once per settled change, not per commit', async () => { + let renderCount = 0; + const { result } = renderHook( + () => { + renderCount += 1; + return { version: useCells(selectMeasuredState), graph: useGraphStore().graph }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.version).toBe(1); + const rendersBefore = renderCount; + + // A move is a commit, but not a measurement. + await commit(() => (result.current.graph.getCell('a') as dia.Element).position(9, 9)); + expect(result.current.version).toBe(1); + expect(renderCount).toBe(rendersBefore); + + await commit(() => result.current.graph.addCell(element('c'))); + expect(result.current.version).toBe(2); + }); + + it('selectIsMeasured turns true once sizes settle and re-renders its reader only then', async () => { + let renderCount = 0; + const { result } = renderHook( + () => { + renderCount += 1; + return { isMeasured: useCells(selectIsMeasured), graph: useGraphStore().graph }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isMeasured).toBe(true); + const rendersBefore = renderCount; + + // A later settled change bumps the version, but the diagram stays measured. + await commit(() => result.current.graph.addCell(element('c'))); + expect(result.current.isMeasured).toBe(true); + expect(renderCount).toBe(rendersBefore); + + // A reset to a diagram without sizes is not measured. + await commit(() => result.current.graph.resetCells([])); + expect(result.current.isMeasured).toBe(false); + }); + + // Regression: read through the cells, the selectors made every commit rebuild + // the cells array (O(n) per drag frame). Passed directly to a hook they read + // the measurement itself. + it.each([ + [ + 'useOnCellsChange(selectMeasuredState)', + () => useOnCellsChange(selectMeasuredState, () => {}), + ], + [ + 'useCells(selectMeasuredState)', + () => { + useCells(selectMeasuredState); + }, + ], + [ + 'useCells(selectElementsSizes)', + () => { + useCells(selectElementsSizes); + }, + ], + [ + 'useCells(selectIsMeasured)', + () => { + useCells(selectIsMeasured); + }, + ], + ])('%s does no work on a commit that is not a measurement', async (_name, useReader) => { + const { getElement, renders } = mount(useReader); + await act(async () => flush()); + const rendersBefore = renders(); + const buildArray = jest.spyOn(Array, 'from'); + + for (let frame = 1; frame <= 10; frame += 1) { + await commit(() => getElement('a').position(frame, frame)); + } + + // The container builds its cells array with `Array.from`, and only on demand. + expect(buildArray).not.toHaveBeenCalled(); + expect(renders()).toBe(rendersBefore); + buildArray.mockRestore(); + }); + + it('changes when a measured element is removed, so what is left can be laid out again', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnCellsChange(selectMeasuredState, onChange)); + await act(async () => flush()); + onChange.mockClear(); + + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([[2, 1]]); + }); + + // Regression: removing the last sized element left the diagram "measured", + // while `resetCells([])` did not. + it('is not measured any more once every element has been removed', async () => { + const onChange = jest.fn(); + const { result } = renderHook( + () => { + useOnCellsChange(selectMeasuredState, onChange); + return { isMeasured: useCells(selectIsMeasured), graph: useGraphStore().graph }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isMeasured).toBe(true); + + await commit(() => { + result.current.graph.getCell('a').remove(); + result.current.graph.getCell('b').remove(); + }); + + expect(result.current.isMeasured).toBe(false); + expect(onChange.mock.calls.at(-1)).toEqual([0, 1]); + }); + + it('is measured for an external graph that already holds elements', async () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + graph.addCells([element('a'), element('b')] as never); + const { result } = renderHook(() => useCells(selectIsMeasured), { + wrapper: ({ children }: { readonly children: React.ReactNode }) => ( + {children} + ), + }); + await act(async () => flush()); + expect(result.current).toBe(true); + + await commit(() => graph.getCell('a').remove()); + expect(result.current).toBe(true); + }); + + // The selectors have no cells to read: scoping them to an id, ids or a + // collection used to return the neutral value silently, on every commit. + it('rejects the selectors in the id, ids and collection forms', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const ids = ['a']; + + expect(() => renderHook(() => useCells(ids, selectMeasuredState), { wrapper })).toThrow( + 'all-cells form' + ); + expect(() => + renderHook(() => useOnCellsChange('a', selectIsMeasured, noop), { wrapper }) + ).toThrow('all-cells form'); + + error.mockRestore(); + }); + + // The selectors read the store through the hook. On their own they have no + // store to read: TypeScript rejects a call with cells, and at runtime they + // say so and return their neutral value. + it('returns the neutral value and warns when called outside a hook', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(selectMeasuredState()).toBe(0); + expect(selectIsMeasured()).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('pass it directly')); + + warn.mockRestore(); + }); +}); + +/** Reads the sizes and the graph, counting renders. */ +function mountSizes() { + let renderCount = 0; + const { result } = renderHook( + () => { + renderCount += 1; + return { sizes: useCells(selectElementsSizes), graph: useGraphStore().graph }; + }, + { wrapper } + ); + const getElement = (id: string) => result.current.graph.getCell(id) as dia.Element; + return { result, getElement, renders: () => renderCount }; +} + +describe('selectElementsSizes', () => { + it('returns the size of every element by id', () => { + const { result } = mountSizes(); + + expect([...result.current.sizes]).toEqual([ + ['a', { width: 10, height: 10 }], + ['b', { width: 10, height: 10 }], + ]); + }); + + it('keeps the same map, and does not re-render, while elements only move', async () => { + const { result, getElement, renders } = mountSizes(); + const before = result.current.sizes; + const rendersBefore = renders(); + + await commit(() => getElement('a').position(40, 40)); + + expect(result.current.sizes).toBe(before); + expect(renders()).toBe(rendersBefore); + }); + + it('returns a new map when a size changes, whoever wrote it', async () => { + const { result, getElement } = mountSizes(); + const initial = result.current.sizes; + + await commit(() => getElement('a').resize(30, 20)); + const afterResize = result.current.sizes; + expect(afterResize).not.toBe(initial); + expect(afterResize.get('a')).toEqual({ width: 30, height: 20 }); + // The untouched snapshot is left as it was. + expect(initial.get('a')).toEqual({ width: 10, height: 10 }); + + await commit(() => getElement('b').set('size', { width: 5, height: 5 }, { autoSize: true })); + expect(result.current.sizes).not.toBe(afterResize); + expect(result.current.sizes.get('b')).toEqual({ width: 5, height: 5 }); + }); + + it('follows elements being added, removed and reset', async () => { + const { result } = mountSizes(); + + await commit(() => result.current.graph.addCell(element('c'))); + expect([...result.current.sizes.keys()]).toEqual(['a', 'b', 'c']); + + await commit(() => result.current.graph.getCell('a').remove()); + expect([...result.current.sizes.keys()]).toEqual(['b', 'c']); + + await commit(() => result.current.graph.resetCells([element('x')] as never)); + expect([...result.current.sizes.keys()]).toEqual(['x']); + }); + + it('re-renders once for several sizes changed together', async () => { + const { result, getElement, renders } = mountSizes(); + const before = result.current.sizes; + let rendersForOneResize = 0; + { + const start = renders(); + await commit(() => getElement('a').resize(11, 11)); + rendersForOneResize = renders() - start; + } + + const start = renders(); + await commit(() => { + getElement('a').resize(30, 30); + getElement('b').resize(40, 40); + }); + + expect(renders() - start).toBe(rendersForOneResize); + expect(result.current.sizes).not.toBe(before); + expect(result.current.sizes.get('b')).toEqual({ width: 40, height: 40 }); + }); + + it('calls a useOnCellsChange callback with the new and the previous sizes', async () => { + const onChange = jest.fn(); + const { getElement } = mount(() => useOnCellsChange(selectElementsSizes, onChange)); + + await commit(() => getElement('a').resize(30, 20)); + + const [[sizes, previous]] = changesOf(onChange); + expect(sizes.get('a')).toEqual({ width: 30, height: 20 }); + expect(previous.get('a')).toEqual({ width: 10, height: 10 }); + }); +}); 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 e1256086a0..ddc19d1031 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,7 +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 { AUTO_SIZE_OPTION } from '../../store/measurement'; import { useGraphStore } from '../use-graph-store'; import type { CellRecord } from '../../types/cell.types'; import type { ElementsMeasuredParams } from '../use-on-elements-measured'; @@ -49,12 +49,25 @@ const zeroSizeWrapper = paperRenderElementWrapper({ }, }); -const incrementMeasureState = (previous: number) => previous + 1; - -type MeasureStateRef = ReturnType['measureState']; - -const bumpMeasureFor = (measureState: MeasureStateRef) => () => - measureState.set(incrementMeasureState); +/** + * Mounts the hook for `paperId` through `hookWrapper` and returns a getter of + * the live graph. The wrappers mount their children inside `renderElement`, so + * there is one hook instance per rendered element. + */ +function renderMeasuredProbe( + callback: jest.Mock, + hookWrapper: typeof wrapper = wrapper, + paperId = 'measured-effect-paper' +) { + let graphRef: dia.Graph | undefined; + function Probe() { + graphRef = useGraphStore().graph; + useOnElementsMeasured(paperId, callback); + return null; + } + renderHook(() => Probe(), { wrapper: hookWrapper }); + return () => graphRef as dia.Graph; +} describe('useOnElementsMeasured', () => { it('fires callback with isInitial=true after seed cells are measured', async () => { @@ -66,65 +79,62 @@ describe('useOnElementsMeasured', () => { expect(initialCalls.length).toBeGreaterThan(0); }); - it('subsequent measurement bumps fire callback with isInitial=false', async () => { + it('later settled changes fire the callback with isInitial=false', async () => { const callback = jest.fn(); - let bumpMeasure: () => void = () => {}; - function Probe() { - const { measureState } = useGraphStore(); - bumpMeasure = bumpMeasureFor(measureState); - useOnElementsMeasured('measured-effect-paper', callback); - return null; - } - renderHook(() => Probe(), { wrapper }); + const getGraph = renderMeasuredProbe(callback); await waitFor(() => expect(callback.mock.calls.some(([event]) => event.isInitial === true)).toBe(true) ); callback.mockClear(); act(() => { - bumpMeasure(); + (getGraph().getCell('a') as dia.Element).set('size', { width: 70, height: 70 }, { + [AUTO_SIZE_OPTION]: true, + } as object); }); - await flush(); - expect(callback).toHaveBeenCalled(); + await waitFor(() => expect(callback).toHaveBeenCalled()); for (const [event] of callback.mock.calls) { expect((event as ElementsMeasuredParams).isInitial).toBe(false); } }); - // Regression: ElementModel defaults to size {0,0}. The ResizeObserver - // pipeline sets the real size via `cell.set('size', ..., { autoSize: true })`. - // Previously, the `change:size` listener in graph-changes.ts skipped - // 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', async () => { + // An element nothing measures is settled once the paper rendered it, whatever + // its size: zero is a legal size. A later measurement is one more change. + it('fires for a zero-sized element once it is rendered, then for its measurement', async () => { const callback = jest.fn(); - let graphRef: dia.Graph | undefined; + const getGraph = renderMeasuredProbe(callback, zeroSizeWrapper, 'zero-size-paper'); - function Probe() { - const store = useGraphStore(); - graphRef = store.graph; - useOnElementsMeasured('zero-size-paper', callback); - return null; - } + await waitFor(() => expect(callback).toHaveBeenCalled()); + expect(callback.mock.calls[0][0].isInitial).toBe(true); + callback.mockClear(); - renderHook(() => Probe(), { wrapper: zeroSizeWrapper }); + act(() => { + const cell = getGraph().getCell('zero-el') as dia.Element; + cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object); + }); - // Wait for render to complete and graph to be available. - await waitFor(() => expect(graphRef).toBeDefined()); - await flush(); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + expect(callback.mock.calls[0][0].isInitial).toBe(false); + }); - // Initial size is {0,0} — callback should NOT have fired yet - // (measureState only bumps when elementsMeasured.size > 0). - expect(callback).not.toHaveBeenCalled(); + it('reports isInitial again for the first event after a graph reset', async () => { + const callback = jest.fn(); + const getGraph = renderMeasuredProbe(callback); + await waitFor(() => expect(callback).toHaveBeenCalled()); + callback.mockClear(); - // Simulate ResizeObserver setting the real measured size. act(() => { - const cell = graphRef!.getCell('zero-el') as dia.Element; - cell.set('size', { width: 100, height: 60 }, { [AUTO_SIZE_OPTION]: true } as object); + getGraph().resetCells([ + { + id: 'x', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 9, height: 9 }, + }, + ]); }); - await waitFor(() => expect(callback).toHaveBeenCalled()); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); expect(callback.mock.calls[0][0].isInitial).toBe(true); }); @@ -132,21 +142,10 @@ describe('useOnElementsMeasured', () => { // 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); + const getGraph = renderMeasuredProbe(callback); + const getElement = () => getGraph().getCell('a') as dia.Element; await waitFor(() => expect(callback).toHaveBeenCalled()); callback.mockClear(); @@ -161,7 +160,8 @@ describe('useOnElementsMeasured', () => { it('fires with isInitial=false for a measurement write', async () => { const callback = jest.fn(); - const getElement = renderMeasuredProbe(callback); + const getGraph = renderMeasuredProbe(callback); + const getElement = () => getGraph().getCell('a') as dia.Element; await waitFor(() => expect(callback).toHaveBeenCalled()); callback.mockClear(); diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index 8693acb52f..0a7a4545d0 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -10,6 +10,7 @@ export * from './use-graph-store'; export * from './use-combined-ref'; export * from './use-stores'; export * from './use-markup'; +export * from './use-on-cells-change'; export * from './use-on-elements-measured'; export * from './use-create-portal-paper'; export * from './use-create-features'; diff --git a/packages/joint-react/src/hooks/use-are-elements-measured.ts b/packages/joint-react/src/hooks/use-are-elements-measured.ts deleted file mode 100644 index 998d68c011..0000000000 --- a/packages/joint-react/src/hooks/use-are-elements-measured.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useGraphStore } from './use-graph-store'; -import { useSyncExternalStore } from 'react'; - -/** - * Returns `true` when at least one element has been measured. - */ -export function useAreElementsMeasured() { - const { measureState } = useGraphStore(); - return useSyncExternalStore( - (onStoreChange) => { - // subscribe for both, elements size from container and also measure state changes. - const unsubscribeMeasureState = measureState.subscribe(onStoreChange); - return () => { - unsubscribeMeasureState(); - }; - }, - () => { - return measureState.get() > 0; - } - ); -} diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts index 5f0631785b..0e94ce918a 100644 --- a/packages/joint-react/src/hooks/use-cells.ts +++ b/packages/joint-react/src/hooks/use-cells.ts @@ -4,7 +4,8 @@ import { type mvc, type dia } from '@joint/core'; import { useGraphStore } from './use-graph-store'; import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; import type { ReadonlyContainer } from '../store/state-container'; -import { areArraysShallowEqual, arrayAwareEqual } from '../utils/selector-utils'; +import { areArraysShallowEqual, arrayAwareEqual } from '../selectors/selector-utils'; +import { getSelectorSource } from '../selectors/source-selector'; import { isCollection } from '../utils/is'; import { subscribeToCollection } from '../utils/collection-subscription'; import { parseUseCellsArgs } from './use-cells.utils'; @@ -27,12 +28,25 @@ type CellsResult = type UnknownEqual = (a: unknown, b: unknown) => boolean; /** Selector over the resolved cells array (the array forms of `useCells`). */ -type CellsSelector = ( +export type CellsSelector = ( cells: ReadonlyArray> ) => Selected; +/** What a `useCells` call can be scoped to: one cell, several cells, or a JointJS collection. */ +type CellsTarget = CellId | null | readonly CellId[] | mvc.Collection; + /** Equality test that short-circuits a re-render when the selected value is unchanged. */ -type SelectedEqual = (a: Selected, b: Selected) => boolean; +export type SelectedEqual = (a: Selected, b: Selected) => boolean; + +/** The arguments of every `useCells` form: an optional target, a selector, an equality function. */ +type CellsArguments = [ + argument1?: CellsTarget | CellsSelector, + argument2?: + | CellsSelector + | ((cell: Computed | undefined) => Selected) + | SelectedEqual, + argument3?: SelectedEqual, +]; // ── Module-scoped helpers ─────────────────────────────────────────────────── @@ -261,19 +275,25 @@ export function useCells< export function useCells< Cell extends AnyCellRecord = CellRecord, Selected = ReadonlyArray>, ->( - argument1?: - | CellId - | null - | readonly CellId[] - | CellsSelector - | mvc.Collection, - argument2?: - | CellsSelector - | ((cell: Computed | undefined) => Selected) - | SelectedEqual, - argument3?: SelectedEqual -): CellsResult, Selected> { +>(...args: CellsArguments): CellsResult, Selected> { + const { subscribe, getSnapshot, select, isEqual } = useCellsSource(...args); + return useSyncExternalStoreWithSelector(subscribe, getSnapshot, getSnapshot, select, isEqual); +} + +/** + * The subscription behind {@link useCells}, without the render: what to + * subscribe to, the change token, and the cached selection for the given + * `useCells` arguments. Shared with {@link useOnCellsChange}, which listens + * to it directly instead of re-rendering. + * @param args - The `useCells` arguments: target and/or selector, then an equality function. + * @returns `subscribe`, `getSnapshot`, `select` and `isEqual`, identity-stable while the arguments are. + * @internal + */ +export function useCellsSource< + Cell extends AnyCellRecord = CellRecord, + Selected = ReadonlyArray>, +>(...args: CellsArguments) { + const [argument1, argument2, argument3] = args; const store = useGraphStore(); // The store holds resolved (Computed) records; the public `Cell` generic is the // input record shape, so internally we work in `Computed`. @@ -284,11 +304,7 @@ export function useCells< const { targetId, ids, arraySelector, cellSelector, isEqual } = parseUseCellsArgs< Computed, Selected - >( - argument1, - argument2, - argument3 - ); + >(argument1, argument2, argument3); const hasSelector = arraySelector !== undefined || cellSelector !== undefined; const arraySelectorRef = useRef(arraySelector); @@ -326,8 +342,21 @@ export function useCells< // ── Subscribe ── + // An all-cells selector with its own source (see `createSourceSelector`) is read + // from that source: no subscription to every commit, no cells array built. + const isAllCellsForm = targetId === undefined && !ids && !collectionArgument; + const selectorSource = isAllCellsForm ? getSelectorSource(arraySelector, store) : undefined; + // Not dev-only: in production the selector would return its fallback forever. + if (!isAllCellsForm && getSelectorSource(arraySelector ?? cellSelector, store)) { + throw new Error( + 'This selector reads the graph store, not the cells: use it in the all-cells form, ' + + 'without an id, ids or collection.' + ); + } + const subscribe = useCallback( (listener: () => void) => { + if (selectorSource) return selectorSource.subscribe(listener); if (collectionArgument) { return subscribeToCollection( collectionArgument, @@ -348,12 +377,13 @@ export function useCells< return container.subscribe(listener); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey] + [container, collectionArgument, targetId, idsKey, selectorSource] ); // ── Snapshot ── const getSnapshot = useCallback(() => { + if (selectorSource) return selectorSource.get(); // Per-id token for ANY single-cell form (with or without a selector): the // token is the cell record itself, so the store only re-reads when THIS cell // changes, never on unrelated commits. Immutable records keep the reference @@ -363,23 +393,31 @@ export function useCells< // All-cells / ids / selector-only forms: the immutable snapshot reference // changes on every commit and doubles as the change token. return container.getSnapshot(); - }, [container, collectionArgument, targetId]); + }, [container, collectionArgument, targetId, selectorSource]); // ── Equality ── + // Read through a ref, like the selectors, so an inline `isEqual` does not make + // a new subscription on every render. + const isEqualRef = useRef(isEqual); + isEqualRef.current = isEqual; + const hasIsEqual = isEqual !== undefined; const isEqualCallback = useMemo(() => { - if (isEqual) return isEqual as unknown as UnknownEqual; + if (hasIsEqual) { + return (a, b) => (isEqualRef.current as unknown as UnknownEqual)(a, b); + } if (targetId === undefined && !hasSelector) { return (a, b) => areArraysShallowEqual(a as readonly unknown[], b as readonly unknown[]); } if (hasSelector) return arrayAwareEqual; return Object.is; - }, [isEqual, targetId, hasSelector]); + }, [hasIsEqual, targetId, hasSelector]); // ── Selector ── const select = useCallback( (): Result => { + if (selectorSource) return selectorSource.get() as Result; const subscribedIds = collectionArgument ? collectionIdsRef.current : idsRef.current; const next = computeNext, Selected>( container, @@ -396,7 +434,7 @@ export function useCells< return cachedRef.current.value; } if (hasSelector && cachedRef.current.hasValue) { - warnUnstableSelector('useCells', cachedRef.current.value, next, !!isEqual); + warnUnstableSelector('useCells', cachedRef.current.value, next, hasIsEqual); } // No defensive copy for the all-cells form: the container's snapshot is // already immutable and yields a fresh reference on every commit, so it is @@ -405,14 +443,8 @@ export function useCells< return next; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey, isEqualCallback] + [container, collectionArgument, targetId, idsKey, isEqualCallback, selectorSource] ); - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - select, - isEqualCallback - ); + return { subscribe, getSnapshot, select, isEqual: isEqualCallback }; } diff --git a/packages/joint-react/src/hooks/use-create-portal-paper.tsx b/packages/joint-react/src/hooks/use-create-portal-paper.tsx index d14efb428f..0c6d45f06f 100644 --- a/packages/joint-react/src/hooks/use-create-portal-paper.tsx +++ b/packages/joint-react/src/hooks/use-create-portal-paper.tsx @@ -44,7 +44,6 @@ import { SVGElementItem, } from '../components/paper/render-element/paper-element-item'; import { createSelectPaperVersion } from '../selectors'; -import { useAreElementsMeasured } from './use-are-elements-measured'; import { LINK_MODEL_TYPE } from '../mvc/link-model'; import { subscribeToPaperEvents } from './use-on-paper-events'; import { useOnEvents } from './use-on-events'; @@ -209,7 +208,6 @@ export function useCreatePortalPaper( } const graphStore = useGraphStore(); - const areElementsMeasured = useAreElementsMeasured(); // The set of cell ids — stable across data-only commits (a drag does zero work // here), a new reference only on add/remove. Partition by type for portals. @@ -481,20 +479,11 @@ export function useCreatePortalPaper( - + ) : ( - + )} ); @@ -503,7 +492,6 @@ export function useCreatePortalPaper( }, [ version, HTMLRendererContainer, - areElementsMeasured, elementIds, hasRenderElement, paperStore, diff --git a/packages/joint-react/src/hooks/use-measure-element.tsx b/packages/joint-react/src/hooks/use-measure-element.tsx index c8481d1e37..63fc07ab09 100644 --- a/packages/joint-react/src/hooks/use-measure-element.tsx +++ b/packages/joint-react/src/hooks/use-measure-element.tsx @@ -1,7 +1,7 @@ import { useContext, useLayoutEffect, type RefObject } from 'react'; import { CellIdContext } from '../context'; import { useGraphStore } from './use-graph-store'; -import type { TransformElementLayout } from '../store/create-elements-size-observer'; +import type { TransformElementLayout } from '../store/measurement'; import { usePaper } from './use-paper'; import type { ElementSize } from '../types/cell.types'; import { useCell } from './use-cell'; @@ -142,7 +142,8 @@ export function useMeasureElement( options: MeasureElementOptions = EMPTY_OBJECT ): Required { const { transform } = options; - const { graph, setMeasuredNode } = useGraphStore(); + const graphStore = useGraphStore(); + const { graph, measurement } = graphStore; const { paper } = usePaper(); const id = useContext(CellIdContext); if (id === undefined) { @@ -179,15 +180,18 @@ export function useMeasureElement( paper.requestViewUpdate(view, paper.FLAG_MEASURE, view.UPDATE_PRIORITY); } - const clean = setMeasuredNode({ id, node: nodeRef.current, transform }); - return () => { - // No class cleanup here: views aren't recycled today, so the view - // is gone with the cell. - clean(); - }; + // No class cleanup: views aren't recycled today, so the view is gone with the cell. + return measurement.observe({ id, node: nodeRef.current, transform }); // transform is not a dependency because it doesn't change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeRef, graph, id, paper, setMeasuredNode]); + }, [nodeRef, graph, id, paper, measurement]); + + // The paper may route the links before React commits a measured size (the + // application may flush the views from a measurement callback), so let + // them resolve again against the committed content. + useLayoutEffect(() => { + if (paper) graphStore.clearViewForElementAndLinks({ cellId: id, paper }); + }, [graphStore, id, paper, size]); return size; } diff --git a/packages/joint-react/src/hooks/use-on-cells-change.ts b/packages/joint-react/src/hooks/use-on-cells-change.ts new file mode 100644 index 0000000000..64c95be803 --- /dev/null +++ b/packages/joint-react/src/hooks/use-on-cells-change.ts @@ -0,0 +1,140 @@ +import { useLayoutEffect } from 'react'; +import type { dia, mvc } from '@joint/core'; +import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; +import { useCellsSource, type CellsSelector, type SelectedEqual } from './use-cells'; +import { useLatestRef } from './use-latest-ref'; +import { rethrowLater } from '../utils/scheduler'; + +/** + * Callback of {@link useOnCellsChange}: receives the newly selected value and + * the one before it, absent on the first call. + * @group Types + */ +export type OnCellsChange = (value: Selected, previousValue?: Selected) => void; + +/** + * Runs a callback when a value selected from the cells changes, without + * re-rendering the component: {@link useCells} as an effect. The callback + * runs once when the hook mounts and then synchronously with each store change + * that changes the selected value, before React re-renders its subscribers. + * Changing the watched id, ids or collection starts over: the callback runs + * again with the current value and no previous one. + * + * It takes the same selectors and equality functions as {@link useCells}. + * Keep the selector and `isEqual` cheap and return a primitive or an existing + * record: they run on every commit, including each frame of a drag. + * @title All cells + * @param selector - Derives the value to watch from all cells. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange, selectMeasuredState, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnCellsChange(selectMeasuredState, (measuredState) => { + * if (measuredState) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + selector: CellsSelector, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +/** + * Runs a callback when a value selected from one cell changes. A missing cell + * (or a nullish id) passes `undefined` to the selector. + * @title One cell + * @param id - Id of the cell to watch. + * @param selector - Derives the value to watch from the cell. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange, selectElementSize } from '@joint/react'; + * + * function LogSize({ id }: { id: string }) { + * useOnCellsChange(id, (cell) => cell && selectElementSize(cell), (size) => console.info(size)); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + id: CellId | null | undefined, + selector: (cell: Computed | undefined) => Selected, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +/** + * Runs a callback when a value selected from several cells, or from the cells + * of a JointJS collection, changes. + * @title Several cells or a collection + * @param target - Ids of the cells to watch, or a `mvc.Collection` of cells. + * @param selector - Derives the value to watch from those cells. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnCellsChange } from '@joint/react'; + * + * const selectCount = (cells: readonly unknown[]) => cells.length; + * + * function LogSelection({ ids }: { ids: readonly string[] }) { + * useOnCellsChange(ids, selectCount, (count) => console.info(count)); + * return null; + * } + * ``` + */ +export function useOnCellsChange( + target: readonly CellId[] | mvc.Collection, + selector: CellsSelector, + onChange: OnCellsChange, + isEqual?: SelectedEqual +): void; +export function useOnCellsChange( + ...args: + | [CellsSelector, OnCellsChange, SelectedEqual?] + | [ + CellId | null | undefined | readonly CellId[] | mvc.Collection, + CellsSelector | ((cell: AnyCellRecord | undefined) => Selected), + OnCellsChange, + SelectedEqual?, + ] +): void { + // The target forms carry the callback one position later than the all-cells + // form. The overloads guarantee the shapes, which the union hides from TS. + const [onChange, sourceArguments] = ( + typeof args[0] === 'function' + ? [args[1], [args[0], args[2]]] + : [args[2], [args[0], args[1], args[3]]] + ) as [OnCellsChange, Parameters>]; + const { subscribe, select } = useCellsSource(...sourceArguments); + const onChangeRef = useLatestRef(onChange); + + useLayoutEffect(() => { + // `select` returns the cached value while it is equal, so identity is the test. + let previous = select(); + onChangeRef.current(previous); + return subscribe(() => { + const next = select(); + if (next === previous) return; + const before = previous; + previous = next; + // This runs inside the store's notification: an error must not keep the + // other subscribers of this change from being notified. + try { + onChangeRef.current(next, before); + } catch (error) { + rethrowLater(error); + } + }); + }, [subscribe, select, onChangeRef]); +} 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 0715133e97..3f2dfff75a 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -1,9 +1,10 @@ -import { useLayoutEffect, useRef } from 'react'; +import { useLayoutEffect } from 'react'; import type { dia } from '@joint/core'; import { usePaperStore, useResolvePaperId } from './use-paper'; import type { PaperTarget } from '../types'; import { useGraphStore } from './use-graph-store'; import { useLatestRef } from './use-latest-ref'; +import { rethrowLater } from '../utils/scheduler'; /** * Payload passed to the {@link useOnElementsMeasured} callback after a @@ -48,6 +49,9 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * 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. + * @deprecated Use {@link useOnCellsChange} with {@link selectMeasuredState} to + * react to every settled change, or {@link useCells} with + * {@link selectIsMeasured} to act once the sizes are known. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -70,6 +74,9 @@ export function useOnElementsMeasured(callback: OnElementsMeasured): void; /** * Calls a callback when element sizes are measured, targeting a specific paper * instead of the surrounding context. Useful when several papers share one graph. + * @deprecated Use {@link useOnCellsChange} with {@link selectMeasuredState} to + * react to every settled change, or {@link useCells} with + * {@link selectIsMeasured} to act once the sizes are known. * @title On a specific paper * @param paperTarget - Which paper to watch: a registered paper id, a * `dia.Paper` instance, or a React ref to one. @@ -98,34 +105,36 @@ export function useOnElementsMeasured( const isContextForm = typeof paperTargetOrCallback === 'function'; const paperTarget = isContextForm ? undefined : (paperTargetOrCallback as PaperTarget); - const callback = isContextForm ? (paperTargetOrCallback as OnElementsMeasured) : (callbackArgument as OnElementsMeasured); + const callback = isContextForm + ? (paperTargetOrCallback as OnElementsMeasured) + : (callbackArgument as OnElementsMeasured); const paperId = useResolvePaperId(paperTarget); const paperStore = usePaperStore(paperId); const callbackRef = useLatestRef(callback); - 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); + const { measurement, graph } = useGraphStore(); 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. - reportedGenerationRef.current = -1; + const { stateSource } = measurement; + // A new paper (or graph store) starts its own history, and so does a graph + // reset (the state goes back to `0`): the next event is the initial one. + let previousState = 0; function handleChanges() { - // 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 }); + const state = stateSource.get(); + const isInitial = previousState === 0; + previousState = state; + if (state === 0) return; + // This runs inside the store's notification: an error must not keep the + // other subscribers of this change from being notified. + try { + callbackRef.current({ isInitial, paper, graph }); + } catch (error) { + rethrowLater(error); + } // 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 // next rAF — producing a one-frame flash where the element is visible at its @@ -135,12 +144,7 @@ export function useOnElementsMeasured( } // Flush any measurement that happened before subscription (e.g. initial // data sync ran before this paperStore was available). - if (measureState.get() > 0) { - handleChanges(); - } - const unsubscribe = measureState.subscribe(handleChanges); - return () => { - unsubscribe(); - }; - }, [paperStore, graphStore, measureState, graph, callbackRef]); + handleChanges(); + return stateSource.subscribe(handleChanges); + }, [paperStore, measurement, graph, callbackRef]); } diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 9ae4148f34..8f99a47084 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -12,7 +12,7 @@ export { GraphProvider } from './components/graph/graph-provider'; /** @group Types */ export type { GraphProviderProps } from './components/graph/graph-provider'; /** @group Types */ -export type { AutoSizeOrigin } from './store/graph-store'; +export type { AutoSizeOrigin } from './store/measurement'; /** @group Types */ export type { IncrementalCellsChange } from './store/graph-projection'; @@ -132,10 +132,7 @@ export { useMeasureElement } from './hooks/use-measure-element'; /** @group Types */ export type { MeasureElementOptions } from './hooks/use-measure-element'; /** @group Types */ -export type { - TransformElementLayout, - TransformElementLayoutParams, -} from './store/create-elements-size-observer'; +export type { TransformElementLayout, TransformElementLayoutParams } from './store/measurement'; /** * useOnElementsMeasured() @@ -145,6 +142,14 @@ export { useOnElementsMeasured } from './hooks/use-on-elements-measured'; /** @group Types */ export type { ElementsMeasuredParams, OnElementsMeasured } from './hooks/use-on-elements-measured'; +/** + * useOnCellsChange() + * @group Hooks + */ +export { useOnCellsChange } from './hooks/use-on-cells-change'; +/** @group Types */ +export type { OnCellsChange } from './hooks/use-on-cells-change'; + /** * useOnPaperEvents() * @group Hooks @@ -194,7 +199,9 @@ export { selectCellParent, selectCellLayer, selectCellZIndex, -} from './selectors/cell-selectors'; +} from './selectors'; +/** @group Selectors */ +export { selectMeasuredState, selectIsMeasured, selectElementsSizes } from './selectors'; // Data // ---- diff --git a/packages/joint-react/src/internal.ts b/packages/joint-react/src/internal.ts index 99fb9fb8d5..c6cbbe17a3 100644 --- a/packages/joint-react/src/internal.ts +++ b/packages/joint-react/src/internal.ts @@ -27,7 +27,8 @@ export type { FeaturesContext as FeaturesContextType } from './context'; export { FeaturesProvider } from './components'; // Store Classes -export { GraphStore, DEFAULT_CELL_NAMESPACE, AUTO_SIZE_OPTION } from './store/graph-store'; +export { GraphStore, DEFAULT_CELL_NAMESPACE } from './store/graph-store'; +export { AUTO_SIZE_OPTION } from './store/measurement'; export type { GraphStoreInternalSnapshot, PaperStoreState } from './store/graph-store'; export type { GraphStoreOptions } from './store/graph-store'; export { PaperStore } from './store/paper-store'; diff --git a/packages/joint-react/src/utils/__tests__/selector-utils.test.ts b/packages/joint-react/src/selectors/__tests__/selector-utils.test.ts similarity index 100% rename from packages/joint-react/src/utils/__tests__/selector-utils.test.ts rename to packages/joint-react/src/selectors/__tests__/selector-utils.test.ts diff --git a/packages/joint-react/src/selectors/__tests__/source-selector.test.ts b/packages/joint-react/src/selectors/__tests__/source-selector.test.ts new file mode 100644 index 0000000000..b9362b3d75 --- /dev/null +++ b/packages/joint-react/src/selectors/__tests__/source-selector.test.ts @@ -0,0 +1,30 @@ +import { createSourceSelector, getSelectorSource, type SelectorSource } from '../source-selector'; +import type { GraphStore } from '../../store/graph-store'; + +const store = {} as GraphStore; +const unsubscribe = () => {}; +const source: SelectorSource = { subscribe: () => unsubscribe, get: () => 7 }; + +describe('source-selector', () => { + it('resolves the source of a selector made by createSourceSelector', () => { + const resolve = jest.fn(() => source); + const selector = createSourceSelector(resolve, 0); + + expect(getSelectorSource(selector, store)).toBe(source); + expect(resolve).toHaveBeenCalledWith(store); + }); + + it('has no source for a plain selector or a missing one', () => { + expect(getSelectorSource(() => 1, store)).toBeUndefined(); + expect(getSelectorSource(undefined, store)).toBeUndefined(); + }); + + it('returns the fallback and warns when called directly', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const selector = createSourceSelector(() => source, 42); + + expect(selector()).toBe(42); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/selectors/__tests__/index.test.ts b/packages/joint-react/src/selectors/__tests__/store-selectors.test.ts similarity index 93% rename from packages/joint-react/src/selectors/__tests__/index.test.ts rename to packages/joint-react/src/selectors/__tests__/store-selectors.test.ts index 8180ffd7b5..7ed1161cf9 100644 --- a/packages/joint-react/src/selectors/__tests__/index.test.ts +++ b/packages/joint-react/src/selectors/__tests__/store-selectors.test.ts @@ -2,10 +2,10 @@ import { selectResetVersion, createSelectPaperVersion, selectGraphFeaturesVersion, -} from '../index'; +} from '../store-selectors'; import type { GraphStoreInternalSnapshot } from '../../store/graph-store'; -describe('selectors/index', () => { +describe('store-selectors', () => { const snapshot: GraphStoreInternalSnapshot = { papers: { 'paper-1': { version: 7 } as GraphStoreInternalSnapshot['papers'][string], diff --git a/packages/joint-react/src/selectors/index.ts b/packages/joint-react/src/selectors/index.ts index a141f876cb..e2939c8f0e 100644 --- a/packages/joint-react/src/selectors/index.ts +++ b/packages/joint-react/src/selectors/index.ts @@ -1,25 +1,6 @@ -import type { GraphStoreInternalSnapshot } from '../store/graph-store'; - -// ── Public cell selectors ─────────────────────────────────────────────────── - +// Public selectors, passed to `useCell` / `useCells` / `useOnCellsChange`. export * from './cell-selectors'; +export * from './measurement-selectors'; -// ── Internal selectors ────────────────────────────────────────────────────── - -export const selectResetVersion = (state: GraphStoreInternalSnapshot): number => state.resetVersion; - -/** - * Creates a selector for the version of a specific paper. - * @param id - The paper ID to select the version for. - */ -export function createSelectPaperVersion(id: string) { - return (snapshot: GraphStoreInternalSnapshot) => snapshot.papers[id]?.version; -} - -/** - * Selects the graph features version from the internal snapshot. - * Used to trigger re-renders when graph-level features change. - * @param state - */ -export const selectGraphFeaturesVersion = (state: GraphStoreInternalSnapshot): number => - state.graphFeaturesVersion; +// Internal selectors of the graph store snapshot. +export * from './store-selectors'; diff --git a/packages/joint-react/src/selectors/measurement-selectors.ts b/packages/joint-react/src/selectors/measurement-selectors.ts new file mode 100644 index 0000000000..83092ccab7 --- /dev/null +++ b/packages/joint-react/src/selectors/measurement-selectors.ts @@ -0,0 +1,105 @@ +import type { dia } from '@joint/core'; +import type { CellId } from '../types/cell.types'; +import { createSourceSelector } from './source-selector'; + +/** + * Selects the measured state of the diagram, a value to react to: `0` while the + * element sizes are not known, and otherwise a number that changes each time the + * sizes settle. That is the first pass, and each later addition, removal or + * re-measurement, once no element is still waiting to be measured. It is `0` + * again after a graph reset, or when no element is left, until the diagram + * settles anew. A value is never reused, so it works as an effect + * dependency; treat it as opaque beyond "zero or not". + * + * Sizes the application writes itself (`cell.resize()`, controlled `cells` + * sync) are not measurements and do not change it. + * + * Pass it directly to {@link useCells} or {@link useOnCellsChange} in their + * all-cells form. The hook then listens to the measurement only, so it does no + * work on other commits (a drag, for example). + * @returns The measured state, `0` when nothing is measured yet. + * @group Selectors + * @example + * ```tsx + * import { useOnCellsChange, selectMeasuredState, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnCellsChange(selectMeasuredState, (measuredState) => { + * if (measuredState) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export const selectMeasuredState = createSourceSelector( + ({ measurement }) => measurement.stateSource, + 0 +); + +/** + * Selects whether the element sizes of the diagram are known: `false` until the + * sizes first settle, and again after a graph reset, or when no element is + * left, until the diagram settles anew. Use it when something should happen + * once per diagram, such as fitting the paper; use + * {@link selectMeasuredState} to react to every later change too. + * + * Pass it directly to {@link useCells} or {@link useOnCellsChange} in their + * all-cells form. + * @returns `true` once the element sizes are known. + * @group Selectors + * @example + * ```tsx + * import { useLayoutEffect } from 'react'; + * import { useCells, usePaper, selectIsMeasured } from '@joint/react'; + * + * // Mount inside a : fit the paper once the sizes are known. + * function FitOnMeasure() { + * const { paper } = usePaper(); + * const isMeasured = useCells(selectIsMeasured); + * useLayoutEffect(() => { + * if (isMeasured && paper) paper.transformToFitContent({ padding: 20 }); + * }, [isMeasured, paper]); + * return null; + * } + * ``` + */ +export const selectIsMeasured = createSourceSelector( + ({ measurement }) => measurement.isMeasuredSource, + false +); + +const NO_SIZES: ReadonlyMap = new Map(); + +/** + * Selects the size of every element, by id. The map is the same reference + * until a size changes, an element is added or removed, or the graph is reset, + * so a component reading it does not re-render while elements are only moved. + * It changes for every size, including one the application writes itself + * (`cell.resize()`, a resize tool). + * + * To run a layout when sizes are known, prefer {@link selectMeasuredState}: a + * layout that resizes elements would change this map again. + * + * Pass it directly to {@link useCells} or {@link useOnCellsChange} in their + * all-cells form. The hook then listens to size changes only, so it does no + * work on other commits (a drag, for example). + * @returns The element sizes, keyed by element id. + * @group Selectors + * @example + * ```tsx + * import { useCells, selectElementsSizes } from '@joint/react'; + * + * function WidestElement() { + * const sizes = useCells(selectElementsSizes); + * let widest = 0; + * for (const { width } of sizes.values()) widest = Math.max(widest, width); + * return {widest}; + * } + * ``` + */ +export const selectElementsSizes = createSourceSelector( + ({ measurement }) => measurement.sizesSource, + NO_SIZES +); diff --git a/packages/joint-react/src/utils/selector-utils.ts b/packages/joint-react/src/selectors/selector-utils.ts similarity index 95% rename from packages/joint-react/src/utils/selector-utils.ts rename to packages/joint-react/src/selectors/selector-utils.ts index 82c20771c7..f9e3c1c728 100644 --- a/packages/joint-react/src/utils/selector-utils.ts +++ b/packages/joint-react/src/selectors/selector-utils.ts @@ -1,6 +1,5 @@ import type { ElementPosition, ElementSize } from '../types/cell.types'; - export const isStrictEqual = Object.is; export const identitySelector = (item: T) => item; @@ -10,10 +9,7 @@ export const identitySelector = (item: T) => item; * @param a * @param b */ -export function isShallowEqual( - a: object | undefined, - b: object | undefined -): boolean { +export function isShallowEqual(a: object | undefined, b: object | undefined): boolean { if (a === b) return true; if (!a || !b) return false; const objectA = a as Record; diff --git a/packages/joint-react/src/selectors/source-selector.ts b/packages/joint-react/src/selectors/source-selector.ts new file mode 100644 index 0000000000..991dcab63d --- /dev/null +++ b/packages/joint-react/src/selectors/source-selector.ts @@ -0,0 +1,47 @@ +import type { GraphStore } from '../store/graph-store'; +import { warnSourceSelectorCalled } from '../utils/dev-warnings'; + +/** + * Where a selector's value comes from when it is not derived from the cells: + * `get` returns the selected value and `subscribe` fires when it may have changed. + */ +export interface SelectorSource { + readonly subscribe: (listener: () => void) => () => void; + readonly get: () => Selected; +} + +const selectorSources = new WeakMap SelectorSource>(); + +/** + * Creates an all-cells selector whose value comes from a store source rather + * than from the cells. `useCells` and `useOnCellsChange` recognise it and + * subscribe to that source only, so it costs nothing on other commits and the + * cells array is never built for it. It has to be passed to the hook directly: + * called on its own it cannot reach a store and returns `fallback`. + * @param resolve - Returns the source for a graph store; identity-stable per store. + * @param fallback - The value when there is no store to read. + * @returns The selector to pass to `useCells` / `useOnCellsChange`. + */ +export function createSourceSelector( + resolve: (store: GraphStore) => SelectorSource, + fallback: Selected +): () => Selected { + const selector = () => { + warnSourceSelectorCalled(); + return fallback; + }; + selectorSources.set(selector, resolve); + return selector; +} + +/** + * The source of a selector made by {@link createSourceSelector}, if it is one. + * @param selector - The selector passed to the hook. + * @param store - The graph store the hook reads. + */ +export function getSelectorSource( + selector: object | undefined, + store: GraphStore +): SelectorSource | undefined { + return selector && selectorSources.get(selector)?.(store); +} diff --git a/packages/joint-react/src/selectors/store-selectors.ts b/packages/joint-react/src/selectors/store-selectors.ts new file mode 100644 index 0000000000..a852b40ef0 --- /dev/null +++ b/packages/joint-react/src/selectors/store-selectors.ts @@ -0,0 +1,19 @@ +import type { GraphStoreInternalSnapshot } from '../store/graph-store'; + +export const selectResetVersion = (state: GraphStoreInternalSnapshot): number => state.resetVersion; + +/** + * Creates a selector for the version of a specific paper. + * @param id - The paper ID to select the version for. + */ +export function createSelectPaperVersion(id: string) { + return (snapshot: GraphStoreInternalSnapshot) => snapshot.papers[id]?.version; +} + +/** + * Selects the graph features version from the internal snapshot. + * Used to trigger re-renders when graph-level features change. + * @param state + */ +export const selectGraphFeaturesVersion = (state: GraphStoreInternalSnapshot): number => + state.graphFeaturesVersion; diff --git a/packages/joint-react/src/state/data-mapping/cell-record-merge.ts b/packages/joint-react/src/state/data-mapping/cell-record-merge.ts index b8ccc98961..675857926c 100644 --- a/packages/joint-react/src/state/data-mapping/cell-record-merge.ts +++ b/packages/joint-react/src/state/data-mapping/cell-record-merge.ts @@ -1,6 +1,6 @@ import type { dia } from '@joint/core'; import type { ElementJSONInit, LinkJSONInit } from '../../types/cell.types'; -import { isShallowEqual, isPositionEqual, isSizeEqual } from '../../utils/selector-utils'; +import { isShallowEqual, isPositionEqual, isSizeEqual } from '../../selectors/selector-utils'; import { mapAttributesToElement } from './element-mapper'; import { mapAttributesToLink } from './link-mapper'; diff --git a/packages/joint-react/src/store/__tests__/clear-view.test.ts b/packages/joint-react/src/store/__tests__/clear-view.test.ts index af789664e9..0afbb16fa5 100644 --- a/packages/joint-react/src/store/__tests__/clear-view.test.ts +++ b/packages/joint-react/src/store/__tests__/clear-view.test.ts @@ -1,10 +1,7 @@ import { dia } from '@joint/core'; import { clearConnectedLinkViews, - executeClearViewForCell, - mergeClearViewValidators, shouldClearLink, - type ClearViewCacheEntry, } from '../clear-view'; import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; @@ -26,55 +23,6 @@ function createMockLinkView(): MockLinkView { }; } -describe('mergeClearViewValidators', () => { - it('returns incoming when there is no existing entry', () => { - const incoming: ClearViewCacheEntry = { onValidateLink: () => true }; - const result = mergeClearViewValidators(undefined, incoming); - expect(result).toBe(incoming); - }); - - it('takes precedence with no validator (clear all)', () => { - const existing: ClearViewCacheEntry = { onValidateLink: () => true }; - const incoming: ClearViewCacheEntry = {}; - const result = mergeClearViewValidators(existing, incoming); - expect(result.onValidateLink).toBeUndefined(); - }); - - it('creates a union when both have validators', () => { - const existingValidator = jest.fn().mockReturnValue(false); - const newValidator = jest.fn().mockReturnValue(true); - const result = mergeClearViewValidators( - { onValidateLink: existingValidator }, - { onValidateLink: newValidator } - ); - expect(typeof result.onValidateLink).toBe('function'); - const link = {} as dia.Link; - expect(result.onValidateLink!(link)).toBe(true); - expect(existingValidator).toHaveBeenCalledWith(link); - expect(newValidator).toHaveBeenCalledWith(link); - }); - - it('union short-circuits when existing returns true', () => { - const existingValidator = jest.fn().mockReturnValue(true); - const newValidator = jest.fn().mockReturnValue(false); - const result = mergeClearViewValidators( - { onValidateLink: existingValidator }, - { onValidateLink: newValidator } - ); - const link = {} as dia.Link; - expect(result.onValidateLink!(link)).toBe(true); - expect(newValidator).not.toHaveBeenCalled(); - }); - - it('keeps the existing "clear all" semantics when existing has no validator', () => { - const existing: ClearViewCacheEntry = {}; - const incoming: ClearViewCacheEntry = { onValidateLink: () => true }; - const result = mergeClearViewValidators(existing, incoming); - expect(result).toBe(existing); - expect(result.onValidateLink).toBeUndefined(); - }); -}); - function makeLink(sourceId: string, targetId: string): dia.Link { return new dia.Link({ type: 'standard.Link', @@ -226,74 +174,3 @@ describe('clearConnectedLinkViews', () => { expect(mockLinkView.requestConnectionUpdate).toHaveBeenCalledWith({ async: true }); }); }); - -describe('executeClearViewForCell', () => { - let graph: dia.Graph; - - beforeEach(() => { - graph = createGraph(); - }); - - it('iterates papers and skips ones without a paper instance', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - const papers = [{ paper: undefined }]; - expect(() => executeClearViewForCell(papers, graph, 'a')).not.toThrow(); - }); - - it('skips papers where the cell view is not present', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - - const getCellView = jest.fn(); - const paper = { getCellView } as unknown as dia.Paper; - - executeClearViewForCell([{ paper }], graph, 'a'); - expect(getCellView).toHaveBeenCalledWith('a'); - }); - - it('cleans the cell view nodes cache and clears connected link views', () => { - graph.addCell({ - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 10, height: 10 }, - }); - graph.addCell({ - id: 'b', - type: 'element', - position: { x: 50, y: 0 }, - size: { width: 10, height: 10 }, - }); - const link = new dia.Link({ - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }); - graph.addCell(link); - - const cleanNodesCache = jest.fn(); - const elementView = { cleanNodesCache } as unknown as dia.ElementView; - - const mockLinkView = createMockLinkView(); - link.findView = jest.fn().mockReturnValue(mockLinkView) as unknown as typeof link.findView; - - const paper = { - getCellView: jest.fn().mockReturnValue(elementView), - } as unknown as dia.Paper; - - executeClearViewForCell([{ paper }], graph, 'a'); - expect(cleanNodesCache).toHaveBeenCalledTimes(1); - expect(mockLinkView.requestConnectionUpdate).toHaveBeenCalled(); - }); -}); - diff --git a/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts b/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts deleted file mode 100644 index fe8622e5b2..0000000000 --- a/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/* eslint-disable prefer-destructuring */ - -/* eslint-disable @typescript-eslint/no-require-imports */ -import type { dia } from '@joint/core'; -import type { CellId } from '../../types/cell.types'; -import type { GraphStoreObserver } from '../create-elements-size-observer'; - -// Mock ResizeObserver for testing - -let mockResizeObserverInstances: MockResizeObserver[] = []; - -class MockResizeObserver { - private callback: ResizeObserverCallback; - private observedElements = new Map(); - - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - mockResizeObserverInstances.push(this); - } - - observe(target: Element) { - // Simulate an entry with initial size - const entry = this.createEntry(target, 100, 50); - this.observedElements.set(target, entry); - } - - unobserve(target: Element) { - this.observedElements.delete(target); - } - - disconnect() { - this.observedElements.clear(); - } - - // Test helper to simulate resize - triggerResize(target: Element, width: number, height: number) { - const entry = this.createEntry(target, width, height); - this.observedElements.set(target, entry); - this.callback([entry], this as unknown as ResizeObserver); - } - - // Test helper to trigger callback for all observed elements - triggerAllCallbacks() { - const entries = [...this.observedElements.values()]; - if (entries.length > 0) { - this.callback(entries, this as unknown as ResizeObserver); - } - } - - isObserving(target: Element): boolean { - return this.observedElements.has(target); - } - - get observedCount(): number { - return this.observedElements.size; - } - - private createEntry(target: Element, width: number, height: number): ResizeObserverEntry { - return { - target, - contentRect: { - width, - height, - top: 0, - left: 0, - bottom: height, - right: width, - x: 0, - y: 0, - toJSON: () => ({}), - }, - borderBoxSize: [{ inlineSize: width, blockSize: height }], - contentBoxSize: [{ inlineSize: width, blockSize: height }], - devicePixelContentBoxSize: [{ inlineSize: width, blockSize: height }], - } as ResizeObserverEntry; - } - - static getLastInstance(): MockResizeObserver | undefined { - return mockResizeObserverInstances.at(-1); - } - - static clearInstances() { - mockResizeObserverInstances = []; - } -} - -// Replace global ResizeObserver with mock -globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; - -describe('createElementsSizeObserver', () => { - let observer: GraphStoreObserver; - let mockOnBatchUpdate: jest.Mock; - let mockGetCellTransform: jest.Mock; - let mockGetElements: jest.Mock; - let mockElements: Map; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let createElementsSizeObserver: any; - - beforeEach(() => { - MockResizeObserver.clearInstances(); - - // Reset modules and reimport to ensure the mock is used - jest.resetModules(); - // Re-assign the mock after reset to ensure it's used - globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; - - // jest.resetModules() requires synchronous re-import; using `require` is the only - // option here. ESM `import()` is async and does not bypass module cache the same way. - // eslint-disable-next-line @typescript-eslint/no-var-requires - createElementsSizeObserver = require('../create-elements-size-observer').createElementsSizeObserver; - - mockElements = new Map([ - ['element-1', { size: { width: 1, height: 1 }, position: { x: 0, y: 0 } }], - ['element-2', { size: { width: 1, height: 1 }, position: { x: 0, y: 0 } }], - ]); - - mockOnBatchUpdate = jest.fn(); - mockGetCellTransform = jest.fn((id: CellId) => ({ - width: 1, - height: 1, - x: 0, - y: 0, - angle: 0, - element: { id } as dia.Element, - })); - mockGetElements = jest.fn(() => mockElements); - - observer = createElementsSizeObserver({ - onBatchUpdate: mockOnBatchUpdate, - getCellTransform: mockGetCellTransform, - getElements: mockGetElements, - }); - }); - - afterEach(() => { - observer.clean(); - }); - - describe('add', () => { - it('should register element with ResizeObserver', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should return cleanup function that unregisters element', () => { - const element = document.createElement('div'); - - const cleanup = observer.add({ id: 'element-1', node: element }); - expect(observer.has('element-1')).toBe(true); - - cleanup(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should handle multiple elements', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - }); - - it('should process ResizeObserver callback when element is added', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - // Trigger resize via ResizeObserver (simulates browser behavior) - const resizeObserver = MockResizeObserver.getLastInstance(); - expect(resizeObserver).toBeDefined(); - resizeObserver?.triggerResize(element, 100, 50); - - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1']).toBeDefined(); - expect(updateCall['element-1'].width).toBe(100); - expect(updateCall['element-1'].height).toBe(50); - }); - - it('should handle multiple elements with ResizeObserver', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - // Trigger resize for both elements - const resizeObserver = MockResizeObserver.getLastInstance(); - resizeObserver?.triggerResize(element1, 100, 50); - resizeObserver?.triggerResize(element2, 200, 100); - - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); - }); - }); - - describe('ResizeObserver callback', () => { - it('should process size changes from ResizeObserver', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - expect(resizeObserver).toBeDefined(); - - // Trigger initial resize - resizeObserver?.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - // Trigger resize to a different size - resizeObserver?.triggerResize(element, 200, 100); - - // Should be called again for the resize - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); - }); - - it('should not update if size has not changed significantly', () => { - const element = document.createElement('div'); - - observer.add({ id: 'element-1', node: element }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - - // Trigger initial resize - resizeObserver?.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - mockOnBatchUpdate.mockClear(); - - // Trigger resize with same size (within epsilon of 0.5) - resizeObserver?.triggerResize(element, 100.1, 50.1); - - // Should not trigger update because change is within epsilon - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('should use transform function when provided', () => { - const element = document.createElement('div'); - const transform = jest.fn(({ width, height }) => ({ - width: width + 20, - height: height + 20, - })); - - observer.add({ id: 'element-1', node: element, transform }); - - const resizeObserver = MockResizeObserver.getLastInstance(); - resizeObserver?.triggerResize(element, 100, 50); - - expect(transform).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(120); // 100 + 20 - expect(updateCall['element-1'].height).toBe(70); // 50 + 20 - }); - }); - - describe('stack behavior', () => { - it('should allow multiple registrations for the same cell ID', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should only observe the active (latest) node via ResizeObserver', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // nodeA should be unobserved (deactivated when nodeB was added) - expect(resizeObserver.isObserving(nodeA)).toBe(false); - // nodeB should be observed (active) - expect(resizeObserver.isObserving(nodeB)).toBe(true); - }); - - it('should process resize only from the active node', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Trigger resize on the active node (nodeB) - resizeObserver.triggerResize(nodeB, 200, 100); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(200); - expect(updateCall['element-1'].height).toBe(100); - }); - - it('should fall back to previous node when active node is removed', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove the active node (nodeB) - cleanupB(); - - // nodeA should be re-activated - expect(resizeObserver.isObserving(nodeA)).toBe(true); - expect(resizeObserver.isObserving(nodeB)).toBe(false); - expect(observer.has('element-1')).toBe(true); - - // Trigger resize on the re-activated nodeA - resizeObserver.triggerResize(nodeA, 150, 75); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(150); - expect(updateCall['element-1'].height).toBe(75); - }); - - it('should remove non-active node without affecting active observation', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const cleanupA = observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove the non-active node (nodeA) - cleanupA(); - - // nodeB should still be active - expect(resizeObserver.isObserving(nodeB)).toBe(true); - expect(observer.has('element-1')).toBe(true); - - // Trigger resize on nodeB — should still work - resizeObserver.triggerResize(nodeB, 300, 150); - expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); - }); - - it('should handle removing all entries one by one (3-deep stack)', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - const nodeC = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - const cleanupC = observer.add({ id: 'element-1', node: nodeC }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Remove C (active) → B becomes active - cleanupC(); - expect(resizeObserver.isObserving(nodeC)).toBe(false); - expect(resizeObserver.isObserving(nodeB)).toBe(true); - expect(observer.has('element-1')).toBe(true); - }); - - it('should fully remove cell ID after all stack entries are cleaned up', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const cleanupA = observer.add({ id: 'element-1', node: nodeA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB }); - - cleanupB(); - expect(observer.has('element-1')).toBe(true); - - cleanupA(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should use the active node transform function, not a previous one', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const transformA = jest.fn(({ width, height }) => ({ - width: width + 10, - height: height + 10, - })); - const transformB = jest.fn(({ width, height }) => ({ - width: width + 50, - height: height + 50, - })); - - observer.add({ id: 'element-1', node: nodeA, transform: transformA }); - observer.add({ id: 'element-1', node: nodeB, transform: transformB }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(nodeB, 100, 50); - - expect(transformA).not.toHaveBeenCalled(); - expect(transformB).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(150); // 100 + 50 - expect(updateCall['element-1'].height).toBe(100); // 50 + 50 - }); - - it('should use the previous transform after active node is removed', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - - const transformA = jest.fn(({ width, height }) => ({ - width: width + 10, - height: height + 10, - })); - const transformB = jest.fn(({ width, height }) => ({ - width: width + 50, - height: height + 50, - })); - - observer.add({ id: 'element-1', node: nodeA, transform: transformA }); - const cleanupB = observer.add({ id: 'element-1', node: nodeB, transform: transformB }); - - // Remove B, A becomes active with its own transform - cleanupB(); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(nodeA, 100, 50); - - expect(transformA).toHaveBeenCalled(); - - const updateCall = mockOnBatchUpdate.mock.calls[0][0]; - expect(updateCall['element-1'].width).toBe(110); // 100 + 10 - expect(updateCall['element-1'].height).toBe(60); // 50 + 10 - }); - - it('cleanup should be idempotent', () => { - const nodeA = document.createElement('div'); - - const cleanup = observer.add({ id: 'element-1', node: nodeA }); - - cleanup(); - expect(observer.has('element-1')).toBe(false); - - // Second cleanup call should not throw or cause side effects - cleanup(); - expect(observer.has('element-1')).toBe(false); - }); - - it('should handle interleaved add/remove across different cell IDs', () => { - const node1A = document.createElement('div'); - const node1B = document.createElement('div'); - const node2A = document.createElement('div'); - - const cleanup1A = observer.add({ id: 'element-1', node: node1A }); - observer.add({ id: 'element-2', node: node2A }); - observer.add({ id: 'element-1', node: node1B }); - - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // element-1 active is node1B, element-2 active is node2A - expect(resizeObserver.isObserving(node1B)).toBe(true); - expect(resizeObserver.isObserving(node2A)).toBe(true); - expect(resizeObserver.isObserving(node1A)).toBe(false); - - // Remove non-active node from element-1 - cleanup1A(); - expect(resizeObserver.isObserving(node1B)).toBe(true); - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - }); - }); - - describe('clean', () => { - it('should remove all observed elements', () => { - const element1 = document.createElement('div'); - const element2 = document.createElement('div'); - - observer.add({ id: 'element-1', node: element1 }); - observer.add({ id: 'element-2', node: element2 }); - - expect(observer.has('element-1')).toBe(true); - expect(observer.has('element-2')).toBe(true); - - observer.clean(); - - expect(observer.has('element-1')).toBe(false); - expect(observer.has('element-2')).toBe(false); - }); - - it('should clean all stacks including multi-entry stacks', () => { - const nodeA = document.createElement('div'); - const nodeB = document.createElement('div'); - const nodeC = document.createElement('div'); - - observer.add({ id: 'element-1', node: nodeA }); - observer.add({ id: 'element-1', node: nodeB }); - observer.add({ id: 'element-2', node: nodeC }); - - observer.clean(); - - expect(observer.has('element-1')).toBe(false); - expect(observer.has('element-2')).toBe(false); - }); - }); - - describe('has', () => { - it('should return true for registered elements', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - - expect(observer.has('element-1')).toBe(true); - }); - - it('should return false for unregistered elements', () => { - expect(observer.has('non-existent')).toBe(false); - }); - }); - - describe('processSizeChange branches', () => { - it('skips when elements map does not contain the cell id', () => { - const element = document.createElement('div'); - mockElements.clear(); // ← getElements() returns an empty map - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips when cell transform already matches the measured size (within epsilon)', () => { - const element = document.createElement('div'); - mockGetCellTransform.mockImplementation((id: CellId) => ({ - width: 100, - height: 50, - x: 0, - y: 0, - angle: 0, - element: { id } as dia.Element, - })); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - resizeObserver.triggerResize(element, 100, 50); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - }); - - describe('ResizeObserver entry edge cases', () => { - it('skips entries with no borderBoxSize', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - - // Construct an entry with empty borderBoxSize manually - const callback = (resizeObserver as unknown as { callback: ResizeObserverCallback }) - .callback; - callback( - [ - { - target: element, - contentRect: {} as DOMRectReadOnly, - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - } as ResizeObserverEntry, - ], - resizeObserver as unknown as ResizeObserver - ); - - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips entries with zero size (e.g. display:none)', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - - resizeObserver.triggerResize(element, 0, 0); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - - it('skips entries whose target is not currently observed', () => { - const element = document.createElement('div'); - observer.add({ id: 'element-1', node: element }); - const resizeObserver = MockResizeObserver.getLastInstance()!; - const otherNode = document.createElement('div'); - - const callback = (resizeObserver as unknown as { callback: ResizeObserverCallback }) - .callback; - callback( - [ - { - target: otherNode, - contentRect: {} as DOMRectReadOnly, - borderBoxSize: [{ inlineSize: 100, blockSize: 50 }], - contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], - devicePixelContentBoxSize: [{ inlineSize: 100, blockSize: 50 }], - } as ResizeObserverEntry, - ], - resizeObserver as unknown as ResizeObserver - ); - expect(mockOnBatchUpdate).not.toHaveBeenCalled(); - }); - }); -}); 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 f431b34dc6..5aefa16e49 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -19,22 +19,6 @@ function setup() { return { graph, onChanges, controller }; } -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, onElementRemove, onReset, controller }; -} - function addElement(graph: dia.Graph, id: string, x = 10, y = 20, width = 100, height = 50) { graph.addCell({ id, @@ -385,121 +369,6 @@ describe('graphChanges', () => { }); }); - describe('onElementsSizeChange', () => { - it('fires for each element when resetCells seeds cells with sizes', () => { - const { graph, onElementsSizeChange } = setupWithSize(); - graph.resetCells([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 100, height: 50 }, - }, - { - id: 'b', - type: 'element', - position: { x: 200, y: 0 }, - size: { width: 80, height: 40 }, - }, - { - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]); - - const elementCalls = onElementsSizeChange.mock.calls.filter( - ([id]) => id === 'a' || id === 'b' - ); - expect(elementCalls).toHaveLength(2); - expect(elementCalls).toEqual( - expect.arrayContaining([ - ['a', { width: 100, height: 50 }], - ['b', { width: 80, height: 40 }], - ]) - ); - }); - - 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([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 0, height: 0 }, - }, - ]); - onElementsSizeChange.mockClear(); - - const cell = graph.getCell('a') as dia.Element; - 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({ autoSize: true }) - ); - }); - - it('does not fire for links on reset', () => { - const { graph, onElementsSizeChange } = setupWithSize(); - graph.resetCells([ - { - id: 'a', - type: 'element', - position: { x: 0, y: 0 }, - size: { width: 100, height: 50 }, - }, - { - id: 'b', - type: 'element', - position: { x: 200, y: 0 }, - size: { width: 80, height: 40 }, - }, - { - id: 'l1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]); - - for (const [id] of onElementsSizeChange.mock.calls) { - expect(id).not.toBe('l1'); - } - }); - }); - describe('destroy', () => { it('stops listening to graph events', () => { const { graph, onChanges, controller } = setup(); 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 5557aea252..a93988adb8 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 @@ -343,7 +343,7 @@ describe('GraphStore observer wiring', () => { }); const node = document.createElement('div'); - const cleanup = store.setMeasuredNode({ id: 'a', node }); + const cleanup = store.measurement.observe({ id: 'a', node }); expect(typeof cleanup).toBe('function'); cleanup(); store.destroy(false); @@ -499,7 +499,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'a', node }); + store.measurement.observe({ id: 'a', node }); const observer = MockResizeObserver.instances.at(-1)!; observer.triggerResize(node, 100, 50); @@ -522,7 +522,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ + store.measurement.observe({ id: 'a', node, transform: ({ width, height }) => ({ width, height, x: 9, y: 11 }), @@ -562,7 +562,7 @@ describe('GraphStore size observer integration', () => { }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'a', node }); + store.measurement.observe({ id: 'a', node }); const observer = MockResizeObserver.instances.at(-1)!; observer.triggerResize(node, 50, 25); @@ -581,7 +581,7 @@ describe('GraphStore size observer integration', () => { size: { width: 1, height: 1 }, }); const node = document.createElement('div'); - store.setMeasuredNode({ id: 'ghost', node }); + store.measurement.observe({ id: 'ghost', node }); // Remove the cell — the projection no longer contains it, so // processSizeChange bails out before reaching getCellTransform. 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 142f41d60b..9822eefde1 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -8,6 +8,9 @@ import type { CellRecord } from '../../types/cell.types'; const createGraph = () => new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const measuredState = (store: { + readonly measurement: { readonly stateSource: { readonly get: () => number } }; +}) => store.measurement.stateSource.get(); describe('GraphStore', () => { describe('constructor', () => { @@ -61,7 +64,7 @@ describe('GraphStore', () => { store.destroy(true); }); - it('bumps measureState after initialCells seed (so useOnElementsMeasured can fire isInitial)', async () => { + it('bumps the measurement version after the initialCells seed', async () => { const initialCells: readonly CellRecord[] = [ { id: 'a', @@ -71,14 +74,14 @@ describe('GraphStore', () => { } as CellRecord, ]; const store = new GraphStore({ initialCells }); - // simpleScheduler defers the measureState bump to a microtask. + // simpleScheduler defers the bump to a microtask. await flush(); - expect(store.measureState.get()).toBeGreaterThan(0); + expect(measuredState(store)).toBeGreaterThan(0); store.destroy(false); }); - it('does not bump measureState when initialCells contain only links or zero-sized elements', async () => { - const initialCells: readonly CellRecord[] = [ + it('counts a zero-sized element as measured (zero is a legal size), but not a diagram of links only', async () => { + const zeroSized: readonly CellRecord[] = [ { id: 'zero', type: ELEMENT_MODEL_TYPE, @@ -86,35 +89,49 @@ describe('GraphStore', () => { size: { width: 0, height: 0 }, } as CellRecord, ]; - const store = new GraphStore({ initialCells }); + const store = new GraphStore({ initialCells: zeroSized }); await flush(); - expect(store.measureState.get()).toBe(0); + expect(measuredState(store)).toBe(1); store.destroy(false); + + const linksOnly = new GraphStore({ + initialCells: [ + { + id: 'l', + type: LINK_MODEL_TYPE, + source: { x: 0, y: 0 }, + target: { x: 9, y: 9 }, + } as CellRecord, + ], + }); + await flush(); + expect(measuredState(linksOnly)).toBe(0); + linksOnly.destroy(false); }); - it('bumps measureState once an unsized element is measured to the size it already has', async () => { + it('does not change the measured state for an application resize or an equal measurement', async () => { const initialCells: readonly CellRecord[] = [ { 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. + // No paper renders it, so the seed settles on its own. + expect(measuredState(store)).toBe(1); + // The application sizes the element: not a measurement. (store.graph.getCell('a') as dia.Element).resize(120, 40); await flush(); - expect(store.measureState.get()).toBe(0); + expect(measuredState(store)).toBe(1); // The observer measures the same size, so it writes nothing to the graph. const node = document.createElement('div'); - store.setMeasuredNode({ id: 'a', node }); + store.measurement.observe({ 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); + expect(measuredState(store)).toBe(1); store.destroy(false); }); @@ -221,7 +238,9 @@ describe('GraphStore', () => { it('fires with added/changed/removed summary', async () => { const snaps: Array> = []; const store = new GraphStore({}); - store.setOnIncrementalCellsChange((c) => snaps.push(snapshot(c as Parameters[0]))); + store.setOnIncrementalCellsChange((c) => + snaps.push(snapshot(c as Parameters[0])) + ); store.graph.addCell({ id: 'a', type: ELEMENT_MODEL_TYPE, @@ -239,7 +258,9 @@ describe('GraphStore', () => { it('reports links alongside elements in the unified pipeline', async () => { const snaps: Array> = []; const store = new GraphStore({}); - store.setOnIncrementalCellsChange((c) => snaps.push(snapshot(c as Parameters[0]))); + store.setOnIncrementalCellsChange((c) => + snaps.push(snapshot(c as Parameters[0])) + ); store.graph.addCells([ { id: 'a', @@ -317,7 +338,7 @@ describe('GraphStore', () => { const measure = (store: GraphStore, id: string) => { const node = document.createElement('div'); document.body.append(node); - store.setMeasuredNode({ id, node }); + store.measurement.observe({ id, node }); return () => node.remove(); }; diff --git a/packages/joint-react/src/store/__tests__/measurement.test.ts b/packages/joint-react/src/store/__tests__/measurement.test.ts new file mode 100644 index 0000000000..32c2e2c623 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/measurement.test.ts @@ -0,0 +1,709 @@ +import { dia } from '@joint/core'; +import { createMeasurement, AUTO_SIZE_OPTION, type AutoSizeOrigin } from '../measurement'; +import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; +import type { PaperStore } from '../paper-store'; + +/** A ResizeObserver the test drives: `report()` is what the browser would deliver. */ +class TestResizeObserver { + static readonly instances: TestResizeObserver[] = []; + readonly observed = new Set(); + isDisconnected = false; + 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.isDisconnected = true; + this.observed.clear(); + } + report(target: Element, width: number, height: number, hasBox = true) { + const borderBoxSize = hasBox ? [{ inlineSize: width, blockSize: height }] : []; + const entry = { target, borderBoxSize } as unknown as ResizeObserverEntry; + this.callback([entry], this as unknown as ResizeObserver); + } +} + +const flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const lastObserver = () => TestResizeObserver.instances.at(-1)!; +const NO_PAPERS: ReadonlyMap = new Map(); +const newNode = () => document.createElement('div'); + +type Transform = Parameters['observe']>[0]['transform']; + +/** + * A graph with its measurement. `add` puts a 1x1 element at (10, 20) unless + * sized otherwise; `writes` counts the batches the measurement writes in, one + * per ResizeObserver callback that changed something. + */ +function setup(autoSizeOrigin: AutoSizeOrigin = 'top-left') { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + const measurement = createMeasurement({ graph, autoSizeOrigin, paperStores: NO_PAPERS }); + /** What `selectMeasuredState` reads right now. */ + const version = () => measurement.stateSource.get(); + const nodesById = new Map(); + const writes = jest.fn(); + graph.on('batch:stop', ({ batchName }: { batchName?: string }) => { + if (batchName === 'auto-size') writes(); + }); + const add = (id: string, width = 1, height = 1) => { + graph.addCell({ id, type: 'element', position: { x: 10, y: 20 }, size: { width, height } }); + return graph.getCell(id) as dia.Element; + }; + const observe = (id: string, node: HTMLElement = newNode(), transform?: Transform) => { + nodesById.set(id, [...(nodesById.get(id) ?? []), node]); + return measurement.observe({ id, node, transform }); + }; + /** The old observer's `has(id)`: the element has a registered node, so one of them is observed. */ + const has = (id: string) => + (nodesById.get(id) ?? []).some((node) => + TestResizeObserver.instances.some((instance) => instance.observed.has(node)) + ); + const size = (id: string) => (graph.getCell(id) as dia.Element).size(); + return { graph, measurement, version, add, observe, has, size, writes }; +} + +beforeEach(() => { + TestResizeObserver.instances.length = 0; + globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; +}); + +describe('measurement — observe', () => { + it('does not create a ResizeObserver until a node is observed', () => { + const { add, observe } = setup(); + add('element-1'); + expect(TestResizeObserver.instances).toHaveLength(0); + + observe('element-1'); + observe('element-1'); + expect(TestResizeObserver.instances).toHaveLength(1); + }); + + it('registers the element with the ResizeObserver', () => { + const { add, observe, has } = setup(); + add('element-1'); + + observe('element-1'); + + expect(has('element-1')).toBe(true); + expect(has('non-existent')).toBe(false); + }); + + it('returns a cleanup that unregisters the element', () => { + const { add, observe, has } = setup(); + add('element-1'); + + const cleanup = observe('element-1'); + expect(has('element-1')).toBe(true); + + cleanup(); + expect(has('element-1')).toBe(false); + }); + + it('handles multiple elements', () => { + const { add, observe, has } = setup(); + add('element-1'); + add('element-2'); + + observe('element-1'); + observe('element-2'); + + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + }); + + it('writes the reported size to the element', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + }); + + it('writes each of several elements in its own batch', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + add('element-2'); + const first = newNode(); + const second = newNode(); + observe('element-1', first); + observe('element-2', second); + + lastObserver().report(first, 100, 50); + lastObserver().report(second, 200, 100); + + expect(writes).toHaveBeenCalledTimes(2); + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + expect(size('element-2')).toEqual({ width: 200, height: 100 }); + }); +}); + +describe('measurement — ResizeObserver callback', () => { + it('writes every size change', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + expect(writes).toHaveBeenCalledTimes(1); + + lastObserver().report(node, 200, 100); + expect(writes).toHaveBeenCalledTimes(2); + }); + + it('does not write when the size has not changed significantly', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + lastObserver().report(node, 100, 50); + writes.mockClear(); + + // Within the 0.5 tolerance. + lastObserver().report(node, 100.1, 50.1); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('tags the write as a measurement and rounds to two decimals', () => { + const { graph, add, observe, size } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + const onSize = jest.fn(); + graph.on('change:size', onSize); + + lastObserver().report(node, 100.004, 50); + + expect(size('element-1')).toEqual({ width: 100, height: 50 }); + expect(element.position()).toEqual({ x: 10, y: 20 }); + expect(onSize.mock.calls[0][2]).toEqual(expect.objectContaining({ [AUTO_SIZE_OPTION]: true })); + }); + + it('uses the transform when provided', () => { + const { add, observe, size } = setup(); + const element = add('element-1'); + const node = newNode(); + const transform = jest.fn(({ width, height }) => ({ width: width + 20, height: height + 20 })); + observe('element-1', node, transform); + + lastObserver().report(node, 100, 50); + + expect(transform).toHaveBeenCalledWith({ + x: 10, + y: 20, + angle: 0, + model: element, + width: 100, + height: 50, + id: 'element-1', + }); + expect(size('element-1')).toEqual({ width: 120, height: 70 }); + }); + + it('writes the position a transform returns', () => { + const { add, observe } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node, ({ width, height }) => ({ width, height, x: 9, y: 11 })); + + lastObserver().report(node, 80, 40); + + expect(element.position()).toEqual({ x: 9, y: 11 }); + }); + + it('keeps the center fixed with the center origin', () => { + const { add, observe } = setup('center'); + const element = add('element-1', 20, 20); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 40, 60); + + expect(element.position()).toEqual({ x: 0, y: 0 }); + }); + + it('skips an element that is not in the graph', () => { + const { observe, writes } = setup(); + const node = newNode(); + observe('element-1', node); + + expect(() => lastObserver().report(node, 100, 50)).not.toThrow(); + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips an element removed from the graph after it registered', () => { + const { add, observe, writes } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + element.remove(); + + expect(() => lastObserver().report(node, 100, 50)).not.toThrow(); + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips when the model already has the measured size (within the tolerance)', () => { + const { add, observe, writes } = setup(); + add('element-1', 100, 50); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips a report equal to the last one, even if the application resized meanwhile', () => { + const { add, observe, size, writes } = setup(); + const element = add('element-1'); + const node = newNode(); + observe('element-1', node); + lastObserver().report(node, 100, 50); + writes.mockClear(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + element.resize(300, 300); + lastObserver().report(node, 100, 50); + + expect(writes).not.toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 300, height: 300 }); + warn.mockRestore(); + }); + + it('skips entries with no borderBoxSize', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 100, 50, false); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips entries with zero size (e.g. display:none)', () => { + const { add, observe, writes } = setup(); + add('element-1'); + const node = newNode(); + observe('element-1', node); + + lastObserver().report(node, 0, 0); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('skips entries whose target is not currently observed', () => { + const { add, observe, writes } = setup(); + add('element-1'); + observe('element-1'); + + lastObserver().report(newNode(), 100, 50); + + expect(writes).not.toHaveBeenCalled(); + }); +}); + +describe('measurement — several nodes for one element', () => { + it('allows multiple registrations for the same element', () => { + const { add, observe, has } = setup(); + add('element-1'); + + observe('element-1'); + observe('element-1'); + + expect(has('element-1')).toBe(true); + }); + + it('observes only the latest node', () => { + const { add, observe } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + + observe('element-1', nodeA); + observe('element-1', nodeB); + + expect(lastObserver().observed.has(nodeA)).toBe(false); + expect(lastObserver().observed.has(nodeB)).toBe(true); + }); + + it('writes only what the latest node reports', () => { + const { add, observe, size, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + observe('element-1', nodeA); + observe('element-1', nodeB); + + lastObserver().report(nodeA, 77, 77); + expect(writes).not.toHaveBeenCalled(); + + lastObserver().report(nodeB, 200, 100); + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 200, height: 100 }); + }); + + it('falls back to the previous node when the latest one is removed', () => { + const { add, observe, has, size, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + observe('element-1', nodeA); + const cleanupB = observe('element-1', nodeB); + + cleanupB(); + + expect(lastObserver().observed.has(nodeA)).toBe(true); + expect(lastObserver().observed.has(nodeB)).toBe(false); + expect(has('element-1')).toBe(true); + + lastObserver().report(nodeA, 150, 75); + expect(writes).toHaveBeenCalledTimes(1); + expect(size('element-1')).toEqual({ width: 150, height: 75 }); + }); + + it('removes a node that is not the latest without affecting the latest', () => { + const { add, observe, has, writes } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const cleanupA = observe('element-1', nodeA); + observe('element-1', nodeB); + + cleanupA(); + + expect(lastObserver().observed.has(nodeB)).toBe(true); + expect(has('element-1')).toBe(true); + + lastObserver().report(nodeB, 300, 150); + expect(writes).toHaveBeenCalledTimes(1); + }); + + it('handles removing the latest of three nodes', () => { + const { add, observe, has } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const nodeC = newNode(); + observe('element-1', nodeA); + observe('element-1', nodeB); + const cleanupC = observe('element-1', nodeC); + + cleanupC(); + + expect(lastObserver().observed.has(nodeC)).toBe(false); + expect(lastObserver().observed.has(nodeB)).toBe(true); + expect(lastObserver().observed.has(nodeA)).toBe(false); + expect(has('element-1')).toBe(true); + }); + + it('fully unregisters the element after all its nodes are cleaned up', () => { + const { add, observe, has } = setup(); + add('element-1'); + const cleanupA = observe('element-1'); + const cleanupB = observe('element-1'); + + cleanupB(); + expect(has('element-1')).toBe(true); + + cleanupA(); + expect(has('element-1')).toBe(false); + expect(lastObserver().observed.size).toBe(0); + }); + + it('uses the transform of the latest node, not a previous one', () => { + const { add, observe, size } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const transformA = jest.fn(({ width, height }) => ({ width: width + 10, height: height + 10 })); + const transformB = jest.fn(({ width, height }) => ({ width: width + 50, height: height + 50 })); + observe('element-1', nodeA, transformA); + observe('element-1', nodeB, transformB); + + lastObserver().report(nodeB, 100, 50); + + expect(transformA).not.toHaveBeenCalled(); + expect(transformB).toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 150, height: 100 }); + }); + + it('uses the previous transform after the latest node is removed', () => { + const { add, observe, size } = setup(); + add('element-1'); + const nodeA = newNode(); + const nodeB = newNode(); + const transformA = jest.fn(({ width, height }) => ({ width: width + 10, height: height + 10 })); + const transformB = jest.fn(({ width, height }) => ({ width: width + 50, height: height + 50 })); + observe('element-1', nodeA, transformA); + const cleanupB = observe('element-1', nodeB, transformB); + + cleanupB(); + lastObserver().report(nodeA, 100, 50); + + expect(transformA).toHaveBeenCalled(); + expect(transformB).not.toHaveBeenCalled(); + expect(size('element-1')).toEqual({ width: 110, height: 60 }); + }); + + it('has an idempotent cleanup', () => { + const { add, observe, has } = setup(); + add('element-1'); + const cleanup = observe('element-1'); + + cleanup(); + expect(has('element-1')).toBe(false); + + expect(() => cleanup()).not.toThrow(); + expect(has('element-1')).toBe(false); + }); + + it('handles interleaved registrations across different elements', () => { + const { add, observe, has } = setup(); + add('element-1'); + add('element-2'); + const node1A = newNode(); + const node1B = newNode(); + const node2A = newNode(); + const cleanup1A = observe('element-1', node1A); + observe('element-2', node2A); + observe('element-1', node1B); + + expect(lastObserver().observed.has(node1B)).toBe(true); + expect(lastObserver().observed.has(node2A)).toBe(true); + expect(lastObserver().observed.has(node1A)).toBe(false); + + cleanup1A(); + expect(lastObserver().observed.has(node1B)).toBe(true); + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + }); +}); + +describe('measurement — destroy', () => { + it('stops observing every element', () => { + const { measurement, add, observe, has } = setup(); + add('element-1'); + add('element-2'); + observe('element-1'); + observe('element-2'); + expect(has('element-1')).toBe(true); + expect(has('element-2')).toBe(true); + + measurement.destroy(); + + expect(has('element-1')).toBe(false); + expect(has('element-2')).toBe(false); + expect(lastObserver().isDisconnected).toBe(true); + }); + + it('stops observing elements with several nodes, and a late cleanup is a no-op', () => { + const { measurement, add, observe, has } = setup(); + add('element-1'); + add('element-2'); + observe('element-1'); + const cleanupLatest = observe('element-1'); + observe('element-2'); + + measurement.destroy(); + + expect(has('element-1')).toBe(false); + expect(has('element-2')).toBe(false); + expect(() => cleanupLatest()).not.toThrow(); + expect(lastObserver().observed.size).toBe(0); + }); + + it('stops following the graph', async () => { + const { measurement, add, version } = setup(); + measurement.destroy(); + + add('element-1'); + await flush(); + + expect(version()).toBe(0); + }); +}); + +describe('measurement — dev warning', () => { + it('warns once when an observed element is resized by the application, never for a measurement', () => { + const { add, observe } = setup(); + const observed = add('warned'); + const plain = add('plain'); + const node = newNode(); + const cleanup = observe('warned', node); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + plain.resize(9, 9); + lastObserver().report(node, 100, 50); + expect(warn).not.toHaveBeenCalled(); + + observed.resize(200, 80); + observed.resize(220, 90); + expect(warn).toHaveBeenCalledTimes(1); + + // No longer observed: its size is the application's to set. + cleanup(); + warn.mockClear(); + observed.resize(10, 10); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +const sized = (id: string) => ({ id, type: 'element', size: { width: 100, height: 50 } }); +const unsized = (id: string) => ({ id, type: 'element', size: { width: 0, height: 0 } }); +const link = (id: string, source: string, target: string) => ({ + id, + type: 'standard.Link', + source: { id: source }, + target: { id: target }, +}); + +// What `graph-changes.ts` used to forward as callbacks, now heard by the module itself. +describe('measurement — graph events', () => { + it('tracks each element a reset seeds, and changes once for them', async () => { + const { graph, version } = setup(); + + graph.resetCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + await flush(); + expect(version()).toBe(1); + + // Both were tracked: with one of them gone the diagram is still measured. + graph.getCell('a').remove(); + await flush(); + expect(version()).toBe(2); + }); + + it('clears what it tracked before tracking the seed of a reset', async () => { + const { graph, version, add } = setup(); + add('a', 100, 50); + await flush(); + expect(version()).toBe(1); + + // Nothing is measured from the reset until its seed settles. + graph.resetCells([sized('x')]); + expect(version()).toBe(0); + await flush(); + // A value is never reused, so the pass after a reset is seen as a change. + expect(version()).toBe(2); + + // A reset to no elements stays unmeasured. + graph.resetCells([link('l', 'x', 'x')]); + await flush(); + expect(version()).toBe(0); + }); + + it('changes when an element is removed, and not when a link is', async () => { + const { graph, version } = setup(); + graph.addCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + await flush(); + expect(version()).toBe(1); + + graph.getCell('l1').remove(); + await flush(); + expect(version()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + expect(version()).toBe(2); + }); + + it('is not measured any more once the last element is removed', async () => { + const { graph, version } = setup(); + graph.addCells([sized('a'), unsized('z')]); + await flush(); + expect(version()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + // A zero-sized element is still an element with a known size. + expect(version()).toBe(2); + + graph.getCell('z').remove(); + await flush(); + expect(version()).toBe(0); + }); + + it('changes for a measurement write, told apart by the change:size options', async () => { + const { graph, version } = setup(); + graph.resetCells([unsized('a')]); + await flush(); + expect(version()).toBe(1); + + (graph.getCell('a') as dia.Element).set( + 'size', + { width: 120, height: 60 }, + { [AUTO_SIZE_OPTION]: true } + ); + await flush(); + + expect(version()).toBe(2); + }); + + it('never changes for a size the application writes', async () => { + const { version, add } = setup(); + const element = add('a'); + await flush(); + + element.resize(70, 70); + element.resize(0, 0); + await flush(); + + expect(version()).toBe(1); + }); + + // Regression: elements already in the graph when the measurement is created + // (an external graph) were never counted, so the state was wrong from then on. + it('counts the elements the graph already holds', async () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + graph.addCells([sized('a'), sized('b'), link('l1', 'a', 'b')]); + const measurement = createMeasurement({ + graph, + autoSizeOrigin: 'top-left', + paperStores: NO_PAPERS, + }); + await flush(); + expect(measurement.stateSource.get()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + // One element is left, so the diagram is still measured. + expect(measurement.stateSource.get()).toBe(2); + + graph.getCell('b').remove(); + await flush(); + expect(measurement.stateSource.get()).toBe(0); + }); + + it('does not track links', async () => { + const { graph, version } = setup(); + + graph.resetCells([link('l1', 'a', 'b')]); + graph.addCell(link('l2', 'a', 'b')); + await flush(); + + expect(version()).toBe(0); + }); + + it('changes once for a batch of elements, whatever their sizes', async () => { + const { graph, version, add } = setup(); + graph.addCell(unsized('zero')); + add('a'); + add('b'); + await flush(); + + expect(version()).toBe(1); + }); +}); diff --git a/packages/joint-react/src/store/clear-view.ts b/packages/joint-react/src/store/clear-view.ts index 2fc483eebe..28ff1942d3 100644 --- a/packages/joint-react/src/store/clear-view.ts +++ b/packages/joint-react/src/store/clear-view.ts @@ -2,51 +2,6 @@ import type { dia } from '@joint/core'; import type { CellId } from '../types/cell.types'; import type { IncrementalChange } from '../state/incremental.types'; -/** - * Cache entry for batched clearView updates. - */ -export interface ClearViewCacheEntry { - /** Callback to validate which links should be cleared */ - onValidateLink?: (link: dia.Link) => boolean; -} - -/** - * Merges two clearView validators. - * - If no validator (clear all links), that takes precedence - * - If both have validators, creates union validator - * @param existing - Existing validator - * @param incoming - New validator - * @returns Merged validator or undefined - */ -export function mergeClearViewValidators( - existing: ClearViewCacheEntry | undefined, - incoming: ClearViewCacheEntry -): ClearViewCacheEntry { - // No existing entry - use new - if (!existing) { - return incoming; - } - - const { onValidateLink } = incoming; - - // No validator means clear all links - this takes precedence - if (!onValidateLink) { - return { onValidateLink: undefined }; - } - - // If existing has validator, create union - if (existing.onValidateLink) { - const existingValidator = existing.onValidateLink; - const newValidator = onValidateLink; - return { - onValidateLink: (link: dia.Link) => existingValidator(link) || newValidator(link), - }; - } - - // Existing has no validator (clear all) - keep that behavior - return existing; -} - /** * Determines if a link should be cleared based on cell connection. * @param link - The link to check @@ -105,32 +60,3 @@ export function clearConnectedLinkViews( } return changes; } - -/** - * Executes clearView for a single cell across all papers. - * @param papers - Iterable of paper stores with paper property - * @param graph - The JointJS Graph instance - * @param cellId - The cell ID to clear - * @param onValidateLink - Optional callback to determine which links to keep - */ -export function executeClearViewForCell( - papers: Iterable<{ readonly paper?: dia.Paper }>, - graph: dia.Graph, - cellId: CellId, - onValidateLink?: (link: dia.Link) => boolean -): void { - for (const paperStore of papers) { - const { paper } = paperStore; - if (!paper) { - continue; - } - - const elementView = paper.getCellView(cellId); - if (!elementView) { - continue; - } - - elementView.cleanNodesCache(); - clearConnectedLinkViews(paper, graph, cellId, onValidateLink); - } -} diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts deleted file mode 100644 index 870c180aca..0000000000 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * Element size observer with stack-based multi-hook support. - * - * Tracks DOM element sizes via ResizeObserver and syncs them to the graph. - * Multiple {@link useMeasureElement} hooks can target the same cell ID, only the - * most recently added (active) node is observed. When it unmounts, the - * previous node in the stack becomes active again. - * - * Internal data structures: - * - `observedStacksByCellId`, `Map` (last = active) - * - `activeObservedElementByDomNode`, `WeakMap` for O(1) lookup in the ResizeObserver callback - */ -import type { dia } from '@joint/core'; -import type { CellId } from '../types/cell.types'; -import type { ElementLayout } from '../types/cell.types'; -import type { ElementJSONInit } from '../types/cell.types'; - -const DEFAULT_OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; -// Epsilon value to avoid jitter due to sub-pixel rendering -// especially on Safari -const EPSILON = 0.5; - -/** Element layout where width/height are required but x/y may be omitted. */ -type ElementLayoutOptionalXY = Pick & - Partial>; - -/** - * The element's measurement, passed to a {@link TransformElementLayout} callback. - * Carries the element's current `x`, `y`, and `angle` together with the freshly - * measured `width` and `height`, plus the underlying model and cell id. - * @expand - * @group Types - */ -export interface TransformElementLayoutParams extends Required { - /** The JointJS `dia.Element` instance being measured. */ - readonly model: dia.Element; - /** Id of the cell being measured. */ - readonly id: CellId; -} - -/** - * Adjusts a measured element layout before it is written to the graph. Receives - * the element's current geometry plus its newly measured size, and returns the - * `width`/`height` (and optionally `x`/`y`) to apply — use it to clamp sizes, - * snap to a grid, or reposition while auto-sizing. Pass it via the `transform` - * option of {@link useMeasureElement}. - * @example - * ```tsx - * import type { TransformElementLayout } from '@joint/react'; - * - * // Never let a measured element shrink below 80px wide. - * const transform: TransformElementLayout = ({ width, height }) => ({ - * width: Math.max(width, 80), - * height, - * }); - * ``` - * @see {@link TransformElementLayoutParams} - * @group Types - */ -export type TransformElementLayout = ( - params: TransformElementLayoutParams -) => ElementLayoutOptionalXY; - -/** - * Options for registering an element to be measured for size changes. - * @group Types - */ -export interface SetMeasuredNodeOptions { - /** The DOM node (HTML or SVG) to observe for size changes */ - readonly node: HTMLElement | SVGElement; - /** Optional callback to handle size updates before they're applied */ - readonly transform?: TransformElementLayout; - /** The ID of the cell in the graph that corresponds to this DOM node */ - readonly id: CellId; -} - -interface ObservedElement { - readonly id: CellId; - readonly node: HTMLElement | SVGElement; - readonly transform?: TransformElementLayout; - lastWidth?: number; - lastHeight?: number; - isMeasured: boolean; -} - -/** - * Options for creating an elements size observer. - */ -interface Options { - /** Options to pass to the ResizeObserver constructor */ - readonly resizeObserverOptions?: ResizeObserverOptions; - /** Function to get the current size of a cell from the graph */ - readonly getCellTransform: ( - id: CellId - ) => ElementLayoutOptionalXY & { model: dia.Element; angle: number }; - /** Function to get the elements from the container */ - 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; -} - -/** - * Observer interface for tracking element size changes. - * Uses ResizeObserver to automatically detect when DOM elements change size - * and updates the corresponding graph elements. - * @group Types - */ -export interface GraphStoreObserver { - /** - * Adds an element to be observed for size changes. - * @param options - Options for measuring the element - * @returns Cleanup function to stop observing - */ - readonly add: (options: SetMeasuredNodeOptions) => () => void; - /** - * Cleans up all observers and resources. - */ - readonly clean: () => void; - /** - * Checks if a node is currently being observed. - * @param id - The ID of the cell to check - * @returns True if the node is being observed - */ - readonly has: (id: CellId) => boolean; -} - -/** - * Rounds a number to two decimals. - * @param value - The value to round to two decimals - * @returns The rounded value - */ -function roundToTwoDecimals(value: number) { - return Math.round(value * 100) / 100; -} - -/** - * Options for processing a single element's size change. - */ -interface ProcessSizeChangeOptions { - readonly measuredWidth: number; - readonly measuredHeight: number; - readonly observedElement: ObservedElement; - readonly getCellTransform: Options['getCellTransform']; - readonly elements: Map; - readonly mutableLayouts: Record; -} - -/** - * Processes a size change for a single element. - * Returns true if the element was updated, false otherwise. - * @param options - The options containing size data and element references - * @returns True if the element was updated, false otherwise - */ -function processSizeChange(options: ProcessSizeChangeOptions): boolean { - const { - measuredWidth, - measuredHeight, - observedElement, - getCellTransform, - elements, - mutableLayouts, - } = options; - - if (!elements.has(observedElement.id)) { - return false; - } - - const currentCellTransform = getCellTransform(observedElement.id); - if ( - Math.abs(currentCellTransform.width - measuredWidth) <= EPSILON && - Math.abs(currentCellTransform.height - measuredHeight) <= EPSILON - ) { - return false; - } - - if ( - Math.abs((observedElement.lastWidth ?? 0) - measuredWidth) <= EPSILON && - Math.abs((observedElement.lastHeight ?? 0) - measuredHeight) <= EPSILON - ) { - return false; - } - - observedElement.lastWidth = measuredWidth; - observedElement.lastHeight = measuredHeight; - - const { transform } = observedElement; - if (transform) { - // Provide the full current transform to the callback - const { x = 0, y = 0, angle = 0, model } = currentCellTransform; - mutableLayouts[observedElement.id] = transform({ - x, - y, - angle, - model, - width: measuredWidth, - height: measuredHeight, - id: observedElement.id, - }); - } else { - // No transform provided, use measured size directly. Position is left unchanged. - mutableLayouts[observedElement.id] = { - width: measuredWidth, - height: measuredHeight, - }; - } - - return true; -} - -/** - * Creates an observer for element size changes using the ResizeObserver API. - * - * This function sets up automatic size tracking for DOM elements that correspond to graph elements. - * When a DOM element's size changes (e.g., due to content changes or CSS updates), the observer - * automatically updates the corresponding graph element's size. - * - * **Features:** - * - Uses ResizeObserver for efficient size tracking - * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering - * - Supports custom size update handlers - * @param options - The options for creating the size observer - * @returns A GraphStoreObserver instance with methods to add/remove observers - */ -export function createElementsSizeObserver(options: Options): GraphStoreObserver { - const { - resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, - getCellTransform, - onBatchUpdate, - onElementMeasured, - getElements, - } = options; - - // Stack per cell ID: last entry is the active (observed) node. - // When the active node unmounts, the previous one becomes active again. - const observedStacksByCellId = new Map(); - - // Maps only the active DOM node to its ObservedElement for O(1) lookup in the ResizeObserver callback. - const activeObservedElementByDomNode = new WeakMap(); - - /** Returns the active (last) element from the stack, or `undefined` if empty. */ - function getActiveElement(stack: readonly ObservedElement[]): ObservedElement | undefined { - return stack.at(-1); - } - - /** Starts observing the given element and registers it in the active DOM node lookup. */ - function activateElement(observedElement: ObservedElement) { - observer.observe(observedElement.node, resizeObserverOptions); - activeObservedElementByDomNode.set(observedElement.node, observedElement); - } - - /** Stops observing the given element and removes it from the active DOM node lookup. */ - function deactivateElement(observedElement: ObservedElement) { - observer.unobserve(observedElement.node); - activeObservedElementByDomNode.delete(observedElement.node); - } - - const observer = new ResizeObserver((entries) => { - let hasAnySizeChange = false; - const elements = getElements(); - const mutableLayouts: Record = {}; - - for (const entry of entries) { - const { target, borderBoxSize } = entry; - - const observedElement = activeObservedElementByDomNode.get( - target as HTMLElement | SVGElement - ); - if (!observedElement) continue; - - onElementMeasured?.(observedElement.id); - - if (!borderBoxSize || borderBoxSize.length === 0) { - continue; - } - - const [size] = borderBoxSize; - const { inlineSize, blockSize } = size; - - const measuredWidth = roundToTwoDecimals(inlineSize); - const measuredHeight = roundToTwoDecimals(blockSize); - - // Skip zero-size entries. This happens when an element is hidden (display:none) - // or removed from the DOM. We must never propagate 0-size to the model. - if (measuredWidth <= 0 || measuredHeight <= 0) { - continue; - } - - const wasUpdated = processSizeChange({ - measuredWidth, - measuredHeight, - observedElement, - getCellTransform, - mutableLayouts, - elements, - }); - - if (wasUpdated) { - hasAnySizeChange = true; - } - } - - if (!hasAnySizeChange) { - return; - } - - onBatchUpdate(mutableLayouts); - }); - - return { - add({ id, node, transform }: SetMeasuredNodeOptions) { - const observedElement: ObservedElement = { - id, - node, - transform, - isMeasured: false, - }; - - let stack = observedStacksByCellId.get(id); - - if (!stack) { - stack = []; - observedStacksByCellId.set(id, stack); - } - - // Deactivate the current active node before pushing the new one - const previousActive = getActiveElement(stack); - if (previousActive) { - deactivateElement(previousActive); - } - - stack.push(observedElement); - activateElement(observedElement); - - return () => { - const currentStack = observedStacksByCellId.get(id); - if (!currentStack) return; - - const isActive = getActiveElement(currentStack) === observedElement; - - if (isActive) { - deactivateElement(observedElement); - currentStack.pop(); - - // Fall back to the previous node in the stack - const newActive = getActiveElement(currentStack); - if (newActive) { - activateElement(newActive); - } - } else { - // Remove non-active entry from the middle of the stack - const index = currentStack.indexOf(observedElement); - if (index !== -1) { - currentStack.splice(index, 1); - } - } - - if (currentStack.length === 0) { - observedStacksByCellId.delete(id); - } - }; - }, - clean() { - // cleanup all observed nodes and clear all stacks - for (const [, stack] of observedStacksByCellId.entries()) { - for (const { node } of stack) { - observer.unobserve(node); - } - } - observedStacksByCellId.clear(); - observer.disconnect(); - }, - has(id: CellId) { - const stack = observedStacksByCellId.get(id); - return !!stack && stack.length > 0; - }, - }; -} diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index e25fe70cf8..3d87a4d6e1 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -60,27 +60,7 @@ interface OnChangeOptions { readonly isReset?: boolean; } -/** - * 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: 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 { +interface Options { readonly graph: dia.Graph; readonly onChanges: (options: OnChangeOptions) => void; } @@ -97,7 +77,7 @@ interface JointJSEventOptions { * @returns Controller exposing updateGraph and destroy. */ export function graphChanges(options: Options) { - const { graph, onElementsSizeChange, onElementRemove, onReset } = options; + const { graph } = options; const changes = new Map>(); let batchDepth = 0; @@ -168,10 +148,6 @@ export function graphChanges(options: Options) { _collection: mvc.Collection, { isUpdateFromReact }: JointJSEventOptions ) => { - if (cell.isElement() && onElementsSizeChange) { - const size = cell.size(); - onElementsSizeChange(cell.id, size); - } if (isUpdateFromReact) return; onCellEvent(cell, 'add'); } @@ -184,7 +160,6 @@ export function graphChanges(options: Options) { _collection: mvc.Collection, { isUpdateFromReact }: JointJSEventOptions ) => { - if (cell.isElement()) onElementRemove?.(cell.id); if (isUpdateFromReact) return; onCellEvent(cell, 'remove'); } @@ -196,16 +171,8 @@ 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 - // for seed elements never reach `onElementsSizeChange` via the - // `add` listener. Mirror that path here so initial-measurement - // gating (e.g. `useOnElementsMeasured`) fires for seed cells. - if (onElementsSizeChange && cell.isElement()) { - onElementsSizeChange(cell.id, (cell as dia.Element).size()); - } } // Bypass the simpleScheduler wrapper used for normal cell events. // `reset` is a one-shot bulk operation and callers (e.g. GraphStore @@ -224,15 +191,6 @@ export function graphChanges(options: Options) { onChanges({ changes: layoutChanges, isInsideBatch: true, deferCommit: isDeferring() }); }); - 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 // async) flushes as a single React update. Plain batches commit live. diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 02578ac446..57ceee69dc 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 MeasurementListeners, type UpdateGraphOptions } from './graph-changes'; +import { graphChanges, type UpdateGraphOptions } from './graph-changes'; import { asReadonlyContainer, createContainer, type ContainerChangeSet } from './state-container'; import { mergeCellRecord, toCellRecord } from '../state/data-mapping/cell-record-merge'; @@ -36,7 +36,7 @@ export type OnIncrementalCellsChange extends MeasurementListeners { +> { readonly graph: dia.Graph; readonly onIncrementalCellsChange?: OnIncrementalCellsChange; } @@ -55,8 +55,7 @@ export function graphProjection< Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, >(options: GraphProjectionState) { - const { graph, onIncrementalCellsChange, onElementsSizeChange, onElementRemove, onReset } = - options; + const { graph, onIncrementalCellsChange } = options; const cells = createContainer(); @@ -123,9 +122,6 @@ 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. @@ -248,6 +244,12 @@ export function graphProjection< // when the transaction closes, so a burst of edits (sync or spread across // `await`s) becomes a single React update. `flushContainer` self-guards // when nothing is pending. + // Cleared before anything is notified: a subscriber may change the graph + // from its callback, and those changes are recorded here for the next + // pass. Clearing afterwards would drop them and leave the store out of + // sync with the graph. + changes.clear(); + if (!deferCommit) flushContainer(); const hasTrackedChanges = @@ -261,8 +263,6 @@ export function graphProjection< changed!.clear(); removed!.clear(); } - - changes.clear(); }, }); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 22d745f31f..eed19a780b 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -3,17 +3,12 @@ import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types' import type { AddPaperOptions } from './paper-store'; import { PaperStore, getDefaultPaperState } from './paper-store'; -import { - createElementsSizeObserver, - type GraphStoreObserver, - type SetMeasuredNodeOptions, -} from './create-elements-size-observer'; +import { createMeasurement, type AutoSizeOrigin, type Measurement } from './measurement'; import { ELEMENT_MODEL_TYPE, ElementModel } from '../mvc/element-model'; 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'; @@ -22,10 +17,9 @@ import { type GraphProjection, type OnIncrementalCellsChange, } from './graph-projection'; -import { simpleScheduler } from '../utils/scheduler'; import { cellInputToModel } from '../utils/normalize-cell-input'; import type { CellInput } from '../types/cell.types'; -import { warnDuplicatePapers, warnResizeOnAutoSizedElement } from '../utils/dev-warnings'; +import { warnDuplicatePapers } from '../utils/dev-warnings'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, @@ -33,18 +27,6 @@ export const DEFAULT_CELL_NAMESPACE: Record = { [LINK_MODEL_TYPE]: LinkModel, }; -/** - * `dia.Cell.set()` option key used to mark writes that originate from the - * auto-size / measurement pipeline. `change:size` listeners can read it to - * distinguish measurement writes from external ones (controlled-mode sync, - * `cell.resize`, interactive tools) and avoid feedback loops. Exported for - * plugin authors and `@joint/react-plus` via `@joint/react/internal`. - */ -export const AUTO_SIZE_OPTION = 'autoSize'; - -/** Option shape accepted by `dia.Cell.set()` writes from the auto-size pipeline. */ -type AutoSizeOptions = { [AUTO_SIZE_OPTION]?: boolean }; - /** * Paper snapshot is a simple version counter. * Incremented on every view mount/unmount change to trigger React re-renders. @@ -65,19 +47,6 @@ export interface GraphStoreInternalSnapshot { readonly graphFeaturesVersion: number; } -/** - * Reference point that stays fixed when an auto-sized element's measured size - * changes. Mirrors CSS `transform-origin` semantics. - * - * - `'top-left'` (default): element grows right/down, top-left stays put. - * - `'center'`: element grows symmetrically, geometric center stays put. - * - * Only affects writes from the {@link useMeasureElement} pipeline. Manual `cell.resize()`, - * interactive resize tools, and direct `cell.set('size', ...)` calls are unaffected. - * @group Types - */ -export type AutoSizeOrigin = 'top-left' | 'center'; - /** * Options for constructing a {@link GraphStore}: an optional existing `dia.Graph`, * cell namespace/model overrides, the auto-size origin, and `initialCells` used to @@ -109,24 +78,13 @@ 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; + /** Element measuring and its "sizes are settled" signal. See {@link createMeasurement}. */ + public readonly measurement: Measurement; public readonly graph: dia.Graph; - public readonly autoSizeOrigin: AutoSizeOrigin; public paperStores = new Map(); public features: Record = {}; - private observer: GraphStoreObserver; - /** 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. - private warnAutoSizeResize?: (cell: dia.Cell, size: dia.Size, opt?: AutoSizeOptions) => void; constructor(public readonly config: GraphStoreOptions) { const { @@ -136,7 +94,6 @@ export class GraphStore< autoSizeOrigin = 'top-left', initialCells, } = config; - this.autoSizeOrigin = autoSizeOrigin; this.graph = graph ?? @@ -157,126 +114,17 @@ export class GraphStore< graphFeaturesVersion: 1, }); - // Measurement bookkeeping behind `measureState` (see `useOnElementsMeasured`): - // 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) 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; - this.measureState.set((previous) => previous + 1); - }; - this.scheduleMeasurementDelivery = () => simpleScheduler(deliverMeasurement); - this.graphProjection = graphProjection({ graph: this.graph, onIncrementalCellsChange: (changes) => { this.onIncrementalCellsChange?.(changes); }, - onElementsSizeChange: (id, size, changeOptions) => { - if (size.width > 0 && size.height > 0) { - sizedElements.add(id); - } else { - sizedElements.delete(id); - } - if (changeOptions === undefined) { - // 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.outstandingElements.delete(id); - } else { - return; - } - hasUndeliveredChange = true; - this.scheduleMeasurementDelivery(); - }, - onElementRemove: (id) => { - sizedElements.delete(id); - 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.outstandingElements.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.outstandingElements.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 - // when the ResizeObserver fires. - const map = new Map(); - for (const cell of this.graphProjection.cells.getSnapshot()) { - if (cell.id === undefined) continue; - if (this.isElement(cell)) { - map.set(cell.id, cell); - } - } - return map; - }, - onBatchUpdate: (updatedElements) => { - this.graph.startBatch('auto-size'); - for (const [id, data] of Object.entries(updatedElements)) { - const model = this.graph.getCell(id); - if (!model?.isElement()) continue; - // `autoSize: true` marks writes that originate from the - // ResizeObserver pipeline so `change:size` listeners can tell our - // own writes apart from external ones (controlled-mode sync, - // direct `cell.resize`, etc.) and avoid feedback loops. - const attributes: dia.Cell.Attributes = { - size: { width: data.width, height: data.height }, - }; - - if (data.x !== undefined && data.y !== undefined) { - attributes.position = { x: data.x, y: data.y }; - } else if (this.autoSizeOrigin === 'center') { - // Center-anchored auto-size: keep the geometric center fixed. - const center = model.getCenter(); - attributes.position = { - x: center.x - data.width / 2, - y: center.y - data.height / 2, - }; - } - - model.set(attributes, { [AUTO_SIZE_OPTION]: true }); - // Top-left auto-size (default): don't write position — the cell's - // top-left stays put implicitly and it grows right/down. - } - this.graph.stopBatch('auto-size'); - }, - getCellTransform: (id) => { - const model = this.graph.getCell(id); - if (!model?.isElement()) throw new Error('Cell not found or not an element: ' + id); - return { - model, - ...model.size(), - ...model.position().toJSON(), - angle: model.angle(), - }; - }, + // Before the seed below, so the seed cells are tracked. + this.measurement = createMeasurement({ + graph: this.graph, + autoSizeOrigin, + paperStores: this.paperStores, }); if (initialCells && initialCells.length > 0) { @@ -293,18 +141,6 @@ export class GraphStore< this.graphProjection.syncFromGraph(); } - // dev only — warn when an auto-sized element (registered with the size - // observer because it renders without `useModelGeometry`) is resized by - // something other than the measurement pipeline. Such resizes are - // immediately overwritten by the measured content size. - if (process.env.NODE_ENV !== 'production') { - this.warnAutoSizeResize = (cell, _size, opt) => { - if (opt?.[AUTO_SIZE_OPTION]) return; // our own measurement write - if (!this.observer.has(cell.id)) return; // not auto-sized → resize is honored - warnResizeOnAutoSizedElement(cell.id); - }; - this.graph.on('change:size', this.warnAutoSizeResize); - } } public setOnIncrementalCellsChange = (callback: OnIncrementalCellsChange) => { @@ -370,10 +206,7 @@ export class GraphStore< this.paperStores.clear(); this.graphProjection.destroy(); this.internalState.clean(); - this.observer.clean(); - if (this.warnAutoSizeResize) { - this.graph.off('change:size', this.warnAutoSizeResize); - } + this.measurement.destroy(); if (!isGraphExternal) { this.graph.clear(); } @@ -484,53 +317,6 @@ export class GraphStore< return { paperStore, remove: () => this.removePaper(id) }; }; - 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`) stays outstanding until measured, while - * one that nothing measures is settled now. - * @param id - the rendered element - */ - public markElementRendered = (id: CellId) => { - 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/index.ts b/packages/joint-react/src/store/index.ts index 8e10a0eea4..7685b561fe 100644 --- a/packages/joint-react/src/store/index.ts +++ b/packages/joint-react/src/store/index.ts @@ -1,3 +1,2 @@ export * from './graph-store'; export * from './paper-store'; -export * from './create-elements-size-observer'; diff --git a/packages/joint-react/src/store/measurement.ts b/packages/joint-react/src/store/measurement.ts new file mode 100644 index 0000000000..a11e487c6d --- /dev/null +++ b/packages/joint-react/src/store/measurement.ts @@ -0,0 +1,396 @@ +import { mvc, type dia } from '@joint/core'; +import type { CellId, ElementLayout } from '../types/cell.types'; +import type { PaperStore } from './paper-store'; +import { simpleScheduler } from '../utils/scheduler'; +import { warnResizeOnAutoSizedElement } from '../utils/dev-warnings'; +import type { SelectorSource } from '../selectors/source-selector'; + +/** + * `dia.Cell.set()` option key used to mark writes that originate from the + * auto-size / measurement pipeline. `change:size` listeners can read it to + * distinguish measurement writes from external ones (controlled-mode sync, + * `cell.resize`, interactive tools) and avoid feedback loops. Exported for + * plugin authors and `@joint/react-plus` via `@joint/react/internal`. + */ +export const AUTO_SIZE_OPTION = 'autoSize'; + +/** + * Reference point that stays fixed when an auto-sized element's measured size + * changes. Mirrors CSS `transform-origin` semantics. + * + * - `'top-left'` (default): element grows right/down, top-left stays put. + * - `'center'`: element grows symmetrically, geometric center stays put. + * + * Only affects writes from the {@link useMeasureElement} pipeline. Manual `cell.resize()`, + * interactive resize tools, and direct `cell.set('size', ...)` calls are unaffected. + * @group Types + */ +export type AutoSizeOrigin = 'top-left' | 'center'; + +const OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; +/** Tolerance that absorbs sub-pixel rendering jitter (notably on Safari). */ +const EPSILON = 0.5; + +/** A measured layout: `width`/`height` are required, `x`/`y` may be omitted. */ +type MeasuredLayout = Pick & + Partial>; + +/** + * The element's measurement, passed to a {@link TransformElementLayout} callback. + * Carries the element's current `x`, `y`, and `angle` together with the freshly + * measured `width` and `height`, plus the underlying model and cell id. + * @expand + * @group Types + */ +export interface TransformElementLayoutParams extends Required { + /** The JointJS `dia.Element` instance being measured. */ + readonly model: dia.Element; + /** Id of the cell being measured. */ + readonly id: CellId; +} + +/** + * Adjusts a measured element layout before it is written to the graph. Receives + * the element's current geometry plus its newly measured size, and returns the + * `width`/`height` (and optionally `x`/`y`) to apply — use it to clamp sizes, + * snap to a grid, or reposition while auto-sizing. Pass it via the `transform` + * option of {@link useMeasureElement}. + * @example + * ```tsx + * import type { TransformElementLayout } from '@joint/react'; + * + * // Never let a measured element shrink below 80px wide. + * const transform: TransformElementLayout = ({ width, height }) => ({ + * width: Math.max(width, 80), + * height, + * }); + * ``` + * @see {@link TransformElementLayoutParams} + * @group Types + */ +export type TransformElementLayout = (params: TransformElementLayoutParams) => MeasuredLayout; + +/** A DOM node whose size drives the size of the element `id`. */ +interface MeasuredNode { + readonly id: CellId; + readonly node: HTMLElement | SVGElement; + readonly transform?: TransformElementLayout; +} + +/** A registered {@link MeasuredNode} plus the last size it reported. */ +interface ObservedNode extends MeasuredNode { + lastWidth?: number; + lastHeight?: number; +} + +interface Options { + readonly graph: dia.Graph; + readonly autoSizeOrigin: AutoSizeOrigin; + /** The graph store's live paper registry: papers are what render and measure. */ + readonly paperStores: ReadonlyMap; +} + +const isSameSize = (a: number, b: number) => Math.abs(a - b) <= EPSILON; +const roundToTwoDecimals = (value: number) => Math.round(value * 100) / 100; + +/** + * Everything `@joint/react` knows about measuring elements, for one graph: + * sizing elements from their rendered DOM node ({@link useMeasureElement}) and + * telling when sizes are settled ({@link selectMeasuredState}). + * + * The measured state changes once per settled change. An element is outstanding from + * arriving in the graph until it is accounted for: its React content committed + * without registering a node (`markRendered`), no paper renders it + * (`settleUnrendered`), the node it registered was measured, or it left the + * graph. A zero size is never read as "waiting": it is a legal final size. + * Sizes the application writes are not measurements and never bump. + * + * The `ResizeObserver` is created by the first `observe()`, so a diagram that + * never measures does not have one. + * @param options - the graph, the auto-size origin and the paper registry + * @returns the measurement controller owned by the graph store + */ +export function createMeasurement(options: Options) { + const { graph, autoSizeOrigin, paperStores } = options; + // `0` while nothing is measured; otherwise the latest of `lastState`, which + // only grows, so a reset followed by a pass never repeats a value. + let state = 0; + let lastState = 0; + const stateListeners = new Set<() => void>(); + const subscribe = (stateListener: () => void) => { + stateListeners.add(stateListener); + return () => { + stateListeners.delete(stateListener); + }; + }; + const stateSource: SelectorSource = { subscribe, get: () => state }; + const isMeasuredSource: SelectorSource = { subscribe, get: () => state > 0 }; + const setState = (next: number) => { + state = next; + for (const stateListener of stateListeners) stateListener(); + }; + // The sizes of all elements as one snapshot, built when it is read and + // dropped when a size changes. + // ponytail: an O(n) rebuild per size change, and only while something reads + // it; a persistent map if a diagram resizes elements every frame at scale. + let sizes: ReadonlyMap | undefined; + const sizesListeners = new Set<() => void>(); + const notifySizes = () => { + for (const sizesListener of sizesListeners) sizesListener(); + }; + const sizesSource: SelectorSource> = { + subscribe: (sizesListener) => { + sizesListeners.add(sizesListener); + return () => { + sizesListeners.delete(sizesListener); + }; + }, + get: () => { + if (!sizes) { + const next = new Map(); + for (const element of graph.getElements()) next.set(element.id, element.size()); + sizes = next; + } + return sizes; + }, + }; + /** Nothing to do until the snapshot has been read: then nobody holds a stale one. */ + const invalidateSizes = () => { + if (!sizes) return; + sizes = undefined; + simpleScheduler(notifySizes); + }; + let elementCount = 0; + const outstanding = new Set(); + let hasUndeliveredChange = false; + // Registered nodes per element: the last one is the observed one, and the + // previous one takes over again when it unmounts. + const nodesById = new Map(); + const observedByNode = new WeakMap(); + let resizeObserver: ResizeObserver | undefined; + + const deliver = () => { + if (!hasUndeliveredChange) return; + // Papers account for outstanding elements; without one nothing renders or + // measures, so there is nothing to wait for. + if (outstanding.size > 0 && paperStores.size > 0) return; + // An empty diagram has nothing measured. + if (elementCount === 0) { + if (state > 0) setState(0); + return; + } + hasUndeliveredChange = false; + lastState += 1; + setState(lastState); + }; + const scheduleDelivery = () => simpleScheduler(deliver); + const settle = (id: CellId) => { + if (outstanding.delete(id)) scheduleDelivery(); + }; + /** + * Settles an element that nothing measures any more: it is not waiting for a + * measurement either. Checked after this tick, because an effect that re-runs + * (StrictMode) registers the node again right away. + */ + const settleOnceUnobserved = (id: CellId) => + simpleScheduler(() => { + if (!nodesById.has(id)) settle(id); + }); + /** A change the subscribers are owed an event for, once nothing is outstanding. */ + const markChanged = () => { + hasUndeliveredChange = true; + scheduleDelivery(); + }; + const track = (element: dia.Element) => { + invalidateSizes(); + elementCount += 1; + outstanding.add(element.id); + markChanged(); + }; + + /** + * Whether some paper mounted the element's view and renders React content + * into it, the only way a node gets registered. + */ + const isRendered = (id: CellId) => { + for (const { renderElement, paper } of paperStores.values()) { + if (!renderElement) continue; + const view = paper.getElementView(id); + if (view?.el.isConnected && paper.getCellViewPortalNode(view)) return true; + } + return false; + }; + + const write = (model: dia.Element, layout: MeasuredLayout) => { + const { width, height, x, y } = layout; + const attributes: dia.Cell.Attributes = { size: { width, height } }; + if (x !== undefined && y !== undefined) { + attributes.position = { x, y }; + } else if (autoSizeOrigin === 'center') { + // Keep the geometric center fixed. With 'top-left' the position is not + // written: the top-left stays put and the element grows right/down. + const center = model.getCenter(); + attributes.position = { x: center.x - width / 2, y: center.y - height / 2 }; + } + model.set(attributes, { [AUTO_SIZE_OPTION]: true }); + }; + + /** + * The write a reported box asks for, or `undefined` when there is none: a + * hidden (`display: none`) or detached node reports zero, which is never + * written, and a box equal to the model's size or to the last report is a no-op. + */ + const toWrite = (observed: ObservedNode, box: ResizeObserverSize | undefined) => { + if (!box) return; + const width = roundToTwoDecimals(box.inlineSize); + const height = roundToTwoDecimals(box.blockSize); + if (width <= 0 || height <= 0) return; + const { id, transform, lastWidth = 0, lastHeight = 0 } = observed; + const model = graph.getCell(id); + if (!model?.isElement()) return; + const size = model.size(); + if (isSameSize(size.width, width) && isSameSize(size.height, height)) return; + if (isSameSize(lastWidth, width) && isSameSize(lastHeight, height)) return; + observed.lastWidth = width; + observed.lastHeight = height; + const { x, y } = model.position(); + const params = { x, y, angle: model.angle(), model, width, height, id }; + return [model, transform ? transform(params) : { width, height }] as const; + }; + + const onResize: ResizeObserverCallback = (entries) => { + const writes: Array = []; + for (const { target, borderBoxSize } of entries) { + const observed = observedByNode.get(target); + if (!observed) continue; + // Measured, whether or not a size is written: a measurement equal to the + // model's size (the application pre-sized it) writes nothing. + settle(observed.id); + const pending = toWrite(observed, borderBoxSize?.[0]); + if (pending) writes.push(pending); + } + if (writes.length === 0) return; + graph.startBatch('auto-size'); + for (const [model, layout] of writes) write(model, layout); + graph.stopBatch('auto-size'); + }; + + const startObserving = (observed: ObservedNode) => { + resizeObserver ??= new ResizeObserver(onResize); + resizeObserver.observe(observed.node, OBSERVER_OPTIONS); + observedByNode.set(observed.node, observed); + }; + const stopObserving = (observed: ObservedNode) => { + resizeObserver?.unobserve(observed.node); + observedByNode.delete(observed.node); + }; + + // An external graph may already hold elements. + for (const element of graph.getElements()) track(element); + + const listener = new mvc.Listener(); + listener.listenTo(graph, 'add', (cell: dia.Cell) => { + if (cell.isElement()) track(cell); + }); + listener.listenTo(graph, 'remove', (cell: dia.Cell) => { + if (!cell.isElement()) return; + // What is left has to be laid out again, so a removal is a change too. + invalidateSizes(); + elementCount -= 1; + outstanding.delete(cell.id); + markChanged(); + }); + listener.listenTo(graph, 'reset', (collection: mvc.Collection) => { + // The reset replaces the diagram: until its first settled pass nothing is measured. + invalidateSizes(); + elementCount = 0; + outstanding.clear(); + hasUndeliveredChange = false; + if (state > 0) setState(0); + // `reset` suppresses the per-cell `add` events. + for (const cell of collection.models) if (cell.isElement()) track(cell); + }); + listener.listenTo( + graph, + 'change:size', + (cell: dia.Cell, _size: dia.Size, changeOptions: dia.Cell.Options = {}) => { + invalidateSizes(); + if (changeOptions[AUTO_SIZE_OPTION]) { + outstanding.delete(cell.id); + markChanged(); + } else if (nodesById.has(cell.id)) { + // The measured content size overrides this resize. + warnResizeOnAutoSizedElement(cell.id); + } + } + ); + + return { + /** The measured state as a selector source. See {@link selectMeasuredState}. */ + stateSource, + /** Whether the sizes are known, as a selector source. See {@link selectIsMeasured}. */ + isMeasuredSource, + /** The element sizes, as a selector source. See {@link selectElementsSizes}. */ + sizesSource, + /** + * Sizes the element from `node`, for as long as the returned cleanup has not run. + * @param measured - the element id, its DOM node and an optional transform + * @returns cleanup that stops measuring the node + */ + observe(measured: MeasuredNode) { + const { id } = measured; + const observed: ObservedNode = { ...measured }; + const nodes = nodesById.get(id) ?? []; + const active = nodes.at(-1); + if (active) stopObserving(active); + else nodesById.set(id, nodes); + nodes.push(observed); + startObserving(observed); + return () => { + const registered = nodesById.get(id); + const index = registered?.indexOf(observed) ?? -1; + if (!registered || index === -1) return; + const wasObserved = index === registered.length - 1; + registered.splice(index, 1); + if (!wasObserved) return; + stopObserving(observed); + const previous = registered.at(-1); + if (previous) startObserving(previous); + else { + nodesById.delete(id); + settleOnceUnobserved(id); + } + }; + }, + /** + * An element's React content committed. Called after its subtree's layout + * effects, so an element that registered a node stays outstanding until + * measured, while one that nothing measures is settled now. + * @param id - the rendered element + */ + markRendered(id: CellId) { + if (!nodesById.has(id)) settle(id); + }, + /** + * 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. O(outstanding), which is + * empty between changes. + */ + settleUnrendered() { + for (const id of outstanding) { + if (!nodesById.has(id) && !isRendered(id)) settle(id); + } + }, + /** Stops listening to the graph and observing nodes. */ + destroy() { + listener.stopListening(); + resizeObserver?.disconnect(); + nodesById.clear(); + }, + }; +} + +/** Controller returned by {@link createMeasurement}. */ +export type Measurement = ReturnType; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index 9ddf68b5da..d6f6b2de4c 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -104,8 +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; + /** The measurement's `render:done` listener, kept to unsubscribe an adopted paper. */ + private readonly settleUnrendered: () => void; constructor(options: PaperStoreOptions) { const { @@ -180,10 +180,8 @@ 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); + this.settleUnrendered = graphStore.measurement.settleUnrendered; + this.paper.on('render:done', this.settleUnrendered); if (transform !== undefined) { this.paper.matrix(toSVGMatrix(transform)); @@ -273,7 +271,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); + this.paper.off('render:done', this.settleUnrendered); // 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). diff --git a/packages/joint-react/src/store/state-container.ts b/packages/joint-react/src/store/state-container.ts index b578a06fb2..b4d689041a 100644 --- a/packages/joint-react/src/store/state-container.ts +++ b/packages/joint-react/src/store/state-container.ts @@ -1,6 +1,6 @@ /* eslint-disable unicorn/prefer-spread */ import { simpleScheduler } from '../utils/scheduler'; -import { isStrictEqual } from '../utils/selector-utils'; +import { isStrictEqual } from '../selectors/selector-utils'; import type { CellId, AnyCellRecord } from '../types/cell.types'; /** diff --git a/packages/joint-react/src/utils/__tests__/scheduler.test.ts b/packages/joint-react/src/utils/__tests__/scheduler.test.ts index 29fe263ad3..041c7edc5c 100644 --- a/packages/joint-react/src/utils/__tests__/scheduler.test.ts +++ b/packages/joint-react/src/utils/__tests__/scheduler.test.ts @@ -95,4 +95,75 @@ describe('createScheduler', () => { expect(calls).toEqual(['batch-1', 'batch-2']); }); + // Regression: a callback that threw left the scheduler "flushing" forever, so + // every later callback, for every graph on the page, was silently dropped. + describe('a callback that throws', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not stop the other callbacks of the flush, nor later flushes', async () => { + const scheduler = createScheduler(); + const calls: string[] = []; + scheduler(() => { + throw new Error('boom'); + }); + scheduler(() => calls.push('same flush')); + await Promise.resolve(); + expect(calls).toEqual(['same flush']); + + scheduler(() => calls.push('later flush')); + await Promise.resolve(); + expect(calls).toEqual(['same flush', 'later flush']); + }); + + it('still surfaces the error, on a later task', async () => { + const scheduler = createScheduler(); + scheduler(() => { + throw new Error('boom'); + }); + await Promise.resolve(); + + expect(() => jest.runOnlyPendingTimers()).toThrow('boom'); + }); + }); + + // Regression: callbacks that keep scheduling more work were drained in one + // endless microtask, which freezes the page. The flush now yields. + describe('callbacks that keep scheduling each other', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('yields to the event loop and reports it, and carries on afterwards', async () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const scheduler = createScheduler(); + let runs = 0; + const reschedule = () => { + runs += 1; + if (runs < 1000) scheduler(reschedule); + }; + scheduler(reschedule); + await Promise.resolve(); + + // One flush did a bounded amount of work, then handed control back. + expect(runs).toBeGreaterThan(1); + expect(runs).toBeLessThan(1000); + expect(error).toHaveBeenCalledWith(expect.stringContaining('keeps scheduling')); + + // The rest is drained over later tasks, not lost. + for (let task = 0; task < 100 && runs < 1000; task += 1) { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + } + expect(runs).toBe(1000); + error.mockRestore(); + }); + }); }); diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts index 85ca3b68fa..0ca7c0c5c0 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -125,3 +125,39 @@ export function warnResizeOnAutoSizedElement(cellId: dia.Cell.ID): void { 'explicit size (e.g. from FreeTransform / Halo).' ); } + +/** + * Warns once when a selector made by `createSourceSelector` (for example + * `selectMeasuredState`) is called as a function. Such a selector reads the + * graph store through the hook it is passed to, so on its own it can only + * return its neutral value. Dev-only, tree-shaken in production. + */ +export function warnSourceSelectorCalled(): void { + if (process.env.NODE_ENV === 'production') return; + const key = 'source-selector-called'; + if (WARNED.has(key)) return; + WARNED.add(key); + + console.warn( + '[@joint/react] This selector reads the graph store, not the cells: pass it directly ' + + 'to useCells() or useOnCellsChange() in their all-cells form instead of calling it.' + ); +} + +/** + * Reports once that store callbacks keep scheduling each other, typically a + * change handler that changes what it listens to. The scheduler then spreads + * the work over later tasks instead of freezing the page. Dev-only, + * tree-shaken in production. + */ +export function warnSchedulerCascade(): void { + if (process.env.NODE_ENV === 'production') return; + const key = 'scheduler-cascade'; + if (WARNED.has(key)) return; + WARNED.add(key); + + console.error( + '[@joint/react] A store callback keeps scheduling more work each time it runs, ' + + 'for example a change handler that adds or removes cells. Guard it so it settles.' + ); +} diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts index eb50c4cfe1..ed63804391 100644 --- a/packages/joint-react/src/utils/scheduler.ts +++ b/packages/joint-react/src/utils/scheduler.ts @@ -1,3 +1,19 @@ +import { warnSchedulerCascade } from './dev-warnings'; + +/** Rounds of cascading callbacks one flush drains before it yields to the event loop. */ +const MAX_CASCADE_ROUNDS = 100; + +/** + * Rethrows an error on a later task, so it still surfaces (to `window.onerror` + * and the console) while the caller carries on. + * @param error - The error a callback threw. + */ +export function rethrowLater(error: unknown): void { + setTimeout(() => { + throw error; + }); +} + /** * Creates a microtask-based scheduler that batches callbacks. * @@ -17,21 +33,34 @@ export function createScheduler(): (callback: () => void) => void { // Process cascading callbacks in the same flush so React sees // all store changes as a single batched update. - while (callbacks.size > 0) { + for (let round = 0; callbacks.size > 0 && round < MAX_CASCADE_ROUNDS; round += 1) { const pending = callbacks; callbacks = new Set(); for (const callback of pending) { - callback(); + // One scheduler serves every graph on the page: a callback that throws + // must not stop the others, nor leave the scheduler flushing forever. + try { + callback(); + } catch (error) { + rethrowLater(error); + } } } flushing = false; + if (callbacks.size === 0) return; + // Callbacks keep scheduling each other (a change handler that changes what + // it listens to). Draining them here would never return and freeze the + // page, so the rest runs on a later task. + scheduled = true; + setTimeout(flush); + warnSchedulerCascade(); }; return (callback: () => void): void => { callbacks.add(callback); - // If we're already inside a flush, the while-loop will pick up + // If we're already inside a flush, the flush loop will pick up // the new callback — no need to schedule another microtask. if (scheduled || flushing) { return; diff --git a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx index 0001eebb98..3be5fb229a 100644 --- a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx @@ -5,7 +5,8 @@ import { useCellId, useGraph, useCells, - useOnElementsMeasured, + useOnCellsChange, + selectMeasuredState, linkRoutingOrthogonal, type CellRecord, type Computed, @@ -193,7 +194,9 @@ function LayoutRunner() { } }, [graph]); - useOnElementsMeasured(runLayout); + useOnCellsChange(selectMeasuredState, (measuredState) => { + if (measuredState) runLayout(); + }); return null; } diff --git a/packages/joint-react/stories/examples/automatic-layout-storage/story.tsx b/packages/joint-react/stories/examples/automatic-layout-storage/story.tsx index 318b69eb7f..76185d0437 100644 --- a/packages/joint-react/stories/examples/automatic-layout-storage/story.tsx +++ b/packages/joint-react/stories/examples/automatic-layout-storage/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Persists only the data field of each node to a JSON file, while HTMLHost measures node sizes and a tree layout recomputes positions on every measurement.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('useOnCellsChange'), canvasHeight: 640, code: codeRaw, }, diff --git a/packages/joint-react/stories/examples/automatic-layout/code.tsx b/packages/joint-react/stories/examples/automatic-layout/code.tsx index e61d3eca1e..6bad1007c6 100644 --- a/packages/joint-react/stories/examples/automatic-layout/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout/code.tsx @@ -4,7 +4,8 @@ import { useGraph, useCells, HTMLBox, - useOnElementsMeasured, + useOnCellsChange, + selectMeasuredState, type CellRecord, type ElementRecord, type Computed, @@ -44,8 +45,10 @@ function Main() { const [columns, setColumns] = useState(3); const nextIdRef = useRef(initialCells.length); - // Re-run the grid layout every time an element is (re)measured. - useOnElementsMeasured(() => layoutGrid(graph, columns)); + // Re-run the grid layout every time the element sizes settle. + useOnCellsChange(selectMeasuredState, (measuredState) => { + if (measuredState) layoutGrid(graph, columns); + }); const elementCount = useCells, number>((cells) => { let total = 0; diff --git a/packages/joint-react/stories/examples/automatic-layout/story.tsx b/packages/joint-react/stories/examples/automatic-layout/story.tsx index 809479f3d2..aa0ff0cbb8 100644 --- a/packages/joint-react/stories/examples/automatic-layout/story.tsx +++ b/packages/joint-react/stories/examples/automatic-layout/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Position measured elements into a responsive grid, re-running the layout whenever they resize.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('useOnCellsChange'), code: codeRaw, }, }, diff --git a/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx b/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx index ce7ee179e8..914796bf86 100644 --- a/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx +++ b/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx @@ -1,4 +1,4 @@ -import type { CellRecord, LinkRecord, LinkStyle, OnElementsMeasured } from '@joint/react'; +import type { CellRecord, LinkRecord, LinkStyle } from '@joint/react'; import { type ElementRecord, GraphProvider, @@ -7,14 +7,15 @@ import { SVGText, useCell, useCellId, + useCells, useGraph, useMarkup, - useOnElementsMeasured, usePaper, selectElementSize, + selectIsMeasured, linkRoutingOrthogonal, } from '@joint/react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { dia, elementTools } from '@joint/core'; import { DirectedGraph } from '@joint/layout-directed-graph'; @@ -745,8 +746,13 @@ function Main() { return !model.prop('hidden'); }, []); - const handleElementsMeasured: OnElementsMeasured = useCallback(({ isInitial, paper, graph }) => { - if (!isInitial) return; + const { graph } = useGraph(); + const { paper } = usePaper(); + const isMeasured = useCells(selectIsMeasured); + + // Lay the tree out and fit it once the element sizes are known. + useLayoutEffect(() => { + if (!isMeasured || !paper) return; runLayout(graph); addExpandTools(paper); paper.transformToFitContent({ @@ -755,9 +761,7 @@ function Main() { verticalAlign: 'middle', horizontalAlign: 'middle', }); - }, []); - - useOnElementsMeasured(handleElementsMeasured); + }, [isMeasured, paper, graph]); const renderElement = useCallback((data: FTAData) => RenderFTAElement(data), []); diff --git a/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx b/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx index 7e21827974..93ccbc1b68 100644 --- a/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx +++ b/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx @@ -5,11 +5,13 @@ import { useCell, Paper, SVGText, + useCells, useGraph, - useOnElementsMeasured, + usePaper, selectElementSize, + selectIsMeasured, } from '@joint/react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useLayoutEffect } from 'react'; // Colors — unified dark diagram palette. The status dot colors are random on // purpose: the color IS the status this demo renders. @@ -209,8 +211,12 @@ function Main() { useInterval(setRandomStatuses); - useOnElementsMeasured(({ isInitial, paper }) => { - if (!isInitial) return; + const { paper } = usePaper(); + const isMeasured = useCells(selectIsMeasured); + + // The status lists attach to the element views, which exist once the sizes are known. + useLayoutEffect(() => { + if (!isMeasured || !paper) return; for (const element of graph.getElements()) { StatusList.add(element.findView(paper), 'root', 'status', { attribute: 'status', @@ -221,7 +227,7 @@ function Main() { }); } setRandomStatuses(); - }); + }, [isMeasured, paper, graph, setRandomStatuses]); return ( diff --git a/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx b/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx index 550c384c8d..936a8e5a25 100644 --- a/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx +++ b/packages/joint-react/stories/examples/dynamic-status-icons/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Attaches a custom list highlighter to each shape and refreshes its colored status dots on an interval.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('selectIsMeasured'), canvasHeight: 240, code: codeRaw, }, diff --git a/packages/joint-react/stories/examples/element-controls/code.tsx b/packages/joint-react/stories/examples/element-controls/code.tsx index d723f33157..4160757f77 100644 --- a/packages/joint-react/stories/examples/element-controls/code.tsx +++ b/packages/joint-react/stories/examples/element-controls/code.tsx @@ -3,13 +3,14 @@ import { GraphProvider, useCell, Paper, - useOnElementsMeasured, + useCells, + usePaper, type ElementRecord, selectElementSize, - type OnElementsMeasured, + selectIsMeasured, } from '@joint/react'; import { dia, elementTools, g } from '@joint/core'; -import { useCallback } from 'react'; +import { useLayoutEffect } from 'react'; const PRIMARY = '#ED2637'; const LIGHT = '#DDE6ED'; @@ -749,12 +750,13 @@ function addElementControls(paper: dia.Paper) { // Application Components // ---------------------------------------------------------------------------- function Main() { - const handleElementsMeasured: OnElementsMeasured = useCallback(({ isInitial, paper }) => { - if (!isInitial) return; - addElementControls(paper); - }, []); + const { paper } = usePaper(); + const isMeasured = useCells(selectIsMeasured); - useOnElementsMeasured(handleElementsMeasured); + // The controls attach to the element views, which exist once the sizes are known. + useLayoutEffect(() => { + if (isMeasured && paper) addElementControls(paper); + }, [isMeasured, paper]); return ; } diff --git a/packages/joint-react/stories/examples/element-controls/story.tsx b/packages/joint-react/stories/examples/element-controls/story.tsx index 471b2c53bf..5b3fedda07 100644 --- a/packages/joint-react/stories/examples/element-controls/story.tsx +++ b/packages/joint-react/stories/examples/element-controls/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { description: 'Drag custom elementTools.Control handles to reshape SVG nodes, re-rendering each React shape from its updated element data.', - apiUrl: getAPILink('useOnElementsMeasured'), + apiUrl: getAPILink('selectIsMeasured'), code: codeRaw, canvasHeight: 640, }, diff --git a/packages/joint-react/stories/examples/flowchart/code.tsx b/packages/joint-react/stories/examples/flowchart/code.tsx index 8e9357a35f..a14562577c 100644 --- a/packages/joint-react/stories/examples/flowchart/code.tsx +++ b/packages/joint-react/stories/examples/flowchart/code.tsx @@ -2,10 +2,25 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import './index.css'; -import type { CellRecord, ElementRecord, LinkRecord, LinkLabel, TransformElementLayoutParams } from '@joint/react'; -import { GraphProvider, Paper, useMarkup, useMeasureElement, useOnElementsMeasured, linkRoutingOrthogonal } from '@joint/react'; +import type { + CellRecord, + ElementRecord, + LinkRecord, + LinkLabel, + TransformElementLayoutParams, +} from '@joint/react'; +import { + GraphProvider, + Paper, + useCells, + useMarkup, + useMeasureElement, + usePaper, + selectIsMeasured, + linkRoutingOrthogonal, +} from '@joint/react'; import { dia, highlighters, linkTools } from '@joint/core'; -import { forwardRef, useRef, useState } from 'react'; +import { forwardRef, useLayoutEffect, useRef, useState } from 'react'; const unit = 4; const bevel = 2 * unit; @@ -28,7 +43,12 @@ type ElementData = { // top-left coincides with center, and `autoSizeOrigin="center"` preserves // that center through the first DOM measurement. const flowchartElements: Array> = [ - { id: 'start', type: 'element', data: { label: 'Start', type: 'start' }, position: { x: 60, y: 40 } }, + { + id: 'start', + type: 'element', + data: { label: 'Start', type: 'start' }, + position: { x: 60, y: 40 }, + }, { id: 'addToCart', type: 'element', @@ -251,7 +271,10 @@ const flowchartLinks: LinkRecord[] = [ }, ]; -const initialCells: ReadonlyArray> = [...flowchartElements, ...flowchartLinks]; +const initialCells: ReadonlyArray> = [ + ...flowchartElements, + ...flowchartLinks, +]; interface PropsWithClick { readonly onMouseEnter?: () => void; @@ -404,15 +427,19 @@ function RenderFlowchartElement(data: Readonly) { } function Main() { - useOnElementsMeasured(({ isInitial, paper }) => { - if (!isInitial) return; - paper.transformToFitContent({ + const { paper: fitPaper } = usePaper(); + const isMeasured = useCells(selectIsMeasured); + + // Fit the content once the element sizes are known. + useLayoutEffect(() => { + if (!isMeasured || !fitPaper) return; + fitPaper.transformToFitContent({ padding: 40, useModelGeometry: true, verticalAlign: 'middle', horizontalAlign: 'middle', }); - }); + }, [isMeasured, fitPaper]); return ( { - const contentArea = graph.getBBox(); - if (!contentArea) return; + const { graph } = useGraph(); + const { paper } = usePaper(minimapId); + const measuredState = useCells(selectMeasuredState); + + // Keep the whole diagram in view each time the element sizes settle. + useLayoutEffect(() => { + const contentArea = measuredState > 0 && graph.getBBox(); + if (!contentArea || !paper) return; paper.transformToFitContent({ contentArea, verticalAlign: 'middle', horizontalAlign: 'middle', padding: 20, }); - }); + }, [measuredState, paper, graph]); return (
From 4cd0f809b5a93b8f93c27190137904baec6c2ecd Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 2 Oct 2026 19:49:10 +0700 Subject: [PATCH 7/9] refactor(tests): remove obsolete tests for unsized element measurement --- .../use-on-elements-measured-events.test.tsx | 405 ------------------ ...use-on-elements-measured-settling.test.tsx | 278 ------------ .../src/store/__tests__/graph-store.test.ts | 26 -- 3 files changed, 709 deletions(-) delete mode 100644 packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx delete mode 100644 packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx diff --git a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx deleted file mode 100644 index ac86c8f011..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ /dev/null @@ -1,405 +0,0 @@ -/** - * 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 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 }: { 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; - -/** 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[]): 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(() => { - // 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(); - - expect(harness.events).toHaveLength(0); - - reportMeasurement(harness.graph, 'b'); - await flush(); - - expect(harness.events).toHaveLength(1); - }); -}); - -// An element can be zero-sized for good, rather than briefly on its way to a -// measurement. Nothing will ever give it a size, so treating it as outstanding -// holds every later batch open and the hook stops firing altogether. -describe('useOnElementsMeasured — an element that stays zero-sized', () => { - it('delivers the seed pass with a zero-sized element in the graph', async () => { - const harness = renderGraph([plain('a'), anchor('anchor')]); - - await waitFor(() => expect(harness.events.length).toBeGreaterThan(0)); - await flush(); - - expect(harness.events).toHaveLength(1); - expect(harness.events[0].isInitial).toBe(true); - }); - - it('delivers the batch that adds a zero-sized element', async () => { - const harness = renderGraph([plain('a')]); - await settleAndClear(harness); - - act(() => { - harness.graph.addCells([plain('b'), anchor('anchor')] as never); - }); - await flush(); - - expect(harness.events).toHaveLength(1); - }); - - it('keeps delivering later batches once a zero-sized element is in the graph', async () => { - const harness = renderGraph([plain('a'), anchor('anchor')]); - await settleAndClear(harness); - - act(() => { - harness.graph.addCell(pending('b') as never); - }); - await flush(); - expect(harness.events).toHaveLength(0); - - reportMeasurement(harness.graph, 'b'); - await flush(); - - expect(harness.events).toHaveLength(1); - }); -}); 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 deleted file mode 100644 index 326e43c1fd..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-settling.test.tsx +++ /dev/null @@ -1,278 +0,0 @@ -/** - * 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/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index fa26809a77..9822eefde1 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -135,32 +135,6 @@ 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 }); - 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(); - 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', From 8cd4a8db0413a5c7ef068d8a46eab277b6d5fa70 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 2 Oct 2026 19:51:23 +0700 Subject: [PATCH 8/9] refactor(tests): rename renderMeasuredProbe to renderResizeProbe for clarity --- .../src/hooks/__tests__/use-on-elements-measured.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 84e7f1e60a..789389c238 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 @@ -178,7 +178,7 @@ describe('useOnElementsMeasured', () => { // 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) { + function renderResizeProbe(callback: jest.Mock) { let graphRef: dia.Graph | undefined; function Probe() { const store = useGraphStore(); @@ -192,7 +192,7 @@ describe('useOnElementsMeasured', () => { it('does not fire when the application resizes an element', async () => { const callback = jest.fn(); - const getElement = renderMeasuredProbe(callback); + const getElement = renderResizeProbe(callback); await waitFor(() => expect(callback).toHaveBeenCalled()); callback.mockClear(); @@ -207,7 +207,7 @@ describe('useOnElementsMeasured', () => { it('fires with isInitial=false for a measurement write', async () => { const callback = jest.fn(); - const getElement = renderMeasuredProbe(callback); + const getElement = renderResizeProbe(callback); await waitFor(() => expect(callback).toHaveBeenCalled()); callback.mockClear(); From d54387716a96623ca56b926a847e4027523d9a9d Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 6 Oct 2026 14:51:46 +0700 Subject: [PATCH 9/9] feat(story): add measured state example with dynamic layout and measurement logging --- .../stories/examples/measured-state/code.tsx | 264 ++++++++++++++++++ .../stories/examples/measured-state/story.tsx | 25 ++ 2 files changed, 289 insertions(+) create mode 100644 packages/joint-react/stories/examples/measured-state/code.tsx create mode 100644 packages/joint-react/stories/examples/measured-state/story.tsx diff --git a/packages/joint-react/stories/examples/measured-state/code.tsx b/packages/joint-react/stories/examples/measured-state/code.tsx new file mode 100644 index 0000000000..cff47f03d3 --- /dev/null +++ b/packages/joint-react/stories/examples/measured-state/code.tsx @@ -0,0 +1,264 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable sonarjs/pseudo-random -- picks a demo node to resize, not security-sensitive */ +import { useRef, useState, type ReactNode } from 'react'; +import type { dia } from '@joint/core'; +import { + GraphProvider, + HTMLBox, + Paper, + useCells, + useGraph, + useOnCellsChange, + selectIsMeasured, + selectMeasuredState, + selectElementsSizes, + type CellRecord, + type ElementRecord, +} from '@joint/react'; + +interface NodeData { + readonly label: string; + readonly isLong?: boolean; +} + +const GAP = 24; +const ROW_WIDTH = 560; +const LOG_LIMIT = 9; +const LONG_SUFFIX = ' · with a much longer label'; + +const initialCells: ReadonlyArray> = [ + { id: 'n1', type: 'element', data: { label: 'Node 1' }, position: { x: GAP, y: GAP } }, + { id: 'n2', type: 'element', data: { label: 'Node 2' }, position: { x: 140, y: GAP } }, + { id: 'n3', type: 'element', data: { label: 'Node 3' }, position: { x: 260, y: GAP } }, +]; + +/** Flows the elements left to right by their measured width, wrapping at `ROW_WIDTH`. */ +function layoutRows(graph: dia.Graph) { + let x = GAP; + let y = GAP; + let rowHeight = 0; + for (const element of graph.getElements()) { + const { width, height } = element.size(); + if (x > GAP && x + width > ROW_WIDTH) { + x = GAP; + y += rowHeight + GAP; + rowHeight = 0; + } + element.position(x, y); + x += width + GAP; + rowHeight = Math.max(rowHeight, height); + } +} + +function renderElement({ label, isLong }: NodeData) { + // No size on the records: HTMLBox measures its content and writes the size back. + return {isLong ? label + LONG_SUFFIX : label}; +} + +type Source = 'isMeasured' | 'measuredState' | 'sizes'; + +interface LogEntry { + readonly id: number; + readonly source: Source; + readonly text: string; +} + +const SOURCE_CLASS: Record = { + isMeasured: 'text-brand', + measuredState: 'text-accent', + sizes: 'text-ink', +}; + +/** + * Logs every change of the three measurement selectors. `useOnCellsChange` + * calls back without re-rendering this component; only the log state does. + */ +function useMeasurementLog(isAutoLayout: boolean) { + const { graph } = useGraph(); + const [entries, setEntries] = useState([]); + const nextIdRef = useRef(0); + + const log = (source: Source, text: string) => { + nextIdRef.current += 1; + const entry = { id: nextIdRef.current, source, text }; + setEntries((previous) => [entry, ...previous].slice(0, LOG_LIMIT)); + }; + + // 1. Once per diagram: false → true when the sizes first settle. + useOnCellsChange(selectIsMeasured, (isMeasured, previous) => { + log( + 'isMeasured', + previous === undefined ? `${isMeasured} (mount)` : `${previous} → ${isMeasured}` + ); + }); + + // 2. Every settled change: first pass, add, remove, re-measure. The place to run a layout. + useOnCellsChange(selectMeasuredState, (measuredState) => { + log('measuredState', measuredState ? `settled #${measuredState}` : '0 (nothing measured)'); + if (measuredState && isAutoLayout) layoutRows(graph); + }); + + // 3. Any size change, element by element. Moving elements never fires it. + useOnCellsChange(selectElementsSizes, (sizes, previous) => { + let changed = 0; + let kept = 0; + for (const [id, { width, height }] of sizes) { + const before = previous?.get(id); + if (before) kept += 1; + if (before?.width !== width || before.height !== height) changed += 1; + } + const removed = (previous?.size ?? 0) - kept; + log('sizes', removed > 0 ? `${removed} removed` : `${changed} of ${sizes.size} changed`); + }); + + return entries; +} + +function Controls({ onMove }: Readonly<{ onMove: () => void }>) { + const { graph, setCell, setCellData, removeCell, resetCells } = + useGraph>(); + const nextIdRef = useRef(initialCells.length); + + const addNode = () => { + nextIdRef.current += 1; + const id = `n${nextIdRef.current}`; + setCell({ id, type: 'element', data: { label: `Node ${nextIdRef.current}` } }); + }; + + const removeLast = () => { + const last = graph.getElements().at(-1); + if (last) removeCell(last); + }; + + const toggleLabel = () => { + const elements = graph.getElements(); + const element = elements[Math.floor(Math.random() * elements.length)]; + if (!element) return; + setCellData(element.id, (previous) => ({ ...previous, isLong: !previous.isLong })); + }; + + const moveAll = () => { + for (const element of graph.getElements()) element.translate(0, GAP); + onMove(); + }; + + return ( +
+ + + + + + +
+ ); +} + +function SizesTable() { + // Same map reference until a size changes, so dragging does not re-render this. + const sizes = useCells(selectElementsSizes); + if (sizes.size === 0) return

No elements.

; + return ( +
    + {[...sizes].map(([id, { width, height }]) => ( +
  • + {id} + + {Math.round(width)} × {Math.round(height)} + +
  • + ))} +
+ ); +} + +function Section({ title, children }: Readonly<{ title: string; children: ReactNode }>) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Inspector({ entries, moves }: Readonly<{ entries: readonly LogEntry[]; moves: number }>) { + const isMeasured = useCells(selectIsMeasured); + const measuredState = useCells(selectMeasuredState); + return ( + + ); +} + +function Main() { + const [isAutoLayout, setIsAutoLayout] = useState(true); + const [moves, setMoves] = useState(0); + const entries = useMeasurementLog(isAutoLayout); + + return ( +
+
+ setMoves((count) => count + 1)} /> + + +
+ +
+ ); +} + +export default function App() { + return ( + +
+ + ); +} diff --git a/packages/joint-react/stories/examples/measured-state/story.tsx b/packages/joint-react/stories/examples/measured-state/story.tsx new file mode 100644 index 0000000000..427174dac9 --- /dev/null +++ b/packages/joint-react/stories/examples/measured-state/story.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { getAPILink } from '../../utils/get-api-documentation-link'; +import Code from './code'; +import codeRaw from './code?raw'; + +const meta = { + title: 'Examples/Measured state', + component: Code, + tags: ['example'], + parameters: { + showcase: { + description: + 'Watch selectIsMeasured flip once on load, selectMeasuredState change on every settled add, remove or re-measure, and selectElementsSizes change only when a size does. Moving nodes fires nothing.', + apiUrl: getAPILink('useOnCellsChange'), + code: codeRaw, + canvasHeight: 480, + }, + }, +} satisfies Meta; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {};