From 3694337fb957e423025cc777b7c0bffb10ff1780 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 24 Sep 2026 14:19:12 +0700 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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 = {}; From d992078fea469223abc69cde1e7233ecfb63d9ce Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 22 Oct 2026 15:36:27 +0700 Subject: [PATCH 10/15] feat: introduce GraphState management and update measurement handling - Added `graph-state.ts` to manage the state of the graph, including cell records and sizing version. - Updated measurement logic in `measurement.ts` to use a versioning system instead of a state flag. - Removed the deprecated `collection-subscription.ts` utility. - Refactored various components to utilize the new `useGraphState` and `useOnGraphStateChange` hooks. - Updated examples and stories to reflect changes in state management and measurement handling. - Enhanced dev warnings for reading graph state outside of selectors. --- .changeset/react-cells-inline-selector.md | 5 + .../react-elements-measured-deprecate.md | 5 + .changeset/react-graph-state.md | 5 + .changeset/react-measurement-selectors.md | 5 + .changeset/react-on-cells-change.md | 3 +- .changeset/react-on-graph-state-change.md | 5 + .../bench/baseline-post-refactor.json | 435 +++++++++------ .../graph-provider-hot-reload.test.tsx | 12 +- .../__tests__/measurement-events.test.tsx | 20 +- .../__tests__/measurement-settling.test.tsx | 18 +- .../src/hooks/__tests__/use-cells.test.tsx | 52 ++ .../hooks/__tests__/use-graph-state.test.tsx | 495 ++++++++++++++++++ .../__tests__/use-graph-state.type.test.ts | 55 ++ .../__tests__/use-on-cells-change.test.tsx | 287 +--------- packages/joint-react/src/hooks/index.ts | 2 + packages/joint-react/src/hooks/use-cell.ts | 4 +- .../src/hooks/use-cells-state-selector.ts | 34 ++ packages/joint-react/src/hooks/use-cells.ts | 311 +---------- .../joint-react/src/hooks/use-cells.types.ts | 24 + .../joint-react/src/hooks/use-cells.utils.ts | 134 +---- .../src/hooks/use-graph-state-reader.ts | 20 + .../joint-react/src/hooks/use-graph-state.ts | 71 +++ .../src/hooks/use-on-cells-change.ts | 70 ++- .../src/hooks/use-on-elements-measured.ts | 17 +- .../src/hooks/use-on-graph-state-change.ts | 43 ++ .../src/hooks/use-watch-graph-state.ts | 74 +++ packages/joint-react/src/index.ts | 23 +- .../__tests__/source-selector.test.ts | 30 -- .../src/selectors/graph-state-selectors.ts | 67 +++ packages/joint-react/src/selectors/index.ts | 7 +- .../src/selectors/measurement-selectors.ts | 105 ---- .../src/selectors/selector-utils.ts | 2 +- .../src/selectors/source-selector.ts | 47 -- .../src/store/__tests__/graph-state.test.ts | 361 +++++++++++++ .../src/store/__tests__/graph-store.test.ts | 17 +- .../src/store/__tests__/measurement.test.ts | 10 +- packages/joint-react/src/store/graph-state.ts | 348 ++++++++++++ packages/joint-react/src/store/measurement.ts | 87 +-- .../src/utils/collection-subscription.ts | 49 -- .../joint-react/src/utils/dev-warnings.ts | 19 +- .../automatic-layout-storage/code.tsx | 8 +- .../automatic-layout-storage/story.tsx | 2 +- .../examples/automatic-layout/code.tsx | 8 +- .../examples/automatic-layout/story.tsx | 2 +- .../examples/collapsible-subtrees/code.tsx | 10 +- .../examples/dynamic-status-icons/code.tsx | 10 +- .../examples/dynamic-status-icons/story.tsx | 2 +- .../examples/element-controls/code.tsx | 10 +- .../examples/element-controls/story.tsx | 2 +- .../stories/examples/flowchart/code.tsx | 10 +- .../stories/examples/introduction/code.tsx | 9 +- .../stories/examples/measured-state/code.tsx | 84 +-- .../stories/examples/measured-state/story.tsx | 4 +- 53 files changed, 2179 insertions(+), 1360 deletions(-) create mode 100644 .changeset/react-cells-inline-selector.md create mode 100644 .changeset/react-elements-measured-deprecate.md create mode 100644 .changeset/react-graph-state.md create mode 100644 .changeset/react-measurement-selectors.md create mode 100644 .changeset/react-on-graph-state-change.md create mode 100644 packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts create mode 100644 packages/joint-react/src/hooks/use-cells-state-selector.ts create mode 100644 packages/joint-react/src/hooks/use-cells.types.ts create mode 100644 packages/joint-react/src/hooks/use-graph-state-reader.ts create mode 100644 packages/joint-react/src/hooks/use-graph-state.ts create mode 100644 packages/joint-react/src/hooks/use-on-graph-state-change.ts create mode 100644 packages/joint-react/src/hooks/use-watch-graph-state.ts delete mode 100644 packages/joint-react/src/selectors/__tests__/source-selector.test.ts create mode 100644 packages/joint-react/src/selectors/graph-state-selectors.ts delete mode 100644 packages/joint-react/src/selectors/measurement-selectors.ts delete mode 100644 packages/joint-react/src/selectors/source-selector.ts create mode 100644 packages/joint-react/src/store/__tests__/graph-state.test.ts create mode 100644 packages/joint-react/src/store/graph-state.ts delete mode 100644 packages/joint-react/src/utils/collection-subscription.ts diff --git a/.changeset/react-cells-inline-selector.md b/.changeset/react-cells-inline-selector.md new file mode 100644 index 0000000000..fc12f1aafe --- /dev/null +++ b/.changeset/react-cells-inline-selector.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - fix an inline selector that captures a changed prop returning a stale value until the next graph change diff --git a/.changeset/react-elements-measured-deprecate.md b/.changeset/react-elements-measured-deprecate.md new file mode 100644 index 0000000000..b9c946eed4 --- /dev/null +++ b/.changeset/react-elements-measured-deprecate.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useOnElementsMeasured - deprecate in favor of `useOnGraphStateChange()` with `selectGraphSizingVersion` diff --git a/.changeset/react-graph-state.md b/.changeset/react-graph-state.md new file mode 100644 index 0000000000..25422f8651 --- /dev/null +++ b/.changeset/react-graph-state.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useGraphState - add the hook to read a value selected from the graph state, the cells and the sizing version diff --git a/.changeset/react-measurement-selectors.md b/.changeset/react-measurement-selectors.md new file mode 100644 index 0000000000..af1342d36f --- /dev/null +++ b/.changeset/react-measurement-selectors.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useGraphState - add the `selectGraphSizingVersion` and `selectIsGraphSizingComplete` selectors diff --git a/.changeset/react-on-cells-change.md b/.changeset/react-on-cells-change.md index 98e65952fa..d11cfaea4b 100644 --- a/.changeset/react-on-cells-change.md +++ b/.changeset/react-on-cells-change.md @@ -2,5 +2,4 @@ "@joint/react": minor --- -useOnCellsChange - add the hook, with the `selectMeasuredState`, `selectIsMeasured` and `selectElementsSizes` selectors -`useOnElementsMeasured` is deprecated in favor of it. +useOnCellsChange - add the hook to run a callback when a value selected from the cells changes diff --git a/.changeset/react-on-graph-state-change.md b/.changeset/react-on-graph-state-change.md new file mode 100644 index 0000000000..afbec46956 --- /dev/null +++ b/.changeset/react-on-graph-state-change.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useOnGraphStateChange - add the hook to run a callback when a value selected from the graph state changes diff --git a/packages/joint-react/bench/baseline-post-refactor.json b/packages/joint-react/bench/baseline-post-refactor.json index 7a5c59c72b..2bfe38633b 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-10-07T11:22:10.233Z", + "gitSha": "d54387716a96623ca56b926a847e4027523d9a9d", "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": 6556248617.142366, + "mean": 0.00023452956491256294, + "stddev": 0.008327817193143692, "min": 0.00004099999978279811, - "max": 6.915790999999899, - "samples": 7827033 + "max": 8.916000000000167, + "samples": 4263855 } ], "hooks/full-iteration-selector/n=10": [ { "name": "full iteration + selector (10)", - "opsPerSec": 2535300828.319926, - "mean": 0.0006300311846347321, - "stddev": 0.008285664941017352, + "opsPerSec": 1968274029.1762328, + "mean": 0.0014129604597200728, + "stddev": 0.060980339228978185, "min": 0.000249999999141437, - "max": 3.906082999999853, - "samples": 1587224 + "max": 27.278291000000536, + "samples": 707734 } ], "hooks/single-id-get-selector/n=10": [ { "name": "single-ID get + selector (10)", - "opsPerSec": 9001583723.37333, - "mean": 0.00018175384538777985, - "stddev": 0.006982715983930184, + "opsPerSec": 7385289640.709777, + "mean": 0.0003123684188657082, + "stddev": 0.04007778247984978, "min": 0, - "max": 9.703125, - "samples": 5501981 + "max": 61.599458999999115, + "samples": 3201348 } ], "hooks/shallow-map-comparison/n=10": [ { "name": "shallow Map equal (10)", - "opsPerSec": 5260348561.022442, - "mean": 0.00026741772974151634, - "stddev": 0.018586417085768542, + "opsPerSec": 4238575878.143033, + "mean": 0.0003384518542084552, + "stddev": 0.00375570076063822, "min": 0.0001249999986612238, - "max": 35.464584000001196, - "samples": 3739468 + "max": 2.5979580000021087, + "samples": 2954630 }, { "name": "shallow Map unequal (10)", - "opsPerSec": 1658734644.1832, - "mean": 0.0007927398140844134, - "stddev": 0.07175949929966026, - "min": 0.000499999998282874, - "max": 65.3604579999992, - "samples": 1261448 + "opsPerSec": 1308595896.441942, + "mean": 0.0012824915420510227, + "stddev": 0.05576496879861116, + "min": 0.0005000000019208528, + "max": 33.78308299999844, + "samples": 779740 } ], "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": 1131650963.341761, + "mean": 0.0015354642413513717, + "stddev": 0.02192373584325527, + "min": 0.0005409999976109248, + "max": 7.302957999996579, + "samples": 651269 } ], "hooks/full-iteration-selector/n=100": [ { "name": "full iteration + selector (100)", - "opsPerSec": 507671227.5095935, - "mean": 0.002142448513894801, - "stddev": 0.0036657148157697955, + "opsPerSec": 423375157.5090429, + "mean": 0.0029193504533757365, + "stddev": 0.012919020641110227, "min": 0.0017499999994470272, - "max": 0.9238330000007409, - "samples": 466757 + "max": 3.4670420000002196, + "samples": 342542 } ], "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": 8162931495.12325, + "mean": 0.0001604103687028734, + "stddev": 0.00290308409977465, + "min": 0, + "max": 2.9510000000009313, + "samples": 6234011 } ], "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": 463292029.39763564, + "mean": 0.0030162655428061145, + "stddev": 0.016924350277044167, + "min": 0.0014999999984866008, + "max": 3.4919169999993755, + "samples": 331536 }, { "name": "shallow Map unequal (100)", - "opsPerSec": 214259776.51406443, - "mean": 0.004807214665830148, - "stddev": 0.008679019293239498, - "min": 0.004249999998137355, - "max": 3.7966250000026776, - "samples": 208021 + "opsPerSec": 160138706.77104205, + "mean": 0.008095239609820813, + "stddev": 0.01682626375284981, + "min": 0.004666000000725035, + "max": 2.1466250000012224, + "samples": 123530 } ], "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": 103131103.36629061, + "mean": 0.01784015752666576, + "stddev": 0.18370540093427898, + "min": 0.006207999998878222, + "max": 25.36804200000188, + "samples": 56054 } ], "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": 44071741.22043168, + "mean": 0.02847489088841572, + "stddev": 0.032626964022938576, + "min": 0.01704200000676792, + "max": 2.172292000002926, + "samples": 35120 } ], "hooks/single-id-get-selector/n=1000": [ { "name": "single-ID get + selector (1000)", - "opsPerSec": 9166420604.692993, - "mean": 0.0001251065802928049, - "stddev": 0.0007809810394888233, + "opsPerSec": 7494905904.541577, + "mean": 0.00020464511753626058, + "stddev": 0.009736788172910328, "min": 0.00004099999932805076, - "max": 1.1806250000008731, - "samples": 7993185 + "max": 20.029332999998587, + "samples": 4886508 } ], "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": 42603991.51621076, + "mean": 0.02482474135490794, + "stddev": 0.013371336833796795, + "min": 0.020167000002402347, + "max": 0.9157500000001164, + "samples": 40283 }, { "name": "shallow Map unequal (1000)", - "opsPerSec": 19032585.013689395, - "mean": 0.05319225739362969, - "stddev": 0.01233989935991809, - "min": 0.04958299999998417, - "max": 0.5364170000029844, - "samples": 18800 + "opsPerSec": 14183407.469507407, + "mean": 0.08325856473226756, + "stddev": 0.058032716848443584, + "min": 0.0552079999979469, + "max": 1.487250000005588, + "samples": 12011 } ], "container/set-existing/n=10": [ @@ -300,88 +300,88 @@ "controlled/position-change/n=10": [ { "name": "controlled/position-change/n=10", - "opsPerSec": 157236218.1228957, - "mean": 0.006798862942264942, - "stddev": 0.00730341671823921, - "min": 0.005666999999903055, - "max": 2.183082999999897, - "samples": 147084 + "opsPerSec": 113196296.31618722, + "mean": 0.012096132001306235, + "stddev": 0.05266348743780421, + "min": 0.0062499999999090505, + "max": 8.023916999999983, + "samples": 82681 } ], "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": 4249730.372423904, + "mean": 0.5341643718700031, + "stddev": 1.0457211286649342, + "min": 0.13179200000013225, + "max": 14.849542000000383, + "samples": 1877 } ], "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": 86104747.84221543, + "mean": 0.05447658396344459, + "stddev": 1.126389269917178, + "min": 0.006207999999787717, + "max": 132.8043749999997, + "samples": 18383 } ], "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": 216204.6657581864, + "mean": 6.113782286585326, + "stddev": 4.666260656453332, + "min": 1.486041999999543, + "max": 51.7681249999996, + "samples": 164 } ], "controlled/position-change/n=1000": [ { "name": "controlled/position-change/n=1000", - "opsPerSec": 82319389.7423361, - "mean": 0.03426556061540249, - "stddev": 0.28126870447489927, - "min": 0.006374999999025022, - "max": 28.474333000000115, - "samples": 29184 + "opsPerSec": 88458401.87573092, + "mean": 0.03709406182356622, + "stddev": 0.34100924245857234, + "min": 0.006332999997539446, + "max": 35.74091699999917, + "samples": 26980 } ], "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": 26222.087584254376, + "mean": 46.21684115624997, + "stddev": 21.94959092269449, + "min": 19.28112499999952, + "max": 111.38929199999984, "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": 91391916.0629433, + "mean": 0.029337366654128512, + "stddev": 0.6127954626612058, + "min": 0.006375000000844011, + "max": 97.87212500000169, + "samples": 34553 } ], "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": 3587.5671343811496, + "mean": 303.96338868749956, + "stddev": 111.90897378789431, + "min": 187.4635419999977, + "max": 814.263291999996, "samples": 64 } ], @@ -504,59 +504,158 @@ "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": 107609283.24664287, + "mean": 0.02093810967336781, + "stddev": 0.2789501607228554, + "min": 0.005915999999160704, + "max": 44.07458399999996, + "samples": 47760 }, { "name": "graphProjection (100)", - "opsPerSec": 82146180.94625443, - "mean": 0.03185789700541546, - "stddev": 0.23653690890482493, - "min": 0.006292000000030384, - "max": 24.838708000001134, - "samples": 31390 + "opsPerSec": 84080057.26747039, + "mean": 0.0516581344377278, + "stddev": 0.8099268649799337, + "min": 0.006332999999358435, + "max": 66.44162499999948, + "samples": 19377 }, { "name": "GraphStore (100)", - "opsPerSec": 107824652.24534024, - "mean": 0.01614418052371774, - "stddev": 0.07231301767024387, - "min": 0.006124999999883585, - "max": 8.082916999999725, - "samples": 61942 + "opsPerSec": 83124880.42311327, + "mean": 0.05278206882718829, + "stddev": 0.896654491412595, + "min": 0.006332999999358435, + "max": 99.53816700000061, + "samples": 18946 } ], "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": 93875538.54213212, + "mean": 0.024028237303187563, + "stddev": 0.3434448882178457, + "min": 0.0061659999992116354, + "max": 61.49725000000035, + "samples": 41664 }, { "name": "graphProjection (1000)", - "opsPerSec": 110544984.6762098, - "mean": 0.016250856089156912, - "stddev": 0.09428158912855929, - "min": 0.006457999999838648, - "max": 16.033792000000176, - "samples": 61552 + "opsPerSec": 69463185.8067489, + "mean": 0.03024306889258245, + "stddev": 0.28629066396737934, + "min": 0.006832999999460299, + "max": 30.91216700000041, + "samples": 33095 }, { "name": "GraphStore (1000)", - "opsPerSec": 116700306.81772408, - "mean": 0.013303929876136874, - "stddev": 0.050927563796028565, - "min": 0.006207999998878222, - "max": 5.420540999999503, - "samples": 75167 + "opsPerSec": 89060758.42007807, + "mean": 0.023937887612211745, + "stddev": 0.23692027964257345, + "min": 0.006667000001471024, + "max": 38.05083399999785, + "samples": 41775 + } + ], + "container/batchSet-1-changed/n=100": [ + { + "name": "container/batchSet-1-changed/n=100", + "opsPerSec": 3307328129.9137573, + "mean": 0.0003734778539092256, + "stddev": 0.0039058962956780054, + "min": 0.00016599999980826396, + "max": 2.560666000000083, + "samples": 1338769 + } + ], + "container/batchSet-100-changed/n=100": [ + { + "name": "container/batchSet-100-changed/n=100", + "opsPerSec": 82714757.26902202, + "mean": 0.022789107385302865, + "stddev": 0.10266788730375488, + "min": 0.008125000000291038, + "max": 4.829916000000139, + "samples": 21949 + } + ], + "container/batchSet-mixed/n=100": [ + { + "name": "container/batchSet-mixed/n=100", + "opsPerSec": 26797819.513413787, + "mean": 0.3569338400566364, + "stddev": 1.9072262683210357, + "min": 0.01412499999969441, + "max": 63.6284999999998, + "samples": 1413 + } + ], + "container/batchSet-1-changed/n=1000": [ + { + "name": "container/batchSet-1-changed/n=1000", + "opsPerSec": 2507027600.7019525, + "mean": 0.0013003174772832592, + "stddev": 0.051883720456374315, + "min": 0.00016599999980826396, + "max": 20.24591700000019, + "samples": 384522 + } + ], + "container/batchSet-100-changed/n=1000": [ + { + "name": "container/batchSet-100-changed/n=1000", + "opsPerSec": 54788281.72938199, + "mean": 0.05642775815370605, + "stddev": 0.5859758556978961, + "min": 0.009041999999681138, + "max": 50.92291600000044, + "samples": 8861 + } + ], + "container/batchSet-mixed/n=1000": [ + { + "name": "container/batchSet-mixed/n=1000", + "opsPerSec": 5762391.921159412, + "mean": 0.3277797578534037, + "stddev": 0.9640277635962766, + "min": 0.08808300000055169, + "max": 17.855333999999857, + "samples": 1528 + } + ], + "container/batchSet-1-changed/n=10000": [ + { + "name": "container/batchSet-1-changed/n=10000", + "opsPerSec": 2313850593.0140386, + "mean": 0.0019721223543984786, + "stddev": 0.11634848443854251, + "min": 0.00016599999980826396, + "max": 27.71191700000054, + "samples": 253534 + } + ], + "container/batchSet-100-changed/n=10000": [ + { + "name": "container/batchSet-100-changed/n=10000", + "opsPerSec": 55329563.3012874, + "mean": 0.058109681944754896, + "stddev": 0.6672748997250095, + "min": 0.00908299999900919, + "max": 35.277500000000146, + "samples": 8618 + } + ], + "container/batchSet-mixed/n=10000": [ + { + "name": "container/batchSet-mixed/n=10000", + "opsPerSec": 255345.1586641086, + "mean": 10.173419984374988, + "stddev": 17.45952996693197, + "min": 1.3679169999995793, + "max": 99.62012500000128, + "samples": 64 } ] } 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 316638df26..8bf304908b 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,7 @@ 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 UseOnCellsChangeModule from '../../../hooks/use-on-cells-change'; +import type * as UseOnGraphStateChangeModule from '../../../hooks/use-on-graph-state-change'; import type * as SelectorsModule from '../../../selectors'; import type * as GraphProviderModule from '../graph-provider'; import type * as PaperModule from '../../paper/paper'; @@ -89,10 +89,10 @@ 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 { - useOnCellsChange, -}: typeof UseOnCellsChangeModule = require('../../../hooks/use-on-cells-change'); + useOnGraphStateChange, +}: typeof UseOnGraphStateChangeModule = require('../../../hooks/use-on-graph-state-change'); const { - selectMeasuredState, + selectGraphSizingVersion, }: typeof SelectorsModule = require('../../../selectors'); const graphProviderV1: typeof GraphProviderModule = require('../graph-provider'); @@ -195,8 +195,8 @@ function Probe() { mountSequence += 1; return mountSequence; }); - useOnCellsChange(selectMeasuredState, (version, previousMeasuredState) => { - if (version) measuredCalls.push(!previousMeasuredState); + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) measuredCalls.push(!previousSizingVersion); }); const ids = useCellIds(); return h( diff --git a/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx index 28de36de40..7381735eb1 100644 --- a/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx @@ -1,6 +1,6 @@ /** - * Specification for when the measurement version (`selectMeasuredState`) - * changes, written as the events a `useOnCellsChange` subscriber receives. It + * Specification for when the measurement version (`selectGraphSizingVersion`) + * changes, written as the events a `useOnGraphStateChange` subscriber receives. It * was written for the former `useOnElementsMeasured` hook; the cases and their * expectations are unchanged. * @@ -26,11 +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 { useOnCellsChange } from '../use-on-cells-change'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; import { useGraphStore } from '../use-graph-store'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; import { AUTO_SIZE_OPTION } from '../../store/measurement'; -import { selectMeasuredState } from '../../selectors'; +import { selectGraphSizingVersion } from '../../selectors'; import type { CellRecord } from '../../types/cell.types'; import type { dia } from '@joint/core'; @@ -107,8 +107,8 @@ function renderGraph(initialCells: CellRecord[]): Harness { graph = currentGraph; // 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 }); + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) events.push({ isInitial: !previousSizingVersion }); }); return null; } @@ -143,7 +143,7 @@ async function settleAndClear(harness: Harness) { harness.events.length = 0; } -describe('selectMeasuredState — one event per settled change', () => { +describe('selectGraphSizingVersion — one event per settled change', () => { it('delivers one event for the seed pass', async () => { const harness = renderGraph([plain('a')]); @@ -263,7 +263,7 @@ describe('selectMeasuredState — one event per settled change', () => { }); }); -describe('selectMeasuredState — a graph reset starts a new measurement history', () => { +describe('selectGraphSizingVersion — 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. @@ -320,7 +320,7 @@ describe('selectMeasuredState — a graph reset starts a new measurement history // 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('selectMeasuredState — sizes written by the application', () => { +describe('selectGraphSizingVersion — 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 () => { @@ -370,7 +370,7 @@ describe('selectMeasuredState — 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('selectMeasuredState — an element that stays zero-sized', () => { +describe('selectGraphSizingVersion — 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__/measurement-settling.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx index 7a16f69c4b..a2cc3619ec 100644 --- a/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/measurement-settling.test.tsx @@ -9,9 +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 { useOnCellsChange } from '../use-on-cells-change'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; import { useGraphStore } from '../use-graph-store'; -import { selectMeasuredState } from '../../selectors'; +import { selectGraphSizingVersion } 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'; @@ -90,8 +90,8 @@ function renderGraph(initialCells: CellRecord[], paperProps: Partial graph = currentGraph; // 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 }); + useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => { + if (version) events.push({ isInitial: !previousSizingVersion }); }); return null; } @@ -119,7 +119,7 @@ async function settleAndClear(harness: Harness) { // measurer, it becomes outstanding then. const hideAnchor: PaperProps['cellVisibility'] = ({ model }) => model.id !== 'anchor'; -describe('selectMeasuredState — an element the paper does not render', () => { +describe('selectGraphSizingVersion — 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 }); @@ -143,7 +143,7 @@ describe('selectMeasuredState — an element the paper does not render', () => { }); // What else ends the wait: the waiting element leaves the graph. -describe('selectMeasuredState — a waiting element is removed', () => { +describe('selectGraphSizingVersion — 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); @@ -165,7 +165,7 @@ describe('selectMeasuredState — 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', () => { +describe('selectGraphSizingVersion — 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); @@ -192,7 +192,7 @@ describe('selectMeasuredState — a waiting element stops measuring', () => { }); // A removal changes what a layout has to arrange, so it is a settled change too. -describe('selectMeasuredState — a settled element is removed', () => { +describe('selectGraphSizingVersion — a settled element is removed', () => { it('delivers one event for the removal', async () => { const harness = renderGraph([plain('a'), plain('b')]); await settleAndClear(harness); @@ -210,7 +210,7 @@ describe('selectMeasuredState — a settled element is removed', () => { // 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('selectMeasuredState — the content of an element changes', () => { +describe('selectGraphSizingVersion — the content of an element changes', () => { class TestResizeObserver { static readonly instances: TestResizeObserver[] = []; readonly observed = new Set(); diff --git a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx index 69593db8d4..867cf5917c 100644 --- a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx @@ -1310,3 +1310,55 @@ describe('useCells (selector reactivity — no collection)', () => { expect(renderedIds).toEqual(['a', 'b']); }); }); + +describe('useCells — what a re-render changes', () => { + const element = (id: string | number, x: number): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + const cells: readonly CellRecord[] = [element(1, 100), element('1', 200), element('a|b', 300)]; + const selectXs = (picked: readonly CellRecord[]) => + picked.map((cell) => (cell as { readonly position?: { readonly x: number } }).position?.x); + function cellsWrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; + } + + // Regression: ids were joined into the key that says "same target", so the + // number 1 and the string '1' were one target and the hook kept the first. + it('tells a numeric id from the same id as a string', () => { + const { result, rerender } = renderHook( + ({ ids }: { readonly ids: ReadonlyArray }) => useCells(ids, selectXs), + { wrapper: cellsWrapper, initialProps: { ids: [1] as ReadonlyArray } } + ); + expect(result.current).toEqual([100]); + + rerender({ ids: ['1'] }); + + expect(result.current).toEqual([200]); + }); + + // Regression: the selector was read through a ref and run on a store change + // only, so an inline selector returned what the previous props selected. + it('runs an inline selector that captured a changed prop, without a graph change', () => { + const { result, rerender } = renderHook( + ({ minimumX }: { readonly minimumX: number }) => + useCells( + (all) => + all.filter( + (cell) => + ((cell as { readonly position?: { readonly x: number } }).position?.x ?? 0) >= + minimumX + ).length + ), + { wrapper: cellsWrapper, initialProps: { minimumX: 0 } } + ); + expect(result.current).toBe(3); + + rerender({ minimumX: 250 }); + + expect(result.current).toBe(1); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx new file mode 100644 index 0000000000..eb9c098ea5 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx @@ -0,0 +1,495 @@ +import React, { Suspense, startTransition, useState } from 'react'; +import { render, renderHook, act, screen } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { useGraphState } from '../use-graph-state'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { DEFAULT_CELL_NAMESPACE } from '../../store/graph-store'; +import { selectIsGraphSizingComplete, selectGraphSizingVersion } from '../../selectors'; +import type { GraphState } from '../../store/graph-state'; +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 = (state: GraphState) => state.cells.length; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const selectXOf = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectAX = selectXOf('a'); +const noop = () => {}; +const selectCountOrThrow = (state: GraphState) => { + if (state.cells.length > 2) throw new Error('too many cells'); + return state.cells.length; +}; +// Custom selectors derived from the shipped ones: plain functions compose. +const selectHasSettledTwice = (state: GraphState) => selectGraphSizingVersion(state) > 1; +const selectMeasuredCount = (state: GraphState) => + selectIsGraphSizingComplete(state) ? state.cells.length : 0; + +/** Mounts a hook next to a handle on the graph, counting renders. */ +function mount(useHook: () => Result) { + let renderCount = 0; + const view = renderHook( + () => { + renderCount += 1; + return { value: useHook(), graph: useGraphStore().graph }; + }, + { wrapper } + ); + const getElement = (id: string) => view.result.current.graph.getCell(id) as dia.Element; + return { + ...view, + value: () => view.result.current.value, + graph: () => view.result.current.graph, + getElement, + renders: () => renderCount, + }; +} + +/** The calls made since mount, without StrictMode's repeated mount call. */ +const changesOf = (onChange: jest.Mock) => + onChange.mock.calls.filter(([, previous]) => previous !== undefined); + +describe('useGraphState', () => { + it('selects from the cells and re-renders when the selected value changes', async () => { + const { value, graph } = mount(() => useGraphState(selectCount)); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + + expect(value()).toBe(3); + }); + + it('does not re-render for a change that leaves the selected value equal', async () => { + const { getElement, renders } = mount(() => useGraphState(selectCount)); + const rendersBefore = renders(); + + await commit(() => getElement('a').position(30, 30)); + + expect(renders()).toBe(rendersBefore); + }); + + it('reads one cell without running its selector when another cell changes', async () => { + const selector = jest.fn(selectAX); + const { value, getElement, renders } = mount(() => useGraphState(selector)); + const rendersBefore = renders(); + const callsBefore = selector.mock.calls.length; + + await commit(() => getElement('b').position(70, 0)); + expect(selector).toHaveBeenCalledTimes(callsBefore); + expect(renders()).toBe(rendersBefore); + + await commit(() => getElement('a').position(30, 0)); + expect(value()).toBe(30); + }); + + it('keeps the selected reference stable across re-renders and equal results', async () => { + const { value, getElement, rerender } = mount(() => useGraphState(selectIds)); + const before = value(); + + rerender(); + await commit(() => getElement('a').position(30, 0)); + + expect(value()).toBe(before); + }); + + it('uses the custom equality to decide what a change is', async () => { + const { value, graph } = mount(() => useGraphState(selectCount, isSameParity)); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('e'))); + expect(value()).toBe(5); + }); + + // The selector is part of what the hook renders: a new one (a changed prop + // captured by an inline selector) is read at once, not on the next store change. + it('runs a new selector on re-render, without a store change', () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId }; + }, + { wrapper } + ); + expect(result.current.x).toBe(0); + + act(() => result.current.setId('b')); + + expect(result.current.x).toBe(50); + }); + + it('follows the cell a re-rendered selector reads, and drops the previous one', async () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + const { graph } = useGraphStore(); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId, graph }; + }, + { wrapper } + ); + act(() => result.current.setId('b')); + + await commit(() => (result.current.graph.getCell('b') as dia.Element).position(70, 0)); + + expect(result.current.x).toBe(70); + }); + + // Regression: a consumer mounted after the graph settled has to see the + // settled state on its first render, with no change to wake it. + it('reads the current state when mounted after the graph settled', async () => { + const { result } = renderHook( + () => { + const [isShown, setIsShown] = useState(false); + return { isShown, setIsShown }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isShown).toBe(false); + + const late = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + + expect(late.value()).toBe(true); + }); + + describe('sizing version', () => { + it('re-renders its reader once per settled change, not per commit', async () => { + const { value, getElement, graph, renders } = mount(() => useGraphState(selectGraphSizingVersion)); + await act(async () => flush()); + expect(value()).toBe(1); + const rendersBefore = renders(); + + // A move is a commit, but not a measurement. + await commit(() => getElement('a').position(9, 9)); + expect(value()).toBe(1); + expect(renders()).toBe(rendersBefore); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(2); + }); + + it('selectIsGraphSizingComplete turns true once sizes settle and re-renders its reader only then', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + expect(value()).toBe(true); + const rendersBefore = renders(); + + // A later settled change bumps the sizing version, but the diagram stays measured. + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersBefore); + + // A reset to a diagram without sizes is not measured. + await commit(() => graph().resetCells([])); + expect(value()).toBe(false); + }); + + 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(() => useGraphState(selectIsGraphSizingComplete), { + 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); + }); + + // A selector derived from a shipped one is a selector like any other. + it('re-renders a custom selector derived from selectGraphSizingVersion only when its value changes', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectHasSettledTwice)); + await act(async () => flush()); + expect(value()).toBe(false); + const rendersBefore = renders(); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + const rendersAfterChange = renders(); + expect(rendersAfterChange).toBeGreaterThan(rendersBefore); + + // Settles a third time: the sizing version changes, the derived value does not. + await commit(() => graph().addCell(element('d'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersAfterChange); + }); + + it('combines the sizing version with the cells in one selector', async () => { + const { value, graph } = mount(() => useGraphState(selectMeasuredCount)); + await act(async () => flush()); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(3); + + await commit(() => graph().resetCells([])); + expect(value()).toBe(0); + }); + + // Regression: read through the cells, a measurement selector made every + // commit rebuild the cells array (O(n) per drag frame). A selector that + // reads only the sizing version is not even run on such a commit. + it.each([ + [ + 'useOnGraphStateChange(selectGraphSizingVersion)', + () => useOnGraphStateChange(selectGraphSizingVersion, () => {}), + ], + ['useGraphState(selectGraphSizingVersion)', () => useGraphState(selectGraphSizingVersion)], + ['useGraphState(selectIsGraphSizingComplete)', () => useGraphState(selectIsGraphSizingComplete)], + ['useGraphState(a derived selector)', () => useGraphState(selectHasSettledTwice)], + ])('%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(); + }); + }); +}); + +// Regression: the subscription followed every read, and React reads while +// rendering. A render it never commits (here a transition that suspends) moved +// the subscription away from the cell the committed tree shows. +describe('useGraphState — a render React throws away', () => { + const NEVER = new Promise(() => {}); + let liveGraph: dia.Graph | undefined; + let startSuspendedTransition: (() => void) | undefined; + + function Suspender({ isSuspended }: Readonly<{ isSuspended: boolean }>) { + // Throwing a promise is how a component suspends. + if (isSuspended) throw NEVER; + return null; + } + function PositionReader({ id }: Readonly<{ id: string }>) { + const x = useGraphState((state: GraphState) => state.getCell(id)?.position?.x); + return {String(x)}; + } + function TransitionApp() { + liveGraph = useGraphStore().graph; + const [id, setId] = useState('a'); + const [isSuspended, setIsSuspended] = useState(false); + startSuspendedTransition = () => + startTransition(() => { + setId('b'); + setIsSuspended(true); + }); + return ( + + + + + ); + } + + it('keeps following the cell the committed tree reads', async () => { + render( + + + + ); + expect(screen.getByTestId('x').textContent).toBe('0'); + + // Renders the reader with `b`, suspends, and keeps showing `a`. + await commit(() => startSuspendedTransition?.()); + expect(screen.getByTestId('x').textContent).toBe('0'); + + await commit(() => (liveGraph?.getCell('a') as dia.Element).position(33, 0)); + expect(screen.getByTestId('x').textContent).toBe('33'); + }); +}); + +describe('useOnGraphStateChange', () => { + it('calls back on mount with the current value and no previous one', () => { + const onChange = jest.fn(); + mount(() => useOnGraphStateChange(selectCount, onChange)); + + expect(onChange).toHaveBeenCalled(); + expect(onChange).toHaveBeenLastCalledWith(2); + }); + + it('calls back with the new and the previous value, without re-rendering', async () => { + const onChange = jest.fn(); + const { graph, renders } = mount(() => useOnGraphStateChange(selectCount, onChange)); + const rendersBefore = renders(); + + await commit(() => graph().addCell(element('c'))); + await commit(() => graph().getCell('a').remove()); + + expect(changesOf(onChange)).toEqual([ + [3, 2], + [2, 3], + ]); + expect(renders()).toBe(rendersBefore); + }); + + it('watches one cell, ignoring the others', async () => { + const onChange = jest.fn(); + const { getElement } = mount(() => useOnGraphStateChange(selectAX, onChange)); + + await commit(() => getElement('b').position(70, 0)); + expect(changesOf(onChange)).toEqual([]); + + await commit(() => getElement('a').position(30, 0)); + expect(changesOf(onChange)).toEqual([[30, 0]]); + }); + + // An inline selector is a new function on every render. That must not start + // the watch over, which would call back with no previous value each time. + it('does not call back again when re-rendered with an inline selector', () => { + const onChange = jest.fn(); + const { rerender } = mount(() => + useOnGraphStateChange((state) => state.cells.length, onChange) + ); + const callsBefore = onChange.mock.calls.length; + + rerender(); + rerender(); + + expect(onChange).toHaveBeenCalledTimes(callsBefore); + }); + + // A new selector may read another part of the state, which no notification + // of the previous one would ever report. + it('reads a new selector at once, calls back if the value differs, and follows what it reads', async () => { + const first = jest.fn(); + const second = jest.fn(); + const { result } = renderHook( + () => { + const [isSecond, setIsSecond] = useState(false); + useOnGraphStateChange( + isSecond ? selectXOf('b') : selectXOf('a'), + isSecond ? second : first + ); + return { setIsSecond, graph: useGraphStore().graph }; + }, + { wrapper } + ); + first.mockClear(); + + act(() => result.current.setIsSecond(true)); + expect(second).toHaveBeenLastCalledWith(50, 0); + + await commit(() => (result.current.graph.getCell('b') as dia.Element).position(70, 0)); + expect(second).toHaveBeenLastCalledWith(70, 50); + expect(first).not.toHaveBeenCalled(); + }); + + it('is 0 until the sizes settle, then changes once per settled change', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnGraphStateChange(selectGraphSizingVersion, 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('changes when a measured element is removed, so what is left can be laid out again', async () => { + const onChange = jest.fn(); + const { graph } = mount(() => useOnGraphStateChange(selectGraphSizingVersion, 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( + () => { + useOnGraphStateChange(selectGraphSizingVersion, onChange); + return { isSizingComplete: useGraphState(selectIsGraphSizingComplete), graph: useGraphStore().graph }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isSizingComplete).toBe(true); + + await commit(() => { + result.current.graph.getCell('a').remove(); + result.current.graph.getCell('b').remove(); + }); + + expect(result.current.isSizingComplete).toBe(false); + expect(onChange.mock.calls.at(-1)).toEqual([0, 1]); + }); + + // Regression: only the callback was guarded. A selector that throws inside + // the store's notification cut the other subscribers of that change off. + it('survives a selector that throws, and still notifies the other subscribers', async () => { + // The graph's microtask commit has to stay real. + jest.useFakeTimers({ doNotFake: ['queueMicrotask'] }); + const sibling = jest.fn(); + const { result } = renderHook( + () => { + useOnGraphStateChange(selectCountOrThrow, noop); + useOnGraphStateChange(selectCount, sibling); + return useGraphStore().graph; + }, + { wrapper } + ); + sibling.mockClear(); + + await commit(() => result.current.addCell(element('c'))); + + expect(sibling).toHaveBeenLastCalledWith(3, 2); + expect(() => jest.runOnlyPendingTimers()).toThrow('too many cells'); + jest.useRealTimers(); + }); + + it('stops calling back after unmount', async () => { + const onChange = jest.fn(); + const { graph, unmount } = mount(() => useOnGraphStateChange(selectCount, onChange)); + const liveGraph = graph(); + unmount(); + onChange.mockClear(); + + await commit(() => liveGraph.addCell(element('c'))); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts new file mode 100644 index 0000000000..197d4c6a47 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.type.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/** + * Type-only tests for `useGraphState` / `useOnGraphStateChange`. If the file + * compiles, the contract holds; the dummy `describe`/`it` exists only so Jest + * does not error on a test file with no test cases. + * + * Locked patterns (DX contract): + * - a shipped selector infers its return type + * - an inline selector reads the default `GraphState` + * - a selector annotated with `GraphState` types the cells it reads + * - selectors compose: a custom selector can call a shipped one + * - a graph state selector is NOT a `useCells` selector, which receives cells + */ +import { useGraphState } from '../use-graph-state'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useCells } from '../use-cells'; +import { selectIsGraphSizingComplete, selectGraphSizingVersion } from '../../selectors'; +import type { GraphState } from '../../store/graph-state'; +import type { Computed, ElementRecord } from '../../types/cell.types'; + +type MyElement = ElementRecord<{ readonly label: string }>; + +const expectType = (_value: Expected): void => {}; + +export function typeContracts(): void { + expectType(useGraphState(selectGraphSizingVersion)); + expectType(useGraphState(selectIsGraphSizingComplete)); + expectType(useGraphState((state) => state.cells.length)); + expectType(useGraphState((state) => selectGraphSizingVersion(state) > 1)); + expectType>>( + useGraphState((state: GraphState) => state.cells) + ); + expectType | undefined>( + useGraphState((state: GraphState) => state.getCell('a')) + ); + expectType>>( + useGraphState((state: GraphState) => state.getCells(['a', 'b'])) + ); + + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion, previousSizingVersion) => { + expectType(sizingVersion); + expectType(previousSizingVersion); + }); + + // @ts-expect-error -- a `useCells` selector receives the cells, not the graph state + useCells(selectGraphSizingVersion); + // @ts-expect-error -- the same in a target form + useCells('a', selectIsGraphSizingComplete); +} + +describe('useGraphState types', () => { + it('compiles', () => { + expect(typeof typeContracts).toBe('function'); + }); +}); 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 index b878c89566..437a21fdd6 100644 --- 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 @@ -1,17 +1,11 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react'; -import { dia } from '@joint/core'; +import type { 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 => @@ -37,7 +31,6 @@ const commit = (run: () => void) => }); 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; @@ -298,281 +291,3 @@ describe('useOnCellsChange', () => { 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/index.ts b/packages/joint-react/src/hooks/index.ts index 0a7a4545d0..5f13a00c29 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -10,6 +10,8 @@ export * from './use-graph-store'; export * from './use-combined-ref'; export * from './use-stores'; export * from './use-markup'; +export * from './use-graph-state'; +export * from './use-on-graph-state-change'; export * from './use-on-cells-change'; export * from './use-on-elements-measured'; export * from './use-create-portal-paper'; diff --git a/packages/joint-react/src/hooks/use-cell.ts b/packages/joint-react/src/hooks/use-cell.ts index 7582bd76b3..25dc5b36f9 100644 --- a/packages/joint-react/src/hooks/use-cell.ts +++ b/packages/joint-react/src/hooks/use-cell.ts @@ -159,8 +159,8 @@ export function useCell | Selected>( diff --git a/packages/joint-react/src/hooks/use-cells-state-selector.ts b/packages/joint-react/src/hooks/use-cells-state-selector.ts new file mode 100644 index 0000000000..1a45f365c8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells-state-selector.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import type { AnyCellRecord } from '../types/cell.types'; +import type { GraphStateSelector } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSource, CellsTarget } from './use-cells.types'; +import { toTargetKey } from './use-cells.utils'; + +/** + * The graph state selector of a cells hook: reads what `target` names and + * hands it to `selector`. The same function for as long as the target and the + * selector are. + * @param target - What to read: all cells (`undefined`), no cell (`null`), a cell id, ids or a collection. + * @param selector - Derives the value from what was read; the read itself when omitted. + * @returns The selector to pass to `useGraphState` / `useOnGraphStateChange`. + * @internal + */ +export function useCellsStateSelector( + target: CellsTarget, + selector?: AnyCellsSelector +): GraphStateSelector> { + const targetKey = toTargetKey(target); + return useMemo( + () => (state) => { + let source: CellsSource; + if (target === undefined) source = state.cells; + else if (target === null) source = undefined; + else if (typeof target === 'object') source = state.getCells(target); + else source = state.getCell(target); + return selector ? selector(source) : source; + }, + // `targetKey` stands in for `target`, see `toTargetKey`. + // eslint-disable-next-line react-hooks/exhaustive-deps + [targetKey, selector] + ); +} diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts index 0e94ce918a..67811e3a92 100644 --- a/packages/joint-react/src/hooks/use-cells.ts +++ b/packages/joint-react/src/hooks/use-cells.ts @@ -1,42 +1,12 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; 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 '../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'; -import { warnUnstableSelector } from '../utils/dev-warnings'; -import { toCellRecord } from '../state/data-mapping/cell-record-merge'; +import type { SelectedEqual } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSelector, CellsSource, CellsTarget } from './use-cells.types'; +import { useCellsStateSelector } from './use-cells-state-selector'; +import { useGraphState } from './use-graph-state'; -// ── Types ─────────────────────────────────────────────────────────────────── - -/** - * Union of all possible `useCells` return shapes (depends on argument form). - * `Cell` here is the already-resolved (Computed) record the helpers work with. - */ -type CellsResult = - | readonly Cell[] - | Cell - | undefined - | Selected; - -/** Equality function on raw `unknown` values. */ -type UnknownEqual = (a: unknown, b: unknown) => boolean; - -/** Selector over the resolved cells array (the array forms of `useCells`). */ -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. */ -export type SelectedEqual = (a: Selected, b: Selected) => boolean; +export type { SelectedEqual } from '../store/graph-state'; +export type { CellsSelector } from './use-cells.types'; /** The arguments of every `useCells` form: an optional target, a selector, an equality function. */ type CellsArguments = [ @@ -48,83 +18,6 @@ type CellsArguments = [ argument3?: SelectedEqual, ]; -// ── Module-scoped helpers ─────────────────────────────────────────────────── - -/** - * Resolves a single cell record for the collection form. Tries the graph - * container first; falls back to the collection itself for cells that exist - * outside the graph (e.g. `ui.Clipboard` clones). Fallback records are cached - * by `dia.Cell` identity so repeat renders share the same reference and the - * outer shallow-equality short-circuit can fire. - */ -function resolveCell( - id: CellId, - container: ReadonlyContainer, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): Cell | undefined { - const fromContainer = container.get(id); - if (fromContainer !== undefined) return fromContainer; - if (!collection || !fallbackCache) return undefined; - const rawCell = collection.get(id); - if (!rawCell) return undefined; - const cached = fallbackCache.get(rawCell); - if (cached !== undefined) return cached as Cell; - const record = toCellRecord(rawCell) as Cell; - fallbackCache.set(rawCell, record); - return record; -} - -/** - * Picks cells named by `subscribedIds` in order. Missing ids (not in the - * graph container and not in the optional fallback `collection`) are skipped. - */ -function pickCells( - container: ReadonlyContainer, - subscribedIds: readonly CellId[], - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): readonly Cell[] { - const picked: Cell[] = []; - for (const id of subscribedIds) { - const cell = resolveCell(id, container, collection, fallbackCache); - if (cell !== undefined) picked.push(cell); - } - return picked; -} - -/** - * Builds the next selector result for {@link useCells}. Module-scoped so the - * closure does not nest deeply inside {@link useCells}.select. - */ -function computeNext( - container: ReadonlyContainer, - targetId: CellId | undefined, - subscribedIds: readonly CellId[] | undefined, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined, - arraySelector: ((cells: readonly Cell[]) => Selected) | undefined, - cellSelector: ((cell: Cell | undefined) => Selected) | undefined -): CellsResult { - // Single-cell-with-selector form: a nullish id resolves to no cell, so the - // selector runs against `undefined` instead of falling through to all cells. - if (cellSelector) { - const cell = - targetId === undefined - ? undefined - : resolveCell(targetId, container, collection, fallbackCache); - return cellSelector(cell); - } - if (targetId !== undefined) { - return resolveCell(targetId, container, collection, fallbackCache); - } - const source: readonly Cell[] = - subscribedIds === undefined - ? container.getSnapshot() - : pickCells(container, subscribedIds, collection, fallbackCache); - return arraySelector ? arraySelector(source) : source; -} - // ── Overloads ─────────────────────────────────────────────────────────────── /** @@ -163,8 +56,8 @@ export function useCells< /** * Subscribe to the full cells array. * - * Returned array reference is stable across data-only mutations (the internal - * container mutates items in-place). Size changes produce a new snapshot token. + * The array is a new reference on every graph change; the records in it keep + * their reference until they change themselves. * @title Subscribe to all cells * @template Cell - input cell record shape (defaults to CellRecord); reads resolve to its Computed form * @returns readonly resolved cells array @@ -275,176 +168,22 @@ export function useCells< export function useCells< Cell extends AnyCellRecord = CellRecord, Selected = ReadonlyArray>, ->(...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) { +>(...args: CellsArguments): Selected | CellsSource { 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`. - const container = store.graphProjection.cells as ReadonlyContainer>; - - const collectionArgument = isCollection(argument1) ? argument1 : undefined; - - const { targetId, ids, arraySelector, cellSelector, isEqual } = parseUseCellsArgs< - Computed, - Selected - >(argument1, argument2, argument3); - const hasSelector = arraySelector !== undefined || cellSelector !== undefined; - - const arraySelectorRef = useRef(arraySelector); - arraySelectorRef.current = arraySelector; - const cellSelectorRef = useRef(cellSelector); - cellSelectorRef.current = cellSelector; - - /** Local alias for the hook's return shape so the cache type stays readable. */ - type Result = CellsResult, Selected>; - const cachedRef = useRef<{ hasValue: boolean; value: Result }>({ - hasValue: false, - value: undefined, - }); - - const idsKey = useMemo(() => (ids ? ids.join('|') : ''), [ids]); - const idsRef = useRef(ids); - idsRef.current = ids; - - // ── Collection refs (no-op when not in collection form) ── - - const collectionIdsRef = useRef([]); - const collectionVersionRef = useRef(0); - const fallbackCacheRef = useRef>(new WeakMap()); - - // Use `null` sentinel so the init-block runs on first render too, picking up - // any models that already exist in the collection at subscribe time. - const previousCollectionRef = useRef | undefined | null>(null); - if (previousCollectionRef.current !== collectionArgument) { - previousCollectionRef.current = collectionArgument; - collectionIdsRef.current = collectionArgument - ? collectionArgument.models.map((m) => m.id as CellId) - : []; - collectionVersionRef.current++; - } - - // ── 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, - container, - listener, - collectionIdsRef, - collectionVersionRef - ); - } - if (targetId !== undefined) return container.subscribeById(targetId, listener); - const subscribedIds = idsRef.current; - if (subscribedIds && subscribedIds.length > 0) { - const unsubscribers = subscribedIds.map((id) => container.subscribeById(id, listener)); - return () => { - for (const unsubscribe of unsubscribers) unsubscribe(); - }; - } - return container.subscribe(listener); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [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 - // stable between changes, so useSyncExternalStore stays consistent. - if (targetId !== undefined) return container.get(targetId); - if (collectionArgument) return collectionVersionRef.current; - // 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, 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 (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; - }, [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, - targetId, - subscribedIds, - collectionArgument, - fallbackCacheRef.current, - arraySelectorRef.current, - cellSelectorRef.current - ); - // Reuse the cached value when equal so the returned reference stays stable - // across commits that didn't affect this subscription. - if (cachedRef.current.hasValue && isEqualCallback(cachedRef.current.value, next)) { - return cachedRef.current.value; - } - if (hasSelector && cachedRef.current.hasValue) { - 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 - // safe to return as-is. - cachedRef.current = { hasValue: true, value: next }; - return next; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey, isEqualCallback, selectorSource] - ); - - return { subscribe, getSnapshot, select, isEqual: isEqualCallback }; + const isSelectorFirst = typeof argument1 === 'function'; + // The overloads pair each target with its selector shape, which the argument + // unions hide from TypeScript. + const selector = (isSelectorFirst ? argument1 : argument2) as + | AnyCellsSelector + | undefined; + const hasSelector = typeof selector === 'function'; + // A nullish id next to a selector reads no cell; on its own it reads all cells. + let target: CellsTarget; + if (!isSelectorFirst) target = argument1 ?? (hasSelector ? null : undefined); + const isEqual = (isSelectorFirst ? argument2 : argument3) as + | SelectedEqual> + | undefined; + + const select = useCellsStateSelector(target, hasSelector ? selector : undefined); + return useGraphState(select, isEqual); } diff --git a/packages/joint-react/src/hooks/use-cells.types.ts b/packages/joint-react/src/hooks/use-cells.types.ts new file mode 100644 index 0000000000..1e9a485fb8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells.types.ts @@ -0,0 +1,24 @@ +import { type mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, Computed } from '../types/cell.types'; + +/** Selector over the resolved cells array (the array forms of `useCells`). */ +export type CellsSelector = ( + cells: ReadonlyArray> +) => Selected; + +/** + * What a cells hook is scoped to: one cell, several cells, or a JointJS + * collection. `undefined` is all cells, `null` is no cell. + */ +export type CellsTarget = CellId | null | undefined | readonly CellId[] | mvc.Collection; + +/** What a target resolves to: the cells, one cell, or nothing. */ +export type CellsSource = + | ReadonlyArray> + | Computed + | undefined; + +/** A selector of any `useCells` form; the overloads pair each target with its own shape. */ +export type AnyCellsSelector = ( + source: CellsSource +) => Selected; diff --git a/packages/joint-react/src/hooks/use-cells.utils.ts b/packages/joint-react/src/hooks/use-cells.utils.ts index c1d49c2019..05a4f4fe5d 100644 --- a/packages/joint-react/src/hooks/use-cells.utils.ts +++ b/packages/joint-react/src/hooks/use-cells.utils.ts @@ -1,129 +1,13 @@ -import { type mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import { isCollection } from '../utils/is'; - -/** Normalised arguments after dispatching by the runtime call shape. */ -export interface ParsedCellsArgs { - readonly targetId: CellId | undefined; - readonly ids: readonly CellId[] | undefined; - readonly isCollectionForm: boolean; - readonly arraySelector: ((cells: readonly Cell[]) => Selected) | undefined; - readonly cellSelector: ((cell: Cell | undefined) => Selected) | undefined; - readonly isEqual: ((a: Selected, b: Selected) => boolean) | undefined; -} - -/** Builds default parsed result with no selectors. */ -function defaultParsedArgs( - targetId: CellId | undefined, - ids?: readonly CellId[] -): ParsedCellsArgs { - return { - targetId, - ids, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: undefined, - isEqual: undefined, - }; -} - -/** - * Parses the collection form: `useCells(collection)` or `useCells(collection, selector, isEqual?)`. - */ -function parseCollectionArgs( - argument2: unknown, - argument3: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: true, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: - typeof argument3 === 'function' - ? (argument3 as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} - -/** Parses the `useCells(selector, isEqual?)` form. */ -function parseSelectorFirstArgs( - selectorArgument: (cells: readonly Cell[]) => Selected, - isEqualArgument: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: false, - arraySelector: selectorArgument, - cellSelector: undefined, - isEqual: - typeof isEqualArgument === 'function' - ? (isEqualArgument as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} +import type { CellsTarget } from './use-cells.types'; /** - * Classifies the runtime arguments of {@link useCells} into a normalised shape so - * the hook body can stay flat and cheap to read. - * @param argument1 - first positional arg (id, ids, collection, or selector) - * @param argument2 - second positional arg (selector or isEqual depending on form) - * @param argument3 - third positional arg (isEqual when the form admits it) - * @returns the normalised input + * A stable stand-in for a target in a dependency list: ids passed inline are a + * new array on every render. Serialised rather than joined, so that `[1]`, + * `['1']` and the single id `'1'` stay apart. + * @param target - The target of a cells hook. + * @returns The serialised ids of an ids target, otherwise the target itself. + * @internal */ -export function parseUseCellsArgs( - argument1?: - | CellId - | null - | readonly CellId[] - | ((cells: readonly Cell[]) => Selected) - | mvc.Collection, - argument2?: - | ((cells: readonly Cell[]) => Selected) - | ((cell: Cell | undefined) => Selected) - | ((a: Selected, b: Selected) => boolean), - argument3?: (a: Selected, b: Selected) => boolean -): ParsedCellsArgs { - if (isCollection(argument1)) { - return parseCollectionArgs(argument2, argument3); - } - if (typeof argument1 === 'function') { - return parseSelectorFirstArgs(argument1, argument2); - } - - if (Array.isArray(argument1)) { - return { - targetId: undefined, - ids: argument1 as readonly CellId[], - isCollectionForm: false, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - - // Single-cell form: an explicit id, or a nullish id treated as "no match" - // (the selector then receives `undefined`). A nullish id without a selector - // collapses to the no-arg full-array form. - const targetId: CellId | undefined = - argument1 === undefined || argument1 === null ? undefined : (argument1 as CellId); - if (typeof argument2 === 'function') { - return { - targetId, - ids: undefined, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: argument2 as (cell: Cell | undefined) => Selected, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - return defaultParsedArgs(targetId); +export function toTargetKey(target: CellsTarget): unknown { + return Array.isArray(target) ? JSON.stringify(target) : target; } diff --git a/packages/joint-react/src/hooks/use-graph-state-reader.ts b/packages/joint-react/src/hooks/use-graph-state-reader.ts new file mode 100644 index 0000000000..403e3a48b8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state-reader.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useGraphStore } from './use-graph-store'; +import type { AnyCellRecord } from '../types/cell.types'; +import { createGraphStateReader, type GraphStateReader } from '../store/graph-state'; + +/** + * The reader behind {@link useGraphState} and {@link useOnGraphStateChange}, one + * per hook instance and per graph store. + * @returns The reader of the closest graph store. + * @internal + */ +export function useGraphStateReader(): GraphStateReader< + Cell, + Selected +> { + const store = useGraphStore(); + // Should React drop this memo, the new reader selects afresh: correct, at the + // cost of one new reference for a selected array or object. + return useMemo(() => createGraphStateReader(store), [store]); +} diff --git a/packages/joint-react/src/hooks/use-graph-state.ts b/packages/joint-react/src/hooks/use-graph-state.ts new file mode 100644 index 0000000000..28deacd1a8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import type { AnyCellRecord, CellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useGraphStateReader } from './use-graph-state-reader'; + +/** + * Subscribe to a value selected from the graph state: the cells and the + * sizing version. The component re-renders when the selected value changes. + * + * The hook subscribes to exactly what the selector reads. A selector that + * reads `state.getCell(id)` runs when that cell changes, one that reads + * `state.sizingVersion` when the element sizes settle, and one that reads + * `state.cells` on every graph change, including each frame of a drag. Read + * the narrowest part you need, and return a primitive or an existing record + * rather than a new object. + * + * The selector also runs when it is a new function, so define it outside the + * component, or memoise it, when it is not cheap. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @template Selected - The selector's return type. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. Defaults to a shallow, + * array-aware comparison that falls back to `Object.is` for other values. + * @returns The selected value, the same reference for as long as it is equal. + * @group Hooks + * @example + * ```tsx + * import { useLayoutEffect } from 'react'; + * import { useGraphState, usePaper, selectIsGraphSizingComplete } from '@joint/react'; + * + * // Mount inside a : fit the paper once the element sizes are known. + * function FitOnMeasure() { + * const { paper } = usePaper(); + * const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + * useLayoutEffect(() => { + * if (isSizingComplete && paper) paper.transformToFitContent({ padding: 20 }); + * }, [isSizingComplete, paper]); + * return null; + * } + * ``` + * @example + * ```tsx + * import { useGraphState, type GraphState } from '@joint/react'; + * + * // A custom selector: combine the cells with the sizing version. + * const selectIsReady = (state: GraphState) => state.sizingVersion > 0 && state.cells.length > 0; + * + * function ReadyBadge() { + * return {useGraphState(selectIsReady) ? 'ready' : 'loading'}; + * } + * ``` + */ +export function useGraphState( + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + const reader = useGraphStateReader(); + const getSnapshot = useCallback( + () => reader.read(selector, isEqual), + [reader, selector, isEqual] + ); + const selected = useSyncExternalStore(reader.subscribe, getSnapshot, getSnapshot); + // A committed selector may read other parts of the state than the one before + // it. The subscription follows it here, never while rendering: a render that + // React throws away must leave it where the committed one needs it. Declared + // after the store subscription, so on mount it runs once that is in place. + useEffect(() => { + reader.sync(selector, isEqual); + }, [reader, selector, isEqual]); + return selected; +} diff --git a/packages/joint-react/src/hooks/use-on-cells-change.ts b/packages/joint-react/src/hooks/use-on-cells-change.ts index 64c95be803..5027860e79 100644 --- a/packages/joint-react/src/hooks/use-on-cells-change.ts +++ b/packages/joint-react/src/hooks/use-on-cells-change.ts @@ -1,16 +1,17 @@ -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'; +import type { SelectedEqual } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSelector, CellsTarget } from './use-cells.types'; +import { toTargetKey } from './use-cells.utils'; +import { useCellsStateSelector } from './use-cells-state-selector'; +import { useWatchGraphState, type OnGraphStateChange } from './use-watch-graph-state'; /** * 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; +export type OnCellsChange = OnGraphStateChange; /** * Runs a callback when a value selected from the cells changes, without @@ -30,14 +31,12 @@ export type OnCellsChange = (value: Selected, previousValue?: Selected * @group Hooks * @example * ```tsx - * import { useOnCellsChange, selectMeasuredState, useGraph } from '@joint/react'; + * import { useOnCellsChange } from '@joint/react'; + * + * const selectCount = (cells: readonly unknown[]) => cells.length; * - * // Re-run a layout each time the element sizes settle. - * function AutoLayout() { - * const { graph } = useGraph(); - * useOnCellsChange(selectMeasuredState, (measuredState) => { - * if (measuredState) runLayout(graph); - * }); + * function LogCellCount() { + * useOnCellsChange(selectCount, (count, previousCount) => console.info(count, previousCount)); * return null; * } * ``` @@ -103,38 +102,29 @@ export function useOnCellsChange( ...args: | [CellsSelector, OnCellsChange, SelectedEqual?] | [ - CellId | null | undefined | readonly CellId[] | mvc.Collection, + CellsTarget, 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] = ( + // The target forms carry the selector and the callback one position later + // than the all-cells form. The overloads guarantee the shapes, which the + // union hides from TypeScript. + const [target, selector, onChange, isEqual] = ( 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]); + ? [undefined, args[0], args[1], args[2]] + : [args[0] ?? null, args[1], args[2], args[3]] + ) as [ + CellsTarget, + AnyCellsSelector, + OnCellsChange, + SelectedEqual | undefined, + ]; + useWatchGraphState( + useCellsStateSelector(target, selector), + onChange as OnCellsChange, + isEqual, + toTargetKey(target) + ); } 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 3f2dfff75a..d3620a30cc 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -49,9 +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. + * @deprecated Use {@link useOnGraphStateChange} with {@link selectGraphSizingVersion} + * to react to every settled change, or {@link useGraphState} with + * {@link selectIsGraphSizingComplete} to act once the sizes are known. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -74,9 +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. + * @deprecated Use {@link useOnGraphStateChange} with {@link selectGraphSizingVersion} + * to react to every settled change, or {@link useGraphState} with + * {@link selectIsGraphSizingComplete} 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. @@ -118,13 +118,12 @@ export function useOnElementsMeasured( useLayoutEffect(() => { if (!paperStore) return; const { paper } = paperStore; - 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() { - const state = stateSource.get(); + const state = measurement.getSizingVersion(); const isInitial = previousState === 0; previousState = state; if (state === 0) return; @@ -145,6 +144,6 @@ export function useOnElementsMeasured( // Flush any measurement that happened before subscription (e.g. initial // data sync ran before this paperStore was available). handleChanges(); - return stateSource.subscribe(handleChanges); + return measurement.subscribe(handleChanges); }, [paperStore, measurement, graph, callbackRef]); } diff --git a/packages/joint-react/src/hooks/use-on-graph-state-change.ts b/packages/joint-react/src/hooks/use-on-graph-state-change.ts new file mode 100644 index 0000000000..3d1d849bbc --- /dev/null +++ b/packages/joint-react/src/hooks/use-on-graph-state-change.ts @@ -0,0 +1,43 @@ +import type { AnyCellRecord, CellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useWatchGraphState, type OnGraphStateChange } from './use-watch-graph-state'; + +export type { OnGraphStateChange } from './use-watch-graph-state'; + +/** + * Runs a callback when a value selected from the graph state changes, without + * re-rendering the component: {@link useGraphState} 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. + * + * It takes the same selectors and equality functions as {@link useGraphState} + * and subscribes the same way, to what the selector reads. A new selector + * function is run when the component re-renders, and calls back only if it + * selects another value, so an inline selector is fine as long as it is cheap. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @template Selected - The selector's return type. + * @param selector - Derives the value to watch from the graph state. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @group Hooks + * @example + * ```tsx + * import { useOnGraphStateChange, selectGraphSizingVersion, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + * if (sizingVersion) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export function useOnGraphStateChange( + selector: GraphStateSelector, + onChange: OnGraphStateChange, + isEqual?: SelectedEqual +): void { + useWatchGraphState(selector, onChange, isEqual); +} diff --git a/packages/joint-react/src/hooks/use-watch-graph-state.ts b/packages/joint-react/src/hooks/use-watch-graph-state.ts new file mode 100644 index 0000000000..a5d6e22c19 --- /dev/null +++ b/packages/joint-react/src/hooks/use-watch-graph-state.ts @@ -0,0 +1,74 @@ +import { useLayoutEffect, useRef } from 'react'; +import type { AnyCellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useGraphStateReader } from './use-graph-state-reader'; +import { useLatestRef } from './use-latest-ref'; +import { rethrowLater } from '../utils/scheduler'; + +/** + * Callback of {@link useOnGraphStateChange}: receives the newly selected value + * and the one before it, absent on the first call. + * @group Types + */ +export type OnGraphStateChange = (value: Selected, previousValue?: Selected) => void; + +/** + * The effect behind {@link useOnGraphStateChange} and {@link useOnCellsChange}. + * The equality and the callback are read when they are needed, so neither has + * to be stable. A new selector is read at once and calls back if it selects + * another value; `watchKey` says what is watched, and a new one starts over. + * @param selector - Derives the value to watch from the graph state. + * @param onChange - Called with the new and the previous value. + * @param isEqual - Custom equality for the selected value. + * @param watchKey - Identifies what is watched; changing it runs the callback + * again with the current value and no previous one. + * @internal + */ +export function useWatchGraphState( + selector: GraphStateSelector, + onChange: OnGraphStateChange, + isEqual?: SelectedEqual, + watchKey?: unknown +): void { + const reader = useGraphStateReader(); + const selectorRef = useLatestRef(selector); + const isEqualRef = useLatestRef(isEqual); + const onChangeRef = useLatestRef(onChange); + + const checkRef = useRef<() => void>(undefined); + + useLayoutEffect(() => { + // Never called while rendering, so the subscription can follow each read. + const read = () => reader.sync(selectorRef.current, isEqualRef.current); + // `read` returns the last value while it is equal, so identity is the test. + let previous = read(); + onChangeRef.current(previous); + const check = () => { + // This may run inside the store's notification: an error, of the selector + // or of the callback, must not keep the other subscribers of this change + // from being notified. + try { + const next = read(); + if (next === previous) return; + const before = previous; + previous = next; + onChangeRef.current(next, before); + } catch (error) { + rethrowLater(error); + } + }; + checkRef.current = check; + const unsubscribe = reader.subscribe(check); + return () => { + checkRef.current = undefined; + unsubscribe(); + }; + }, [reader, watchKey, selectorRef, isEqualRef, onChangeRef]); + + // A new selector may read other parts of the state, or select another value + // from the same ones, and the store reports neither. Declared after the + // effect above, so on mount and on a new `watchKey` it finds nothing to do. + useLayoutEffect(() => { + checkRef.current?.(); + }, [selector]); +} diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 8f99a47084..661ab7222b 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -142,6 +142,22 @@ export { useOnElementsMeasured } from './hooks/use-on-elements-measured'; /** @group Types */ export type { ElementsMeasuredParams, OnElementsMeasured } from './hooks/use-on-elements-measured'; +/** + * useGraphState() + * @group Hooks + */ +export { useGraphState } from './hooks/use-graph-state'; +/** @group Types */ +export type { GraphState, GraphStateSelector } from './store/graph-state'; + +/** + * useOnGraphStateChange() + * @group Hooks + */ +export { useOnGraphStateChange } from './hooks/use-on-graph-state-change'; +/** @group Types */ +export type { OnGraphStateChange } from './hooks/use-on-graph-state-change'; + /** * useOnCellsChange() * @group Hooks @@ -200,8 +216,11 @@ export { selectCellLayer, selectCellZIndex, } from './selectors'; -/** @group Selectors */ -export { selectMeasuredState, selectIsMeasured, selectElementsSizes } from './selectors'; +/** + * Graph state selectors, pass to {@link useGraphState} / {@link useOnGraphStateChange} + * @group Selectors + */ +export { selectGraphSizingVersion, selectIsGraphSizingComplete } from './selectors'; // Data // ---- diff --git a/packages/joint-react/src/selectors/__tests__/source-selector.test.ts b/packages/joint-react/src/selectors/__tests__/source-selector.test.ts deleted file mode 100644 index b9362b3d75..0000000000 --- a/packages/joint-react/src/selectors/__tests__/source-selector.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/graph-state-selectors.ts b/packages/joint-react/src/selectors/graph-state-selectors.ts new file mode 100644 index 0000000000..08056ba837 --- /dev/null +++ b/packages/joint-react/src/selectors/graph-state-selectors.ts @@ -0,0 +1,67 @@ +import type { GraphState } from '../store/graph-state'; + +/** + * Selects the sizing version 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 to {@link useGraphState} or {@link useOnGraphStateChange}, or call it + * from your own selector. A hook that reads only the sizing version does no + * work on other graph changes (a drag, for example). + * @param state - The graph state. + * @returns The sizing version, `0` when nothing is measured yet. + * @group Selectors + * @example + * ```tsx + * import { useOnGraphStateChange, selectGraphSizingVersion, useGraph } from '@joint/react'; + * + * // Re-run a layout each time the element sizes settle. + * function AutoLayout() { + * const { graph } = useGraph(); + * useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + * if (sizingVersion) runLayout(graph); + * }); + * return null; + * } + * ``` + */ +export const selectGraphSizingVersion = (state: Pick): number => + state.sizingVersion; + +/** + * 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 selectGraphSizingVersion} to react to every later change too. + * + * Pass it to {@link useGraphState} or {@link useOnGraphStateChange}, or call it + * from your own selector. + * @param state - The graph state. + * @returns `true` once the element sizes are known. + * @group Selectors + * @example + * ```tsx + * import { useLayoutEffect } from 'react'; + * import { useGraphState, usePaper, selectIsGraphSizingComplete } from '@joint/react'; + * + * // Mount inside a : fit the paper once the sizes are known. + * function FitOnMeasure() { + * const { paper } = usePaper(); + * const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + * useLayoutEffect(() => { + * if (isSizingComplete && paper) paper.transformToFitContent({ padding: 20 }); + * }, [isSizingComplete, paper]); + * return null; + * } + * ``` + */ +export const selectIsGraphSizingComplete = (state: Pick): boolean => + state.sizingVersion > 0; diff --git a/packages/joint-react/src/selectors/index.ts b/packages/joint-react/src/selectors/index.ts index e2939c8f0e..b6d1c66e5f 100644 --- a/packages/joint-react/src/selectors/index.ts +++ b/packages/joint-react/src/selectors/index.ts @@ -1,6 +1,9 @@ -// Public selectors, passed to `useCell` / `useCells` / `useOnCellsChange`. +// Cell selectors (`selectCell*`, `selectElement*`), passed to `useCell` / +// `useCells` / `useOnCellsChange`. export * from './cell-selectors'; -export * from './measurement-selectors'; +// Graph state selectors (`select*Graph*`), passed to `useGraphState` / +// `useOnGraphStateChange`. +export * from './graph-state-selectors'; // 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 deleted file mode 100644 index 83092ccab7..0000000000 --- a/packages/joint-react/src/selectors/measurement-selectors.ts +++ /dev/null @@ -1,105 +0,0 @@ -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/selectors/selector-utils.ts b/packages/joint-react/src/selectors/selector-utils.ts index f9e3c1c728..a78c635a13 100644 --- a/packages/joint-react/src/selectors/selector-utils.ts +++ b/packages/joint-react/src/selectors/selector-utils.ts @@ -51,7 +51,7 @@ export function isPositionEqual(a?: ElementPosition, b?: ElementPosition): boole * @param b - second array * @returns true when both arrays have the same length and the same element refs */ -export function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { +function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { if (a === b) return true; if (a.length !== b.length) return false; for (const [index, value] of a.entries()) { diff --git a/packages/joint-react/src/selectors/source-selector.ts b/packages/joint-react/src/selectors/source-selector.ts deleted file mode 100644 index 991dcab63d..0000000000 --- a/packages/joint-react/src/selectors/source-selector.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/store/__tests__/graph-state.test.ts b/packages/joint-react/src/store/__tests__/graph-state.test.ts new file mode 100644 index 0000000000..fc0e481839 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/graph-state.test.ts @@ -0,0 +1,361 @@ +import { dia, mvc } from '@joint/core'; +import { GraphStore } from '../graph-store'; +import { createGraphStateReader, type GraphState } from '../graph-state'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +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 flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const selectX = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectCount = (state: GraphState) => state.cells.length; +const selectState = (state: GraphState) => state; +const selectMeasured = (state: GraphState) => state.sizingVersion; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const selectPicked = (state: GraphState) => + state.getCells(['b', 'missing', 'a']).map((cell) => cell.id); +const selectAAndLate = (state: GraphState) => state.getCells(['a', 'late']); +/** Reads the cells only once the diagram is measured. */ +const selectCountOnceMeasured = (state: GraphState) => + state.sizingVersion > 0 ? state.cells.length : -1; +const selectAndThrow = (state: GraphState) => { + state.getCell('b'); + throw new Error('selector failed'); +}; +const selectMemberIds = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection).map((cell) => cell.id); +const selectMembers = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const MANY_IDS = Array.from({ length: 50 }, (_, index) => `n${index}`); +const MANY_ELEMENTS = MANY_IDS.map((id) => element(id)); +const createStore = () => new GraphStore({ initialCells: [element('a'), element('b', 50)] }); +/** Changes the graph and drains the store's microtask commit. */ +const commit = async (run: () => void) => { + run(); + await flush(); +}; + +describe('createGraphStateReader', () => { + let store: ReturnType; + const getElement = (id: string) => store.graph.getCell(id) as dia.Element; + + beforeEach(() => { + store = createStore(); + }); + afterEach(() => store.destroy(false)); + + describe('read', () => { + it('selects from all cells, one cell, several cells and the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + + expect(reader.read(selectCount)).toBe(2); + expect(reader.read(selectX('b'))).toBe(50); + expect(reader.read(selectX('missing'))).toBeUndefined(); + expect(reader.read(selectPicked)).toEqual(['b', 'a']); + expect(reader.read(selectMeasured)).toBe(store.measurement.getSizingVersion()); + }); + + it('does not run the selector again while what it read is unchanged', async () => { + const reader = createGraphStateReader(store); + const selector = jest.fn(selectX('a')); + + reader.read(selector); + reader.read(selector); + // Another cell changed: this selector read only `a`. + await commit(() => getElement('b').position(70, 0)); + reader.read(selector); + + expect(selector).toHaveBeenCalledTimes(1); + }); + + // Regression: the render-to-subscribe gap. Staleness is tested against the + // store, so a change is seen without any subscription delivering it. + it('sees a change that happened while nothing was subscribed', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selector)).toBe(30); + }); + + it('runs a new selector function even when the store did not change', () => { + const reader = createGraphStateReader(store); + + expect(reader.read(selectX('a'))).toBe(0); + expect(reader.read(selectX('b'))).toBe(50); + }); + + it('returns the previous reference while the new value is equal', async () => { + const reader = createGraphStateReader(store); + const before = reader.read(selectIds); + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selectIds)).toBe(before); + }); + + it('uses a custom equality', async () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCells([element('c'), element('d')])); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCell(element('e'))); + expect(reader.read(selectCount, isSameParity)).toBe(5); + }); + + it('recovers after a selector that throws', () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectX('a'))).toBe(0); + + expect(() => reader.read(selectAndThrow)).toThrow('selector failed'); + expect(reader.read(selectX('a'))).toBe(0); + }); + }); + + // The reader follows what a selector reads while it runs. A state kept and + // read later would silently never update, so it says so. + it('warns, and follows nothing, when the state is read outside of a selector', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const leaked = reader.read(selectState); + reader.subscribe(listener); + + expect(leaked.getCell('a')?.id).toBe('a'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside of its selector')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('subscribe', () => { + it('subscribes only to what the selector read', async () => { + const { cells } = store.graphProjection; + const subscribeToAll = jest.spyOn(cells, 'subscribe'); + const subscribeById = jest.spyOn(cells, 'subscribeById'); + const subscribeToMeasurement = jest.spyOn(store.measurement, 'subscribe'); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + + reader.read(selectX('a')); + const unsubscribe = reader.subscribe(listener); + + expect(subscribeById).toHaveBeenCalledTimes(1); + expect(subscribeById).toHaveBeenCalledWith('a', expect.any(Function)); + expect(subscribeToAll).not.toHaveBeenCalled(); + expect(subscribeToMeasurement).not.toHaveBeenCalled(); + + await commit(() => getElement('b').position(70, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + await commit(() => getElement('a').position(40, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('is not notified of a cell change when the selector reads the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMeasured); + reader.subscribe(listener); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + + await commit(() => store.graph.addCell(element('c'))); + expect(listener).toHaveBeenCalled(); + }); + + // Regression: the render-to-subscribe gap, with the subscription in place + // by the time of the next read. Nothing notified it of the change. + it('sees a change that happened between the read and the subscription', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + reader.subscribe(jest.fn()); + + expect(reader.read(selector)).toBe(30); + }); + + it('moves the subscription when the selector reads something else', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectX('a')); + reader.subscribe(listener); + + reader.sync(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('b').position(70, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Regression: a read alone moved the subscription. React reads while + // rendering, and a render it throws away (a transition, a suspended tree) + // left the committed selector without its notifications. + it('keeps the subscription where it is on a read that does not sync', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const committed = selectX('a'); + reader.read(committed); + reader.subscribe(() => { + listener(); + reader.read(committed); + }); + + // What a discarded render does: reads with another selector, and no more. + reader.read(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(committed)).toBe(30); + }); + + it('follows a selector that reads conditionally', async () => { + const emptyStore = new GraphStore({}); + const reader = createGraphStateReader(emptyStore); + const listener = jest.fn(); + const selector = selectCountOnceMeasured; + expect(reader.read(selector)).toBe(-1); + reader.subscribe(() => { + listener(); + reader.read(selector); + }); + + // Measured now: the listener's read picks up the cells as a second source. + emptyStore.graph.addCells([element('a'), element('b')]); + await flush(); + expect(reader.read(selector)).toBe(2); + listener.mockClear(); + + emptyStore.graph.getCell('a').set('position', { x: 30, y: 0 }); + await flush(); + expect(listener).toHaveBeenCalled(); + emptyStore.destroy(false); + }); + + // Regression: every notification scanned all the watched cells, so one + // commit moving k of N watched cells cost k scans of N. + it('reads each watched cell a constant number of times when all of them change at once', async () => { + const ids = MANY_IDS; + store.graph.addCells(MANY_ELEMENTS); + await flush(); + const reader = createGraphStateReader(store); + const selector = jest.fn((state: GraphState) => state.getCells(ids)); + reader.read(selector); + // What a subscribed hook does: read again on every notification. + reader.subscribe(() => reader.read(selector)); + selector.mockClear(); + const getCell = jest.spyOn(store.graphProjection.cells, 'get'); + + await commit(() => { + store.graph.startBatch('move'); + for (const id of ids) getElement(id).position(5, 5); + store.graph.stopBatch('move'); + }); + + expect(selector).toHaveBeenCalledTimes(1); + // One lookup per notification and one per id in the selector run, not ids². + expect(getCell.mock.calls.length).toBeLessThanOrEqual(ids.length * 3); + }); + + it('is notified when a missing cell it asked for is added', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectAAndLate; + expect(reader.read(selector)).toHaveLength(1); + reader.subscribe(listener); + + await commit(() => store.graph.addCell(element('late'))); + + expect(listener).toHaveBeenCalled(); + expect(reader.read(selector)).toHaveLength(2); + }); + }); + + describe('collections', () => { + it('reads the members of a collection, in its order, and follows membership', () => { + const collection = new mvc.Collection([getElement('b')]); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual(['b']); + reader.subscribe(listener); + + collection.add(getElement('a')); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(selector)).toEqual(['b', 'a']); + + collection.remove(getElement('b')); + expect(reader.read(selector)).toEqual(['a']); + + collection.reset([]); + expect(reader.read(selector)).toEqual([]); + }); + + // Regression: membership was tracked by a counter the subscription bumped, + // so a change before the subscription existed was missed. + it('sees a membership change that happened while nothing was subscribed', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual([]); + + collection.add(getElement('a')); + + expect(reader.read(selector)).toEqual(['a']); + }); + + it('is notified once for several cells added together', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + reader.subscribe(listener); + + collection.add([getElement('a'), getElement('b')]); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('reads a member the graph does not hold from the collection, as one stable record', () => { + const detached = new dia.Element({ id: 'clone', type: ELEMENT_MODEL_TYPE }); + const collection = new mvc.Collection([detached]); + const reader = createGraphStateReader(store); + const first = reader.read(selectMembers(collection)); + const second = reader.read(selectMembers(collection)); + + expect(first.map((cell) => (cell as CellRecord).id)).toEqual(['clone']); + expect(second[0]).toBe(first[0]); + }); + + it('stops listening to the collection on unsubscribe', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + const unsubscribe = reader.subscribe(listener); + + unsubscribe(); + collection.add(getElement('a')); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); 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 9822eefde1..4b0bf64296 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -8,9 +8,8 @@ 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(); +const sizingVersion = (store: { readonly measurement: { readonly getSizingVersion: () => number } }) => + store.measurement.getSizingVersion(); describe('GraphStore', () => { describe('constructor', () => { @@ -76,7 +75,7 @@ describe('GraphStore', () => { const store = new GraphStore({ initialCells }); // simpleScheduler defers the bump to a microtask. await flush(); - expect(measuredState(store)).toBeGreaterThan(0); + expect(sizingVersion(store)).toBeGreaterThan(0); store.destroy(false); }); @@ -91,7 +90,7 @@ describe('GraphStore', () => { ]; const store = new GraphStore({ initialCells: zeroSized }); await flush(); - expect(measuredState(store)).toBe(1); + expect(sizingVersion(store)).toBe(1); store.destroy(false); const linksOnly = new GraphStore({ @@ -105,7 +104,7 @@ describe('GraphStore', () => { ], }); await flush(); - expect(measuredState(linksOnly)).toBe(0); + expect(sizingVersion(linksOnly)).toBe(0); linksOnly.destroy(false); }); @@ -116,11 +115,11 @@ describe('GraphStore', () => { const store = new GraphStore({ initialCells }); await flush(); // No paper renders it, so the seed settles on its own. - expect(measuredState(store)).toBe(1); + expect(sizingVersion(store)).toBe(1); // The application sizes the element: not a measurement. (store.graph.getCell('a') as dia.Element).resize(120, 40); await flush(); - expect(measuredState(store)).toBe(1); + expect(sizingVersion(store)).toBe(1); // The observer measures the same size, so it writes nothing to the graph. const node = document.createElement('div'); @@ -131,7 +130,7 @@ describe('GraphStore', () => { const entry = { target: node, borderBoxSize: [{ inlineSize: 120, blockSize: 40 }] }; callback([entry as unknown as ResizeObserverEntry], {} as ResizeObserver); await flush(); - expect(measuredState(store)).toBe(1); + expect(sizingVersion(store)).toBe(1); store.destroy(false); }); diff --git a/packages/joint-react/src/store/__tests__/measurement.test.ts b/packages/joint-react/src/store/__tests__/measurement.test.ts index 32c2e2c623..13ff3b2371 100644 --- a/packages/joint-react/src/store/__tests__/measurement.test.ts +++ b/packages/joint-react/src/store/__tests__/measurement.test.ts @@ -45,8 +45,8 @@ type Transform = Parameters['observe']>[0][ 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(); + /** What `selectGraphSizingVersion` reads right now. */ + const version = () => measurement.getSizingVersion(); const nodesById = new Map(); const writes = jest.fn(); graph.on('batch:stop', ({ batchName }: { batchName?: string }) => { @@ -675,16 +675,16 @@ describe('measurement — graph events', () => { paperStores: NO_PAPERS, }); await flush(); - expect(measurement.stateSource.get()).toBe(1); + expect(measurement.getSizingVersion()).toBe(1); graph.getCell('a').remove(); await flush(); // One element is left, so the diagram is still measured. - expect(measurement.stateSource.get()).toBe(2); + expect(measurement.getSizingVersion()).toBe(2); graph.getCell('b').remove(); await flush(); - expect(measurement.stateSource.get()).toBe(0); + expect(measurement.getSizingVersion()).toBe(0); }); it('does not track links', async () => { diff --git a/packages/joint-react/src/store/graph-state.ts b/packages/joint-react/src/store/graph-state.ts new file mode 100644 index 0000000000..073bc08fb6 --- /dev/null +++ b/packages/joint-react/src/store/graph-state.ts @@ -0,0 +1,348 @@ +import { mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; +import type { GraphStore } from './graph-store'; +import type { ReadonlyContainer } from './state-container'; +import { toCellRecord } from '../state/data-mapping/cell-record-merge'; +import { isCollection } from '../utils/is'; +import { warnGraphStateReadOutsideSelector, warnUnstableSelector } from '../utils/dev-warnings'; +import { arrayAwareEqual } from '../selectors/selector-utils'; + +/** + * The state of a graph, as a selector of {@link useGraphState} or + * {@link useOnGraphStateChange} sees it. Read it inside the selector only: the + * hook subscribes to exactly what the selector read, so a selector that reads + * one cell is not run when another cell changes, and one that reads + * `sizingVersion` is not run on a drag. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @group Types + * @example + * ```tsx + * import { useGraphState, type GraphState } from '@joint/react'; + * + * const selectCellCount = (state: GraphState) => state.cells.length; + * + * function CellCount() { + * return {useGraphState(selectCellCount)} cells; + * } + * ``` + */ +export interface GraphState { + /** All cells. A new array on every graph change, so a selector reading it runs on each of them. */ + readonly cells: ReadonlyArray>; + /** + * One cell by id, or `undefined` when the graph does not hold it. + * @param id - The id of the cell to read. + */ + readonly getCell: (id: CellId) => Computed | undefined; + /** + * The cells with the given ids, or the cells of a JointJS collection, in + * that order. Cells the graph does not hold are skipped, except the members + * of a collection, which are read from the collection itself. + * @param target - Cell ids, or a `mvc.Collection` of cells. + */ + readonly getCells: ( + target: readonly CellId[] | mvc.Collection + ) => ReadonlyArray>; + /** + * `0` while the element sizes are not known, otherwise a number that changes + * each time they settle. See {@link selectGraphSizingVersion}. + */ + readonly sizingVersion: number; +} + +/** + * Derives a value from the {@link GraphState}. + * @group Types + */ +export type GraphStateSelector = ( + state: GraphState +) => Selected; + +/** + * Equality test that tells whether a newly selected value is a change. + * @group Types + */ +export type SelectedEqual = (a: Selected, b: Selected) => boolean; + +const ALL_CELLS = Symbol('cells'); +const SIZING_VERSION = Symbol('sizingVersion'); +/** Membership and order of a collection; `sort` reorders without adding or removing. */ +const COLLECTION_EVENTS = 'update reset sort'; + +/** A part of the graph state a selector can read: all cells, the sizing version, a collection or one cell. */ +type Source = typeof ALL_CELLS | typeof SIZING_VERSION | mvc.Collection | CellId; + +/** The parts of a {@link GraphStore} the reader reads, whatever its cell shapes are. */ +interface GraphStateStore { + readonly graphProjection: { readonly cells: ReadonlyContainer }; + readonly measurement: Pick; +} + +interface Subscription { + readonly listener: () => void; + readonly unsubscribers: Map void>; +} + +const collectionIds = new WeakMap, readonly CellId[]>(); +const detachedRecords = new WeakMap(); + +/** + * The ids of a collection's cells, the same array for as long as the + * membership and order are. O(members) per call, nothing allocated while unchanged. + * @param collection - The collection to read. + */ +function getCollectionIds(collection: mvc.Collection): readonly CellId[] { + const { models } = collection; + const known = collectionIds.get(collection); + if (known?.length === models.length) { + let isSame = true; + for (const [index, model] of models.entries()) { + if (model.id !== known[index]) { + isSame = false; + break; + } + } + if (isSame) return known; + } + const ids = models.map((model) => model.id); + collectionIds.set(collection, ids); + return ids; +} + +/** + * The record of a collection member the graph does not hold (a `ui.Clipboard` + * clone, for example), one per cell so repeated reads share a reference. + * @param collection - The collection the cell is a member of. + * @param id - The id of the cell. + */ +function getDetachedRecord( + collection: mvc.Collection, + id: CellId +): AnyCellRecord | undefined { + const cell = collection.get(id); + if (!cell) return undefined; + let record = detachedRecords.get(cell); + if (!record) { + record = toCellRecord(cell); + detachedRecords.set(cell, record); + } + return record; +} + +/** + * Reads a selected value from a graph store and keeps track of what the + * selector read, so that it is run again, and its subscriber notified, only + * when one of those parts changes. One reader serves one hook instance. + * + * The staleness test compares what the selector saw with the store itself, not + * with what the subscription delivered, so a change that lands between a + * render and its subscription is not missed. + * @param store - The graph store to read. + * @returns `read`, `sync` and `subscribe`, all identity-stable. + * @internal + */ +export function createGraphStateReader( + store: GraphStateStore +) { + // The store holds resolved (Computed) records of the application's own cell + // shape; the `Cell` generic is how the caller declares that shape. + const cells = store.graphProjection.cells as ReadonlyContainer>; + const { measurement } = store; + // What the last selector run read, with the value it saw. + const seen = new Map(); + let subscription: Subscription | undefined; + // The selector `seen` belongs to. Unset from the start of a run until it + // returns, so a selector that throws leaves nothing to trust. + let lastSelector: GraphStateSelector | undefined; + let selected: { readonly value: Selected } | undefined; + // True only while a selector runs: the state is not to be read at any other time. + let isSelecting = false; + // True while the subscription listens to exactly what `seen` holds. Then a + // change cannot go unreported, and `isDirty` says whether one was: a read + // needs no scan of `seen`. Without it, k changed cells out of N watched ones + // cost k scans of N per commit. + let isListeningToSeen = false; + let isDirty = false; + + const current = (source: Source): unknown => { + if (source === ALL_CELLS) return cells.getSnapshot(); + if (source === SIZING_VERSION) return measurement.getSizingVersion(); + if (typeof source === 'object') return getCollectionIds(source); + return cells.get(source); + }; + + const subscribeTo = (source: Source, notify: () => void): (() => void) => { + // Compares its own source only: once a selector run has seen the new + // values, the rest of the notifications of the same commit find no change. + const listener = () => { + if (!Object.is(current(source), seen.get(source))) isDirty = true; + notify(); + }; + if (source === ALL_CELLS) return cells.subscribe(listener); + if (source === SIZING_VERSION) return measurement.subscribe(listener); + if (typeof source === 'object') { + const controller = new mvc.Listener(); + controller.listenTo(source, COLLECTION_EVENTS, listener); + return () => controller.stopListening(); + } + return cells.subscribeById(source, listener); + }; + + const hasChanged = (): boolean => { + if (isListeningToSeen && !isDirty) return false; + for (const [source, value] of seen) { + if (!Object.is(current(source), value)) return true; + } + isDirty = false; + return false; + }; + + /** Makes the subscription listen to what the last run read, and to nothing else. */ + const syncSubscription = ({ listener, unsubscribers }: Subscription): void => { + for (const [source, unsubscribe] of unsubscribers) { + if (seen.has(source)) continue; + unsubscribe(); + unsubscribers.delete(source); + } + for (const source of seen.keys()) { + if (unsubscribers.has(source)) continue; + unsubscribers.set(source, subscribeTo(source, listener)); + // It may have changed between being read and being listened to. + isDirty = true; + } + isListeningToSeen = true; + }; + + /** Records what a selector read. Outside of a selector run nothing would follow it. */ + const see = (source: Source, value: unknown): void => { + if (isSelecting) seen.set(source, value); + else warnGraphStateReadOutsideSelector(); + }; + + const getCell = (id: CellId): Computed | undefined => { + const cell = cells.get(id); + see(id, cell); + return cell; + }; + + const state: GraphState = { + get cells() { + const snapshot = cells.getSnapshot(); + see(ALL_CELLS, snapshot); + return snapshot; + }, + get sizingVersion() { + const sizingVersion = measurement.getSizingVersion(); + see(SIZING_VERSION, sizingVersion); + return sizingVersion; + }, + getCell, + getCells(target) { + let collection: mvc.Collection | undefined; + let ids: readonly CellId[]; + if (isCollection(target)) { + collection = target; + ids = getCollectionIds(target); + see(target, ids); + } else { + ids = target; + } + const picked: Array> = []; + for (const id of ids) { + // A detached record is of the application's cell shape too, like the + // records of the container above. + const cell = + getCell(id) ?? + (collection && (getDetachedRecord(collection, id) as Computed | undefined)); + if (cell !== undefined) picked.push(cell); + } + return picked; + }, + }; + + const read = ( + selector: GraphStateSelector, + isEqual: SelectedEqual = arrayAwareEqual + ): Selected => { + if (selected && selector === lastSelector && !hasChanged()) return selected.value; + lastSelector = undefined; + isListeningToSeen = false; + seen.clear(); + // Stays set if the selector throws: a read outside of a selector then goes + // unreported until the next run, which is all that is lost. + isSelecting = true; + const next = selector(state); + isSelecting = false; + lastSelector = selector; + isDirty = false; + if (selected) { + if (isEqual(selected.value, next)) return selected.value; + warnUnstableSelector('useGraphState', selected.value, next, isEqual !== arrayAwareEqual); + } + selected = { value: next }; + return next; + }; + + return { + /** + * The value `selector` selects. The selector runs when it is a new function + * or when something its last run read has changed; otherwise, and whenever + * the new value is equal to the last one, the last value is returned as is. + * + * Safe to call while rendering: it leaves the subscription alone, so a + * render React throws away cannot move it. See {@link sync}. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + read, + /** + * Reads like {@link read}, then makes the subscription listen to what that + * run read. Call it with the selector that is in effect, outside of render: + * once it has committed, or when a notification arrives. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + sync( + selector: GraphStateSelector, + isEqual?: SelectedEqual + ): Selected { + const value = read(selector, isEqual); + if (subscription && lastSelector && !isListeningToSeen) syncSubscription(subscription); + return value; + }, + /** + * Calls `listener` when something the selector read may have changed. The + * listener is expected to read again with the selector in effect; the + * subscription then moves to whatever that run read. + * @param listener - called on a change, synchronously with the store + * @returns the unsubscribe function + */ + subscribe(listener: () => void): () => void { + const own: Subscription = { + listener: () => { + listener(); + // The listener has read with the selector in effect: follow it. Not + // after unsubscribing, which the listener itself may have done. + if (subscription === own && lastSelector && !isListeningToSeen) syncSubscription(own); + }, + unsubscribers: new Map(), + }; + subscription = own; + syncSubscription(own); + return () => { + for (const unsubscribe of own.unsubscribers.values()) unsubscribe(); + own.unsubscribers.clear(); + if (subscription !== own) return; + subscription = undefined; + isListeningToSeen = false; + }; + }, + }; +} + +/** Reader returned by {@link createGraphStateReader}. */ +export type GraphStateReader = ReturnType< + typeof createGraphStateReader +>; diff --git a/packages/joint-react/src/store/measurement.ts b/packages/joint-react/src/store/measurement.ts index a11e487c6d..d70d3ee56c 100644 --- a/packages/joint-react/src/store/measurement.ts +++ b/packages/joint-react/src/store/measurement.ts @@ -3,7 +3,6 @@ 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 @@ -96,9 +95,9 @@ 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}). + * telling when sizes are settled ({@link selectGraphSizingVersion}). * - * The measured state changes once per settled change. An element is outstanding from + * The sizing version 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 @@ -112,53 +111,20 @@ const roundToTwoDecimals = (value: number) => Math.round(value * 100) / 100; */ export function createMeasurement(options: Options) { const { graph, autoSizeOrigin, paperStores } = options; - // `0` while nothing is measured; otherwise the latest of `lastState`, which + // `0` while nothing is measured; otherwise the latest of `lastVersion`, 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); + let version = 0; + let lastVersion = 0; + const versionListeners = new Set<() => void>(); + const subscribe = (versionListener: () => void) => { + versionListeners.add(versionListener); return () => { - stateListeners.delete(stateListener); + versionListeners.delete(versionListener); }; }; - 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); + const setVersion = (next: number) => { + version = next; + for (const versionListener of versionListeners) versionListener(); }; let elementCount = 0; const outstanding = new Set(); @@ -176,12 +142,12 @@ export function createMeasurement(options: Options) { if (outstanding.size > 0 && paperStores.size > 0) return; // An empty diagram has nothing measured. if (elementCount === 0) { - if (state > 0) setState(0); + if (version > 0) setVersion(0); return; } hasUndeliveredChange = false; - lastState += 1; - setState(lastState); + lastVersion += 1; + setVersion(lastVersion); }; const scheduleDelivery = () => simpleScheduler(deliver); const settle = (id: CellId) => { @@ -202,7 +168,6 @@ export function createMeasurement(options: Options) { scheduleDelivery(); }; const track = (element: dia.Element) => { - invalidateSizes(); elementCount += 1; outstanding.add(element.id); markChanged(); @@ -295,18 +260,16 @@ export function createMeasurement(options: Options) { 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); + if (version > 0) setVersion(0); // `reset` suppresses the per-cell `add` events. for (const cell of collection.models) if (cell.isElement()) track(cell); }); @@ -314,7 +277,6 @@ export function createMeasurement(options: Options) { graph, 'change:size', (cell: dia.Cell, _size: dia.Size, changeOptions: dia.Cell.Options = {}) => { - invalidateSizes(); if (changeOptions[AUTO_SIZE_OPTION]) { outstanding.delete(cell.id); markChanged(); @@ -326,12 +288,17 @@ export function createMeasurement(options: Options) { ); 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, + /** + * The sizing version: `0` while nothing is measured, otherwise a number + * that changes once per settled change. See {@link selectGraphSizingVersion}. + */ + getSizingVersion: () => version, + /** + * Calls `versionListener` each time the sizing version changes. + * @param versionListener - called synchronously with the change + * @returns the unsubscribe function + */ + subscribe, /** * 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 diff --git a/packages/joint-react/src/utils/collection-subscription.ts b/packages/joint-react/src/utils/collection-subscription.ts deleted file mode 100644 index 14d267b3e5..0000000000 --- a/packages/joint-react/src/utils/collection-subscription.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import type { ReadonlyContainer } from '../store/state-container'; - -/** - * Creates a self-managing subscription for the collection form of {@link useCells}. - * - * Subscribes to each cell ID in the container (per-ID granularity) AND to - * collection membership events (`add`/`remove`/`reset`). On membership - * change, resubscribes to the new set of IDs. - * - * All changes bump `versionRef` so `getSnapshot` returns a new value, - * causing `useSyncExternalStoreWithSelector` to re-evaluate `select`. - */ -export function subscribeToCollection( - collection: mvc.Collection, - container: ReadonlyContainer, - listener: () => void, - collectionIdsRef: { current: readonly CellId[] }, - versionRef: { current: number } -): () => void { - let cellUnsubs: Array<() => void> = []; - - const bumpAndNotify = () => { - versionRef.current++; - listener(); - }; - - const resubscribeCells = () => { - for (const unsub of cellUnsubs) unsub(); - const ids = collection.models.map((m) => m.id as CellId); - collectionIdsRef.current = ids; - cellUnsubs = ids.map((id) => container.subscribeById(id, bumpAndNotify)); - }; - - resubscribeCells(); - - const controller = new mvc.Listener(); - const onMembershipChange = () => { - resubscribeCells(); - bumpAndNotify(); - }; - controller.listenTo(collection, 'add remove reset', onMembershipChange); - - return () => { - for (const unsub of cellUnsubs) unsub(); - controller.stopListening(); - }; -} diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts index 0ca7c0c5c0..692f260a2b 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -72,7 +72,7 @@ export function warnUnstableSelector( 'Options to fix:\n' + ' 1. Return primitives: (cells) => cells.map(c => c.id)\n' + ' 2. Return cell records directly: (cells) => cells.filter(predicate)\n' + - ' 3. Provide a custom isEqual: useCells(input, selector, shallowEqual)\n' + ' 3. Provide a custom isEqual as the last argument of the hook\n' ); } @@ -127,20 +127,21 @@ export function warnResizeOnAutoSizedElement(cellId: dia.Cell.ID): void { } /** - * 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. + * Warns once when the graph state is read outside of the selector it was + * handed to (kept in a variable and read later, or after an `await`). The hook + * subscribes to what the selector reads while it runs, so such a read is not + * followed by any update. Dev-only, tree-shaken in production. */ -export function warnSourceSelectorCalled(): void { +export function warnGraphStateReadOutsideSelector(): void { if (process.env.NODE_ENV === 'production') return; - const key = 'source-selector-called'; + const key = 'graph-state-read-outside-selector'; 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.' + '[useGraphState] The graph state was read outside of its selector. The hook follows only ' + + 'what the selector reads while it runs, so this read will not update.\n\n' + + 'Fix: read everything you need inside the selector and return it.' ); } 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 3be5fb229a..652b04f294 100644 --- a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx @@ -5,8 +5,8 @@ import { useCellId, useGraph, useCells, - useOnCellsChange, - selectMeasuredState, + useOnGraphStateChange, + selectGraphSizingVersion, linkRoutingOrthogonal, type CellRecord, type Computed, @@ -194,8 +194,8 @@ function LayoutRunner() { } }, [graph]); - useOnCellsChange(selectMeasuredState, (measuredState) => { - if (measuredState) runLayout(); + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + if (sizingVersion) 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 76185d0437..3b19af2c30 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('useOnCellsChange'), + apiUrl: getAPILink('useOnGraphStateChange'), 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 6bad1007c6..719c0b23bf 100644 --- a/packages/joint-react/stories/examples/automatic-layout/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout/code.tsx @@ -4,8 +4,8 @@ import { useGraph, useCells, HTMLBox, - useOnCellsChange, - selectMeasuredState, + useOnGraphStateChange, + selectGraphSizingVersion, type CellRecord, type ElementRecord, type Computed, @@ -46,8 +46,8 @@ function Main() { const nextIdRef = useRef(initialCells.length); // Re-run the grid layout every time the element sizes settle. - useOnCellsChange(selectMeasuredState, (measuredState) => { - if (measuredState) layoutGrid(graph, columns); + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + if (sizingVersion) layoutGrid(graph, columns); }); const elementCount = useCells, number>((cells) => { diff --git a/packages/joint-react/stories/examples/automatic-layout/story.tsx b/packages/joint-react/stories/examples/automatic-layout/story.tsx index aa0ff0cbb8..859c0a2658 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('useOnCellsChange'), + apiUrl: getAPILink('useOnGraphStateChange'), 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 914796bf86..97f335358b 100644 --- a/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx +++ b/packages/joint-react/stories/examples/collapsible-subtrees/code.tsx @@ -7,12 +7,12 @@ import { SVGText, useCell, useCellId, - useCells, useGraph, useMarkup, usePaper, selectElementSize, - selectIsMeasured, + useGraphState, + selectIsGraphSizingComplete, linkRoutingOrthogonal, } from '@joint/react'; import { useCallback, useLayoutEffect, useMemo } from 'react'; @@ -748,11 +748,11 @@ function Main() { const { graph } = useGraph(); const { paper } = usePaper(); - const isMeasured = useCells(selectIsMeasured); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); // Lay the tree out and fit it once the element sizes are known. useLayoutEffect(() => { - if (!isMeasured || !paper) return; + if (!isSizingComplete || !paper) return; runLayout(graph); addExpandTools(paper); paper.transformToFitContent({ @@ -761,7 +761,7 @@ function Main() { verticalAlign: 'middle', horizontalAlign: 'middle', }); - }, [isMeasured, paper, graph]); + }, [isSizingComplete, 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 93ccbc1b68..7999a391a1 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,11 @@ import { useCell, Paper, SVGText, - useCells, useGraph, usePaper, selectElementSize, - selectIsMeasured, + useGraphState, + selectIsGraphSizingComplete, } from '@joint/react'; import { useCallback, useEffect, useLayoutEffect } from 'react'; @@ -212,11 +212,11 @@ function Main() { useInterval(setRandomStatuses); const { paper } = usePaper(); - const isMeasured = useCells(selectIsMeasured); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); // The status lists attach to the element views, which exist once the sizes are known. useLayoutEffect(() => { - if (!isMeasured || !paper) return; + if (!isSizingComplete || !paper) return; for (const element of graph.getElements()) { StatusList.add(element.findView(paper), 'root', 'status', { attribute: 'status', @@ -227,7 +227,7 @@ function Main() { }); } setRandomStatuses(); - }, [isMeasured, paper, graph, setRandomStatuses]); + }, [isSizingComplete, 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 936a8e5a25..6e337d2bd3 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('selectIsMeasured'), + apiUrl: getAPILink('selectIsGraphSizingComplete'), 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 4160757f77..1fabfb2e28 100644 --- a/packages/joint-react/stories/examples/element-controls/code.tsx +++ b/packages/joint-react/stories/examples/element-controls/code.tsx @@ -3,11 +3,11 @@ import { GraphProvider, useCell, Paper, - useCells, usePaper, type ElementRecord, selectElementSize, - selectIsMeasured, + useGraphState, + selectIsGraphSizingComplete, } from '@joint/react'; import { dia, elementTools, g } from '@joint/core'; import { useLayoutEffect } from 'react'; @@ -751,12 +751,12 @@ function addElementControls(paper: dia.Paper) { // ---------------------------------------------------------------------------- function Main() { const { paper } = usePaper(); - const isMeasured = useCells(selectIsMeasured); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); // The controls attach to the element views, which exist once the sizes are known. useLayoutEffect(() => { - if (isMeasured && paper) addElementControls(paper); - }, [isMeasured, paper]); + if (isSizingComplete && paper) addElementControls(paper); + }, [isSizingComplete, 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 5b3fedda07..18ea33889a 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('selectIsMeasured'), + apiUrl: getAPILink('selectIsGraphSizingComplete'), 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 a14562577c..6d3c01f1e0 100644 --- a/packages/joint-react/stories/examples/flowchart/code.tsx +++ b/packages/joint-react/stories/examples/flowchart/code.tsx @@ -12,11 +12,11 @@ import type { import { GraphProvider, Paper, - useCells, useMarkup, useMeasureElement, usePaper, - selectIsMeasured, + useGraphState, + selectIsGraphSizingComplete, linkRoutingOrthogonal, } from '@joint/react'; import { dia, highlighters, linkTools } from '@joint/core'; @@ -428,18 +428,18 @@ function RenderFlowchartElement(data: Readonly) { function Main() { const { paper: fitPaper } = usePaper(); - const isMeasured = useCells(selectIsMeasured); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); // Fit the content once the element sizes are known. useLayoutEffect(() => { - if (!isMeasured || !fitPaper) return; + if (!isSizingComplete || !fitPaper) return; fitPaper.transformToFitContent({ padding: 40, useModelGeometry: true, verticalAlign: 'middle', horizontalAlign: 'middle', }); - }, [isMeasured, fitPaper]); + }, [isSizingComplete, fitPaper]); return ( { - const contentArea = measuredState > 0 && graph.getBBox(); + const contentArea = sizingVersion > 0 && graph.getBBox(); if (!contentArea || !paper) return; paper.transformToFitContent({ contentArea, @@ -306,7 +307,7 @@ function MiniMap() { horizontalAlign: 'middle', padding: 20, }); - }, [measuredState, paper, graph]); + }, [sizingVersion, paper, graph]); return (
diff --git a/packages/joint-react/stories/examples/measured-state/code.tsx b/packages/joint-react/stories/examples/measured-state/code.tsx index cff47f03d3..4db18a2bd3 100644 --- a/packages/joint-react/stories/examples/measured-state/code.tsx +++ b/packages/joint-react/stories/examples/measured-state/code.tsx @@ -6,12 +6,11 @@ import { GraphProvider, HTMLBox, Paper, - useCells, useGraph, - useOnCellsChange, - selectIsMeasured, - selectMeasuredState, - selectElementsSizes, + useGraphState, + useOnGraphStateChange, + selectIsGraphSizingComplete, + selectGraphSizingVersion, type CellRecord, type ElementRecord, } from '@joint/react'; @@ -55,7 +54,7 @@ function renderElement({ label, isLong }: NodeData) { return {isLong ? label + LONG_SUFFIX : label}; } -type Source = 'isMeasured' | 'measuredState' | 'sizes'; +type Source = 'isSizingComplete' | 'sizingVersion'; interface LogEntry { readonly id: number; @@ -64,14 +63,13 @@ interface LogEntry { } const SOURCE_CLASS: Record = { - isMeasured: 'text-brand', - measuredState: 'text-accent', - sizes: 'text-ink', + isSizingComplete: 'text-brand', + sizingVersion: 'text-accent', }; /** - * Logs every change of the three measurement selectors. `useOnCellsChange` - * calls back without re-rendering this component; only the log state does. + * Logs every change of the two measurement selectors. `useOnGraphStateChange` calls + * back without re-rendering this component; only the log state does. */ function useMeasurementLog(isAutoLayout: boolean) { const { graph } = useGraph(); @@ -85,30 +83,17 @@ function useMeasurementLog(isAutoLayout: boolean) { }; // 1. Once per diagram: false → true when the sizes first settle. - useOnCellsChange(selectIsMeasured, (isMeasured, previous) => { + useOnGraphStateChange(selectIsGraphSizingComplete, (isSizingComplete, previous) => { log( - 'isMeasured', - previous === undefined ? `${isMeasured} (mount)` : `${previous} → ${isMeasured}` + 'isSizingComplete', + previous === undefined ? `${isSizingComplete} (mount)` : `${previous} → ${isSizingComplete}` ); }); // 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`); + useOnGraphStateChange(selectGraphSizingVersion, (sizingVersion) => { + log('sizingVersion', sizingVersion ? `settled #${sizingVersion}` : '0 (nothing measured)'); + if (sizingVersion && isAutoLayout) layoutRows(graph); }); return entries; @@ -170,24 +155,6 @@ function Controls({ onMove }: Readonly<{ onMove: () => void }>) { ); } -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 (
@@ -198,25 +165,22 @@ function Section({ title, children }: Readonly<{ title: string; children: ReactN } function Inspector({ entries, moves }: Readonly<{ entries: readonly LogEntry[]; moves: number }>) { - const isMeasured = useCells(selectIsMeasured); - const measuredState = useCells(selectMeasuredState); + const isSizingComplete = useGraphState(selectIsGraphSizingComplete); + const sizingVersion = useGraphState(selectGraphSizingVersion); return (
diff --git a/packages/joint-react/stories/examples/measured-state/story.tsx b/packages/joint-react/stories/examples/measured-state/story.tsx index 427174dac9..d077e9facc 100644 --- a/packages/joint-react/stories/examples/measured-state/story.tsx +++ b/packages/joint-react/stories/examples/measured-state/story.tsx @@ -10,8 +10,8 @@ const meta = { 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'), + 'Watch selectIsGraphSizingComplete flip once on load, and selectGraphSizingVersion change on every settled add, remove or re-measure. Moving nodes fires nothing.', + apiUrl: getAPILink('useOnGraphStateChange'), code: codeRaw, canvasHeight: 480, }, From f52acb44b8919c2ffca8702dfd9cb40cd6c33b8b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 22 Oct 2026 16:01:11 +0700 Subject: [PATCH 11/15] fix(joint-react): announce the sizing version after the measured sizes commit - measurement: deliver one scheduler round after the graph events that settle an element, so a subscriber of the new sizing version reads committed cells - measurement: re-check the delivery when a paper is removed, so elements that only waited for the last paper settle - useCells / useOnCellsChange: name the called hook in the unstable-selector dev warning; compute the target key once per render - docs: StrictMode mount call note, examples on the callback and selector types --- .../src/hooks/__tests__/use-cells.test.tsx | 23 +++++++++++++++ .../src/hooks/use-cells-state-selector.ts | 6 ++-- packages/joint-react/src/hooks/use-cells.ts | 11 ++++++-- .../src/hooks/use-graph-state-reader.ts | 10 +++---- .../joint-react/src/hooks/use-graph-state.ts | 18 +++++++++++- .../src/hooks/use-on-cells-change.ts | 15 ++++++++-- .../src/hooks/use-on-graph-state-change.ts | 1 + .../src/hooks/use-watch-graph-state.ts | 14 ++++++++-- .../src/store/__tests__/graph-state.test.ts | 24 ++++++++++++++++ .../src/store/__tests__/graph-store.test.ts | 28 +++++++++++++++++-- .../joint-react/src/store/graph-projection.ts | 12 ++++---- packages/joint-react/src/store/graph-state.ts | 13 +++++++-- packages/joint-react/src/store/graph-store.ts | 2 +- packages/joint-react/src/store/measurement.ts | 12 +++++++- 14 files changed, 161 insertions(+), 28 deletions(-) diff --git a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx index 867cf5917c..ee6cc6fc84 100644 --- a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx @@ -1362,3 +1362,26 @@ describe('useCells — what a re-render changes', () => { expect(result.current).toBe(1); }); }); + +describe('useCells dev warnings', () => { + // Regression: the warning named `useGraphState`, which useCells runs on, not + // the hook the application called. + it('names useCells when its selector returns new objects on every run', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook( + () => { + useCells((cells) => cells.map((cell) => ({ id: cell.id }))); + return useGraphStore().graph; + }, + { wrapper } + ); + + await act(async () => { + (result.current.getCell('a') as dia.Element).position(5, 0); + await flush(); + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[useCells] Selector returns')); + warn.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/hooks/use-cells-state-selector.ts b/packages/joint-react/src/hooks/use-cells-state-selector.ts index 1a45f365c8..3ea9a9522e 100644 --- a/packages/joint-react/src/hooks/use-cells-state-selector.ts +++ b/packages/joint-react/src/hooks/use-cells-state-selector.ts @@ -2,22 +2,22 @@ import { useMemo } from 'react'; import type { AnyCellRecord } from '../types/cell.types'; import type { GraphStateSelector } from '../store/graph-state'; import type { AnyCellsSelector, CellsSource, CellsTarget } from './use-cells.types'; -import { toTargetKey } from './use-cells.utils'; /** * The graph state selector of a cells hook: reads what `target` names and * hands it to `selector`. The same function for as long as the target and the * selector are. * @param target - What to read: all cells (`undefined`), no cell (`null`), a cell id, ids or a collection. + * @param targetKey - `toTargetKey(target)`, computed once by the caller. * @param selector - Derives the value from what was read; the read itself when omitted. * @returns The selector to pass to `useGraphState` / `useOnGraphStateChange`. * @internal */ export function useCellsStateSelector( target: CellsTarget, + targetKey: unknown, selector?: AnyCellsSelector ): GraphStateSelector> { - const targetKey = toTargetKey(target); return useMemo( () => (state) => { let source: CellsSource; @@ -27,7 +27,7 @@ export function useCellsStateSelector( else source = state.getCell(target); return selector ? selector(source) : source; }, - // `targetKey` stands in for `target`, see `toTargetKey`. + // `targetKey` stands in for `target`, see `toTargetKey` in `use-cells.utils`. // eslint-disable-next-line react-hooks/exhaustive-deps [targetKey, selector] ); diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts index 67811e3a92..6222446688 100644 --- a/packages/joint-react/src/hooks/use-cells.ts +++ b/packages/joint-react/src/hooks/use-cells.ts @@ -3,7 +3,8 @@ import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell. import type { SelectedEqual } from '../store/graph-state'; import type { AnyCellsSelector, CellsSelector, CellsSource, CellsTarget } from './use-cells.types'; import { useCellsStateSelector } from './use-cells-state-selector'; -import { useGraphState } from './use-graph-state'; +import { toTargetKey } from './use-cells.utils'; +import { useSelectGraphState } from './use-graph-state'; export type { SelectedEqual } from '../store/graph-state'; export type { CellsSelector } from './use-cells.types'; @@ -184,6 +185,10 @@ export function useCells< | SelectedEqual> | undefined; - const select = useCellsStateSelector(target, hasSelector ? selector : undefined); - return useGraphState(select, isEqual); + const select = useCellsStateSelector( + target, + toTargetKey(target), + hasSelector ? selector : undefined + ); + return useSelectGraphState('useCells', select, isEqual); } diff --git a/packages/joint-react/src/hooks/use-graph-state-reader.ts b/packages/joint-react/src/hooks/use-graph-state-reader.ts index 403e3a48b8..3fe31f35dd 100644 --- a/packages/joint-react/src/hooks/use-graph-state-reader.ts +++ b/packages/joint-react/src/hooks/use-graph-state-reader.ts @@ -6,15 +6,15 @@ import { createGraphStateReader, type GraphStateReader } from '../store/graph-st /** * The reader behind {@link useGraphState} and {@link useOnGraphStateChange}, one * per hook instance and per graph store. + * @param hookName - The hook the application called, named in dev warnings. * @returns The reader of the closest graph store. * @internal */ -export function useGraphStateReader(): GraphStateReader< - Cell, - Selected -> { +export function useGraphStateReader( + hookName: string +): GraphStateReader { const store = useGraphStore(); // Should React drop this memo, the new reader selects afresh: correct, at the // cost of one new reference for a selected array or object. - return useMemo(() => createGraphStateReader(store), [store]); + return useMemo(() => createGraphStateReader(store, hookName), [store, hookName]); } diff --git a/packages/joint-react/src/hooks/use-graph-state.ts b/packages/joint-react/src/hooks/use-graph-state.ts index 28deacd1a8..4a5dc55d27 100644 --- a/packages/joint-react/src/hooks/use-graph-state.ts +++ b/packages/joint-react/src/hooks/use-graph-state.ts @@ -54,7 +54,23 @@ export function useGraphState, isEqual?: SelectedEqual ): Selected { - const reader = useGraphStateReader(); + return useSelectGraphState('useGraphState', selector, isEqual); +} + +/** + * {@link useGraphState} for a hook built on it, which its dev warnings name. + * @param hookName - The hook the application called. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. + * @returns The selected value, the same reference for as long as it is equal. + * @internal + */ +export function useSelectGraphState( + hookName: string, + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + const reader = useGraphStateReader(hookName); const getSnapshot = useCallback( () => reader.read(selector, isEqual), [reader, selector, isEqual] diff --git a/packages/joint-react/src/hooks/use-on-cells-change.ts b/packages/joint-react/src/hooks/use-on-cells-change.ts index 5027860e79..6d434e4dda 100644 --- a/packages/joint-react/src/hooks/use-on-cells-change.ts +++ b/packages/joint-react/src/hooks/use-on-cells-change.ts @@ -10,6 +10,14 @@ import { useWatchGraphState, type OnGraphStateChange } from './use-watch-graph-s * Callback of {@link useOnCellsChange}: receives the newly selected value and * the one before it, absent on the first call. * @group Types + * @example + * ```ts + * import type { OnCellsChange } from '@joint/react'; + * + * const logCount: OnCellsChange = (count, previousCount) => { + * console.info(`cells: ${previousCount ?? '-'} → ${count}`); + * }; + * ``` */ export type OnCellsChange = OnGraphStateChange; @@ -18,6 +26,7 @@ export type OnCellsChange = OnGraphStateChange; * 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. + * Like any effect, the mount call runs twice in development under StrictMode. * Changing the watched id, ids or collection starts over: the callback runs * again with the current value and no previous one. * @@ -121,10 +130,12 @@ export function useOnCellsChange( OnCellsChange, SelectedEqual | undefined, ]; + const targetKey = toTargetKey(target); useWatchGraphState( - useCellsStateSelector(target, selector), + useCellsStateSelector(target, targetKey, selector), onChange as OnCellsChange, isEqual, - toTargetKey(target) + targetKey, + 'useOnCellsChange' ); } diff --git a/packages/joint-react/src/hooks/use-on-graph-state-change.ts b/packages/joint-react/src/hooks/use-on-graph-state-change.ts index 3d1d849bbc..a89fdfd8ec 100644 --- a/packages/joint-react/src/hooks/use-on-graph-state-change.ts +++ b/packages/joint-react/src/hooks/use-on-graph-state-change.ts @@ -9,6 +9,7 @@ export type { OnGraphStateChange } from './use-watch-graph-state'; * re-rendering the component: {@link useGraphState} 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. + * Like any effect, the mount call runs twice in development under StrictMode. * * It takes the same selectors and equality functions as {@link useGraphState} * and subscribes the same way, to what the selector reads. A new selector diff --git a/packages/joint-react/src/hooks/use-watch-graph-state.ts b/packages/joint-react/src/hooks/use-watch-graph-state.ts index a5d6e22c19..3b9169669a 100644 --- a/packages/joint-react/src/hooks/use-watch-graph-state.ts +++ b/packages/joint-react/src/hooks/use-watch-graph-state.ts @@ -9,6 +9,14 @@ import { rethrowLater } from '../utils/scheduler'; * Callback of {@link useOnGraphStateChange}: receives the newly selected value * and the one before it, absent on the first call. * @group Types + * @example + * ```ts + * import type { OnGraphStateChange } from '@joint/react'; + * + * const logVersion: OnGraphStateChange = (version, previousVersion) => { + * console.info(`sizing version: ${previousVersion ?? '-'} → ${version}`); + * }; + * ``` */ export type OnGraphStateChange = (value: Selected, previousValue?: Selected) => void; @@ -22,15 +30,17 @@ export type OnGraphStateChange = (value: Selected, previousValue?: Sel * @param isEqual - Custom equality for the selected value. * @param watchKey - Identifies what is watched; changing it runs the callback * again with the current value and no previous one. + * @param hookName - The hook the application called, named in dev warnings. * @internal */ export function useWatchGraphState( selector: GraphStateSelector, onChange: OnGraphStateChange, isEqual?: SelectedEqual, - watchKey?: unknown + watchKey?: unknown, + hookName = 'useOnGraphStateChange' ): void { - const reader = useGraphStateReader(); + const reader = useGraphStateReader(hookName); const selectorRef = useLatestRef(selector); const isEqualRef = useLatestRef(isEqual); const onChangeRef = useLatestRef(onChange); diff --git a/packages/joint-react/src/store/__tests__/graph-state.test.ts b/packages/joint-react/src/store/__tests__/graph-state.test.ts index fc0e481839..dbc9d0d405 100644 --- a/packages/joint-react/src/store/__tests__/graph-state.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-state.test.ts @@ -2,6 +2,7 @@ import { dia, mvc } from '@joint/core'; import { GraphStore } from '../graph-store'; import { createGraphStateReader, type GraphState } from '../graph-state'; import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../measurement'; import type { CellRecord } from '../../types/cell.types'; const element = (id: string, x = 0): CellRecord => @@ -17,6 +18,8 @@ const selectX = (id: string) => (state: GraphState) => state.getCell(id)?.positi const selectCount = (state: GraphState) => state.cells.length; const selectState = (state: GraphState) => state; const selectMeasured = (state: GraphState) => state.sizingVersion; +const selectVersionAndWidth = (state: GraphState) => + `${state.sizingVersion > 0}:${state.getCell('a')?.size?.width}`; const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); const selectPicked = (state: GraphState) => state.getCells(['b', 'missing', 'a']).map((cell) => cell.id); @@ -165,6 +168,27 @@ describe('createGraphStateReader', () => { expect(listener).toHaveBeenCalledTimes(1); }); + // Regression: the sizing version was announced in the same scheduler round + // as the measured write, ahead of the projection commit of that write, so + // a selector reading cells next to the version saw the pre-measure sizes. + it('announces a new sizing version once the measured sizes are in the cells', async () => { + await flush(); + const reader = createGraphStateReader(store); + const seen: string[] = []; + reader.sync(selectVersionAndWidth); + reader.subscribe(() => seen.push(reader.sync(selectVersionAndWidth))); + + await commit(() => { + store.graph.startBatch('auto-size'); + getElement('a').set('size', { width: 99, height: 10 }, { [AUTO_SIZE_OPTION]: true }); + store.graph.stopBatch('auto-size'); + }); + await flush(); + + expect(seen).not.toContain('true:10'); + expect(seen.at(-1)).toBe('true:99'); + }); + it('is not notified of a cell change when the selector reads the sizing version', async () => { await flush(); const reader = createGraphStateReader(store); 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 4b0bf64296..6d4dcfdc04 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -8,8 +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 sizingVersion = (store: { readonly measurement: { readonly getSizingVersion: () => number } }) => - store.measurement.getSizingVersion(); +const sizingVersion = (store: { + readonly measurement: { readonly getSizingVersion: () => number }; +}) => store.measurement.getSizingVersion(); describe('GraphStore', () => { describe('constructor', () => { @@ -314,6 +315,29 @@ describe('GraphStore', () => { }); }); + describe('removing a paper', () => { + // Regression: only papers hold an outstanding element back, and nothing + // re-checked the delivery when the last one went away, so the sizing + // version stayed `0` until the next graph change. + it('settles the elements that waited for the last paper', async () => { + const store = new GraphStore({}); + const { remove } = store.addPaper('p1', { paperOptions: {} }); + store.graph.addCell({ + id: 'a', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, + }); + await flush(); + expect(sizingVersion(store)).toBe(0); + + remove(); + await flush(); + expect(sizingVersion(store)).toBe(1); + store.destroy(false); + }); + }); + describe('destroy', () => { it('clears features, papers, and internal state', () => { const store = new GraphStore({}); diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 57ceee69dc..01996c7cc1 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -232,6 +232,12 @@ export function graphProjection< } } + // 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(); + // Two commit modes, decided by `deferCommit` (see graph-changes): // // - Plain batches (interactive drags, `auto-size`, layout) and lone edits @@ -244,12 +250,6 @@ 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 = diff --git a/packages/joint-react/src/store/graph-state.ts b/packages/joint-react/src/store/graph-state.ts index 073bc08fb6..54f6cdf4ca 100644 --- a/packages/joint-react/src/store/graph-state.ts +++ b/packages/joint-react/src/store/graph-state.ts @@ -53,6 +53,13 @@ export interface GraphState { /** * Derives a value from the {@link GraphState}. * @group Types + * @example + * ```ts + * import type { CellRecord, GraphStateSelector } from '@joint/react'; + * + * const selectElementCount: GraphStateSelector = (state) => + * state.cells.filter((cell) => cell.type === 'element').length; + * ``` */ export type GraphStateSelector = ( state: GraphState @@ -138,11 +145,13 @@ function getDetachedRecord( * with what the subscription delivered, so a change that lands between a * render and its subscription is not missed. * @param store - The graph store to read. + * @param hookName - The hook the application called, named in dev warnings. * @returns `read`, `sync` and `subscribe`, all identity-stable. * @internal */ export function createGraphStateReader( - store: GraphStateStore + store: GraphStateStore, + hookName = 'useGraphState' ) { // The store holds resolved (Computed) records of the application's own cell // shape; the `Cell` generic is how the caller declares that shape. @@ -277,7 +286,7 @@ export function createGraphStateReader( isDirty = false; if (selected) { if (isEqual(selected.value, next)) return selected.value; - warnUnstableSelector('useGraphState', selected.value, next, isEqual !== arrayAwareEqual); + warnUnstableSelector(hookName, selected.value, next, isEqual !== arrayAwareEqual); } selected = { value: next }; return next; diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index eed19a780b..e1837b4a76 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -140,7 +140,6 @@ export class GraphStore< // (e.g. stencil drag's cloneView). this.graphProjection.syncFromGraph(); } - } public setOnIncrementalCellsChange = (callback: OnIncrementalCellsChange) => { @@ -298,6 +297,7 @@ export class GraphStore< const paperStore = this.paperStores.get(id); paperStore?.destroy(); this.paperStores.delete(id); + this.measurement.onPaperRemoved(); this.internalState.setState((previous) => { const newPapers: Record = {}; for (const [key, value] of Object.entries(previous.papers)) { diff --git a/packages/joint-react/src/store/measurement.ts b/packages/joint-react/src/store/measurement.ts index d70d3ee56c..d8c47ce344 100644 --- a/packages/joint-react/src/store/measurement.ts +++ b/packages/joint-react/src/store/measurement.ts @@ -149,7 +149,11 @@ export function createMeasurement(options: Options) { lastVersion += 1; setVersion(lastVersion); }; - const scheduleDelivery = () => simpleScheduler(deliver); + // One round later than the graph events that settle an element: the + // projection commits those events in the round they are queued in, and a + // subscriber of the new version reads the committed cells. + const deliverNextRound = () => simpleScheduler(deliver); + const scheduleDelivery = () => simpleScheduler(deliverNextRound); const settle = (id: CellId) => { if (outstanding.delete(id)) scheduleDelivery(); }; @@ -350,6 +354,12 @@ export function createMeasurement(options: Options) { if (!nodesById.has(id) && !isRendered(id)) settle(id); } }, + /** + * A paper was removed. Only papers hold outstanding elements back, so with + * the last one gone the event is due. Checked on the next pass, not now: a + * paper re-created right away (StrictMode, a hot reload) keeps them waiting. + */ + onPaperRemoved: scheduleDelivery, /** Stops listening to the graph and observing nodes. */ destroy() { listener.stopListening(); From 1fd55d15b281208f0d405795af1c34feaebd1fbb Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 22 Oct 2026 16:05:20 +0700 Subject: [PATCH 12/15] chore(joint-react): restore the dev bench baseline and consolidate changesets --- .../react-elements-measured-app-resize.md | 2 +- .changeset/react-graph-state.md | 2 +- .changeset/react-measurement-selectors.md | 5 - .changeset/react-on-cells-change.md | 5 - .changeset/react-on-graph-state-change.md | 5 - .../bench/baseline-post-refactor.json | 435 +++++++----------- 6 files changed, 170 insertions(+), 284 deletions(-) delete mode 100644 .changeset/react-measurement-selectors.md delete mode 100644 .changeset/react-on-cells-change.md delete mode 100644 .changeset/react-on-graph-state-change.md diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md index 9ed255334c..6c5ef0a59c 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 exactly once per settled change, when no element is still waiting to be measured +useOnElementsMeasured - fix to fire exactly once per settled change (an addition, a removal or a re-measurement), when no element is still waiting to be measured diff --git a/.changeset/react-graph-state.md b/.changeset/react-graph-state.md index 25422f8651..00dcd790ba 100644 --- a/.changeset/react-graph-state.md +++ b/.changeset/react-graph-state.md @@ -2,4 +2,4 @@ "@joint/react": minor --- -useGraphState - add the hook to read a value selected from the graph state, the cells and the sizing version +useGraphState - add the hook, with `useOnGraphStateChange()`, `useOnCellsChange()` and the `selectGraphSizingVersion` / `selectIsGraphSizingComplete` selectors diff --git a/.changeset/react-measurement-selectors.md b/.changeset/react-measurement-selectors.md deleted file mode 100644 index af1342d36f..0000000000 --- a/.changeset/react-measurement-selectors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": minor ---- - -useGraphState - add the `selectGraphSizingVersion` and `selectIsGraphSizingComplete` selectors diff --git a/.changeset/react-on-cells-change.md b/.changeset/react-on-cells-change.md deleted file mode 100644 index d11cfaea4b..0000000000 --- a/.changeset/react-on-cells-change.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": minor ---- - -useOnCellsChange - add the hook to run a callback when a value selected from the cells changes diff --git a/.changeset/react-on-graph-state-change.md b/.changeset/react-on-graph-state-change.md deleted file mode 100644 index afbec46956..0000000000 --- a/.changeset/react-on-graph-state-change.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": minor ---- - -useOnGraphStateChange - add the hook to run a callback when a value selected from the graph state changes diff --git a/packages/joint-react/bench/baseline-post-refactor.json b/packages/joint-react/bench/baseline-post-refactor.json index 2bfe38633b..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-10-07T11:22:10.233Z", - "gitSha": "d54387716a96623ca56b926a847e4027523d9a9d", + "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": 6556248617.142366, - "mean": 0.00023452956491256294, - "stddev": 0.008327817193143692, + "opsPerSec": 9102033552.308687, + "mean": 0.00012776233369654266, + "stddev": 0.002638525657641091, "min": 0.00004099999978279811, - "max": 8.916000000000167, - "samples": 4263855 + "max": 6.915790999999899, + "samples": 7827033 } ], "hooks/full-iteration-selector/n=10": [ { "name": "full iteration + selector (10)", - "opsPerSec": 1968274029.1762328, - "mean": 0.0014129604597200728, - "stddev": 0.060980339228978185, + "opsPerSec": 2535300828.319926, + "mean": 0.0006300311846347321, + "stddev": 0.008285664941017352, "min": 0.000249999999141437, - "max": 27.278291000000536, - "samples": 707734 + "max": 3.906082999999853, + "samples": 1587224 } ], "hooks/single-id-get-selector/n=10": [ { "name": "single-ID get + selector (10)", - "opsPerSec": 7385289640.709777, - "mean": 0.0003123684188657082, - "stddev": 0.04007778247984978, + "opsPerSec": 9001583723.37333, + "mean": 0.00018175384538777985, + "stddev": 0.006982715983930184, "min": 0, - "max": 61.599458999999115, - "samples": 3201348 + "max": 9.703125, + "samples": 5501981 } ], "hooks/shallow-map-comparison/n=10": [ { "name": "shallow Map equal (10)", - "opsPerSec": 4238575878.143033, - "mean": 0.0003384518542084552, - "stddev": 0.00375570076063822, + "opsPerSec": 5260348561.022442, + "mean": 0.00026741772974151634, + "stddev": 0.018586417085768542, "min": 0.0001249999986612238, - "max": 2.5979580000021087, - "samples": 2954630 + "max": 35.464584000001196, + "samples": 3739468 }, { "name": "shallow Map unequal (10)", - "opsPerSec": 1308595896.441942, - "mean": 0.0012824915420510227, - "stddev": 0.05576496879861116, - "min": 0.0005000000019208528, - "max": 33.78308299999844, - "samples": 779740 + "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": 1131650963.341761, - "mean": 0.0015354642413513717, - "stddev": 0.02192373584325527, - "min": 0.0005409999976109248, - "max": 7.302957999996579, - "samples": 651269 + "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": 423375157.5090429, - "mean": 0.0029193504533757365, - "stddev": 0.012919020641110227, + "opsPerSec": 507671227.5095935, + "mean": 0.002142448513894801, + "stddev": 0.0036657148157697955, "min": 0.0017499999994470272, - "max": 3.4670420000002196, - "samples": 342542 + "max": 0.9238330000007409, + "samples": 466757 } ], "hooks/single-id-get-selector/n=100": [ { "name": "single-ID get + selector (100)", - "opsPerSec": 8162931495.12325, - "mean": 0.0001604103687028734, - "stddev": 0.00290308409977465, - "min": 0, - "max": 2.9510000000009313, - "samples": 6234011 + "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": 463292029.39763564, - "mean": 0.0030162655428061145, - "stddev": 0.016924350277044167, - "min": 0.0014999999984866008, - "max": 3.4919169999993755, - "samples": 331536 + "opsPerSec": 619759976.4328091, + "mean": 0.0017797496249183477, + "stddev": 0.004928262145344146, + "min": 0.0014159999991534278, + "max": 1.6197920000049635, + "samples": 561877 }, { "name": "shallow Map unequal (100)", - "opsPerSec": 160138706.77104205, - "mean": 0.008095239609820813, - "stddev": 0.01682626375284981, - "min": 0.004666000000725035, - "max": 2.1466250000012224, - "samples": 123530 + "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": 103131103.36629061, - "mean": 0.01784015752666576, - "stddev": 0.18370540093427898, - "min": 0.006207999998878222, - "max": 25.36804200000188, - "samples": 56054 + "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": 44071741.22043168, - "mean": 0.02847489088841572, - "stddev": 0.032626964022938576, - "min": 0.01704200000676792, - "max": 2.172292000002926, - "samples": 35120 + "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": 7494905904.541577, - "mean": 0.00020464511753626058, - "stddev": 0.009736788172910328, + "opsPerSec": 9166420604.692993, + "mean": 0.0001251065802928049, + "stddev": 0.0007809810394888233, "min": 0.00004099999932805076, - "max": 20.029332999998587, - "samples": 4886508 + "max": 1.1806250000008731, + "samples": 7993185 } ], "hooks/shallow-map-comparison/n=1000": [ { "name": "shallow Map equal (1000)", - "opsPerSec": 42603991.51621076, - "mean": 0.02482474135490794, - "stddev": 0.013371336833796795, - "min": 0.020167000002402347, - "max": 0.9157500000001164, - "samples": 40283 + "opsPerSec": 45520810.90012255, + "mean": 0.02337060094885724, + "stddev": 0.01735478693567373, + "min": 0.019332999996549916, + "max": 2.1166249999951106, + "samples": 42789 }, { "name": "shallow Map unequal (1000)", - "opsPerSec": 14183407.469507407, - "mean": 0.08325856473226756, - "stddev": 0.058032716848443584, - "min": 0.0552079999979469, - "max": 1.487250000005588, - "samples": 12011 + "opsPerSec": 19032585.013689395, + "mean": 0.05319225739362969, + "stddev": 0.01233989935991809, + "min": 0.04958299999998417, + "max": 0.5364170000029844, + "samples": 18800 } ], "container/set-existing/n=10": [ @@ -300,88 +300,88 @@ "controlled/position-change/n=10": [ { "name": "controlled/position-change/n=10", - "opsPerSec": 113196296.31618722, - "mean": 0.012096132001306235, - "stddev": 0.05266348743780421, - "min": 0.0062499999999090505, - "max": 8.023916999999983, - "samples": 82681 + "opsPerSec": 157236218.1228957, + "mean": 0.006798862942264942, + "stddev": 0.00730341671823921, + "min": 0.005666999999903055, + "max": 2.183082999999897, + "samples": 147084 } ], "controlled/updateGraph/n=10": [ { "name": "controlled/updateGraph/n=10", - "opsPerSec": 4249730.372423904, - "mean": 0.5341643718700031, - "stddev": 1.0457211286649342, - "min": 0.13179200000013225, - "max": 14.849542000000383, - "samples": 1877 + "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": 86104747.84221543, - "mean": 0.05447658396344459, - "stddev": 1.126389269917178, - "min": 0.006207999999787717, - "max": 132.8043749999997, - "samples": 18383 + "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": 216204.6657581864, - "mean": 6.113782286585326, - "stddev": 4.666260656453332, - "min": 1.486041999999543, - "max": 51.7681249999996, - "samples": 164 + "opsPerSec": 542543.6686074686, + "mean": 2.5535463137755285, + "stddev": 3.572476213316869, + "min": 1.2611249999999927, + "max": 57.444915999999466, + "samples": 392 } ], "controlled/position-change/n=1000": [ { "name": "controlled/position-change/n=1000", - "opsPerSec": 88458401.87573092, - "mean": 0.03709406182356622, - "stddev": 0.34100924245857234, - "min": 0.006332999997539446, - "max": 35.74091699999917, - "samples": 26980 + "opsPerSec": 82319389.7423361, + "mean": 0.03426556061540249, + "stddev": 0.28126870447489927, + "min": 0.006374999999025022, + "max": 28.474333000000115, + "samples": 29184 } ], "controlled/updateGraph/n=1000": [ { "name": "controlled/updateGraph/n=1000", - "opsPerSec": 26222.087584254376, - "mean": 46.21684115624997, - "stddev": 21.94959092269449, - "min": 19.28112499999952, - "max": 111.38929199999984, + "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": 91391916.0629433, - "mean": 0.029337366654128512, - "stddev": 0.6127954626612058, - "min": 0.006375000000844011, - "max": 97.87212500000169, - "samples": 34553 + "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": 3587.5671343811496, - "mean": 303.96338868749956, - "stddev": 111.90897378789431, - "min": 187.4635419999977, - "max": 814.263291999996, + "opsPerSec": 5866.66567428112, + "mean": 185.22899542187494, + "stddev": 89.41845182660455, + "min": 139.24441600000137, + "max": 784.1291249999995, "samples": 64 } ], @@ -504,158 +504,59 @@ "graph-projection/position-change/n=100": [ { "name": "baseline (100)", - "opsPerSec": 107609283.24664287, - "mean": 0.02093810967336781, - "stddev": 0.2789501607228554, - "min": 0.005915999999160704, - "max": 44.07458399999996, - "samples": 47760 + "opsPerSec": 101417810.06484096, + "mean": 0.03332241076307389, + "stddev": 0.45364963030742494, + "min": 0.005874999999832653, + "max": 47.54591699999946, + "samples": 30010 }, { "name": "graphProjection (100)", - "opsPerSec": 84080057.26747039, - "mean": 0.0516581344377278, - "stddev": 0.8099268649799337, - "min": 0.006332999999358435, - "max": 66.44162499999948, - "samples": 19377 + "opsPerSec": 82146180.94625443, + "mean": 0.03185789700541546, + "stddev": 0.23653690890482493, + "min": 0.006292000000030384, + "max": 24.838708000001134, + "samples": 31390 }, { "name": "GraphStore (100)", - "opsPerSec": 83124880.42311327, - "mean": 0.05278206882718829, - "stddev": 0.896654491412595, - "min": 0.006332999999358435, - "max": 99.53816700000061, - "samples": 18946 + "opsPerSec": 107824652.24534024, + "mean": 0.01614418052371774, + "stddev": 0.07231301767024387, + "min": 0.006124999999883585, + "max": 8.082916999999725, + "samples": 61942 } ], "graph-projection/position-change/n=1000": [ { "name": "baseline (1000)", - "opsPerSec": 93875538.54213212, - "mean": 0.024028237303187563, - "stddev": 0.3434448882178457, - "min": 0.0061659999992116354, - "max": 61.49725000000035, - "samples": 41664 + "opsPerSec": 111711781.03085949, + "mean": 0.016222944538840746, + "stddev": 0.09040577218414217, + "min": 0.005916999998589745, + "max": 14.222999999999956, + "samples": 62386 }, { "name": "graphProjection (1000)", - "opsPerSec": 69463185.8067489, - "mean": 0.03024306889258245, - "stddev": 0.28629066396737934, - "min": 0.006832999999460299, - "max": 30.91216700000041, - "samples": 33095 + "opsPerSec": 110544984.6762098, + "mean": 0.016250856089156912, + "stddev": 0.09428158912855929, + "min": 0.006457999999838648, + "max": 16.033792000000176, + "samples": 61552 }, { "name": "GraphStore (1000)", - "opsPerSec": 89060758.42007807, - "mean": 0.023937887612211745, - "stddev": 0.23692027964257345, - "min": 0.006667000001471024, - "max": 38.05083399999785, - "samples": 41775 - } - ], - "container/batchSet-1-changed/n=100": [ - { - "name": "container/batchSet-1-changed/n=100", - "opsPerSec": 3307328129.9137573, - "mean": 0.0003734778539092256, - "stddev": 0.0039058962956780054, - "min": 0.00016599999980826396, - "max": 2.560666000000083, - "samples": 1338769 - } - ], - "container/batchSet-100-changed/n=100": [ - { - "name": "container/batchSet-100-changed/n=100", - "opsPerSec": 82714757.26902202, - "mean": 0.022789107385302865, - "stddev": 0.10266788730375488, - "min": 0.008125000000291038, - "max": 4.829916000000139, - "samples": 21949 - } - ], - "container/batchSet-mixed/n=100": [ - { - "name": "container/batchSet-mixed/n=100", - "opsPerSec": 26797819.513413787, - "mean": 0.3569338400566364, - "stddev": 1.9072262683210357, - "min": 0.01412499999969441, - "max": 63.6284999999998, - "samples": 1413 - } - ], - "container/batchSet-1-changed/n=1000": [ - { - "name": "container/batchSet-1-changed/n=1000", - "opsPerSec": 2507027600.7019525, - "mean": 0.0013003174772832592, - "stddev": 0.051883720456374315, - "min": 0.00016599999980826396, - "max": 20.24591700000019, - "samples": 384522 - } - ], - "container/batchSet-100-changed/n=1000": [ - { - "name": "container/batchSet-100-changed/n=1000", - "opsPerSec": 54788281.72938199, - "mean": 0.05642775815370605, - "stddev": 0.5859758556978961, - "min": 0.009041999999681138, - "max": 50.92291600000044, - "samples": 8861 - } - ], - "container/batchSet-mixed/n=1000": [ - { - "name": "container/batchSet-mixed/n=1000", - "opsPerSec": 5762391.921159412, - "mean": 0.3277797578534037, - "stddev": 0.9640277635962766, - "min": 0.08808300000055169, - "max": 17.855333999999857, - "samples": 1528 - } - ], - "container/batchSet-1-changed/n=10000": [ - { - "name": "container/batchSet-1-changed/n=10000", - "opsPerSec": 2313850593.0140386, - "mean": 0.0019721223543984786, - "stddev": 0.11634848443854251, - "min": 0.00016599999980826396, - "max": 27.71191700000054, - "samples": 253534 - } - ], - "container/batchSet-100-changed/n=10000": [ - { - "name": "container/batchSet-100-changed/n=10000", - "opsPerSec": 55329563.3012874, - "mean": 0.058109681944754896, - "stddev": 0.6672748997250095, - "min": 0.00908299999900919, - "max": 35.277500000000146, - "samples": 8618 - } - ], - "container/batchSet-mixed/n=10000": [ - { - "name": "container/batchSet-mixed/n=10000", - "opsPerSec": 255345.1586641086, - "mean": 10.173419984374988, - "stddev": 17.45952996693197, - "min": 1.3679169999995793, - "max": 99.62012500000128, - "samples": 64 + "opsPerSec": 116700306.81772408, + "mean": 0.013303929876136874, + "stddev": 0.050927563796028565, + "min": 0.006207999998878222, + "max": 5.420540999999503, + "samples": 75167 } ] } From 9fe6856534ddd190695a1483d58dd9281f834c77 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 22 Oct 2026 16:12:03 +0700 Subject: [PATCH 13/15] fix(joint-react): move element measuring into one module and settle useOnElementsMeasured - store/measurement.ts replaces create-elements-size-observer.ts and the measurement code in graph-store, graph-changes and graph-projection; the ResizeObserver is created by the first observe() - useOnElementsMeasured: one event per settled change, a removal included; zero-sized elements count as measured; elements of an external graph are tracked from the start; an unmounted measured node no longer blocks events; the event is announced once the measured sizes are in the store - useCells runs on an internal graph-state reader that subscribes to what its selector reads; an inline selector capturing a changed prop is re-run - scheduler: a throwing callback no longer stops the flush; cascading callbacks continue on a later task; a graph change made by a subscriber during notification is no longer dropped --- .changeset/react-cells-inline-selector.md | 5 + .../react-elements-measured-app-resize.md | 2 +- .changeset/react-store-callbacks.md | 5 + .../src/components/graph/graph-provider.tsx | 2 +- .../__tests__/paper-element-item.test.tsx | 32 +- .../render-element/paper-element-item.tsx | 19 +- .../src/hooks/__tests__/use-cells.test.tsx | 75 ++ .../hooks/__tests__/use-graph-state.test.tsx | 328 ++++++++ .../__tests__/use-measure-element.test.tsx | 43 +- .../use-on-elements-measured-events.test.tsx | 12 +- ...use-on-elements-measured-settling.test.tsx | 67 +- .../use-on-elements-measured.test.tsx | 140 ++-- .../src/hooks/use-are-elements-measured.ts | 21 - packages/joint-react/src/hooks/use-cell.ts | 4 +- .../src/hooks/use-cells-state-selector.ts | 34 + packages/joint-react/src/hooks/use-cells.ts | 306 +------- .../joint-react/src/hooks/use-cells.types.ts | 24 + .../joint-react/src/hooks/use-cells.utils.ts | 134 +--- .../src/hooks/use-create-portal-paper.tsx | 16 +- .../src/hooks/use-graph-state-reader.ts | 20 + .../joint-react/src/hooks/use-graph-state.ts | 51 ++ .../src/hooks/use-measure-element.tsx | 22 +- .../src/hooks/use-on-elements-measured.ts | 59 +- packages/joint-react/src/index.ts | 7 +- packages/joint-react/src/internal.ts | 3 +- .../__tests__/selector-utils.test.ts | 0 ...{index.test.ts => store-selectors.test.ts} | 4 +- packages/joint-react/src/selectors/index.ts | 26 +- .../{utils => selectors}/selector-utils.ts | 8 +- .../src/selectors/store-selectors.ts | 19 + .../state/data-mapping/cell-record-merge.ts | 2 +- .../src/store/__tests__/clear-view.test.ts | 133 +--- .../create-elements-size-observer.test.ts | 608 --------------- .../src/store/__tests__/graph-changes.test.ts | 131 ---- .../src/store/__tests__/graph-state.test.ts | 385 ++++++++++ .../__tests__/graph-store-features.test.ts | 10 +- .../src/store/__tests__/graph-store.test.ts | 78 +- .../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-state.ts | 339 +++++++++ packages/joint-react/src/store/graph-store.ts | 236 +----- packages/joint-react/src/store/index.ts | 1 - packages/joint-react/src/store/measurement.ts | 373 +++++++++ packages/joint-react/src/store/paper-store.ts | 12 +- .../joint-react/src/store/state-container.ts | 2 +- .../src/utils/__tests__/scheduler.test.ts | 71 ++ .../src/utils/collection-subscription.ts | 49 -- .../joint-react/src/utils/dev-warnings.ts | 39 +- packages/joint-react/src/utils/scheduler.ts | 35 +- 52 files changed, 2931 insertions(+), 2291 deletions(-) create mode 100644 .changeset/react-cells-inline-selector.md create mode 100644 .changeset/react-store-callbacks.md create mode 100644 packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx delete mode 100644 packages/joint-react/src/hooks/use-are-elements-measured.ts create mode 100644 packages/joint-react/src/hooks/use-cells-state-selector.ts create mode 100644 packages/joint-react/src/hooks/use-cells.types.ts create mode 100644 packages/joint-react/src/hooks/use-graph-state-reader.ts create mode 100644 packages/joint-react/src/hooks/use-graph-state.ts rename packages/joint-react/src/{utils => selectors}/__tests__/selector-utils.test.ts (100%) rename packages/joint-react/src/selectors/__tests__/{index.test.ts => store-selectors.test.ts} (93%) rename packages/joint-react/src/{utils => selectors}/selector-utils.ts (92%) 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__/graph-state.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/graph-state.ts create mode 100644 packages/joint-react/src/store/measurement.ts delete mode 100644 packages/joint-react/src/utils/collection-subscription.ts diff --git a/.changeset/react-cells-inline-selector.md b/.changeset/react-cells-inline-selector.md new file mode 100644 index 0000000000..fc12f1aafe --- /dev/null +++ b/.changeset/react-cells-inline-selector.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - fix an inline selector that captures a changed prop returning a stale value until the next graph change diff --git a/.changeset/react-elements-measured-app-resize.md b/.changeset/react-elements-measured-app-resize.md index 9ed255334c..6c5ef0a59c 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 exactly once per settled change, when no element is still waiting to be measured +useOnElementsMeasured - fix to fire exactly once per settled change (an addition, a removal or a re-measurement), when no element is still waiting to be measured 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/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..e4341e65a0 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 @@ -1,9 +1,4 @@ -import { - useLayoutEffect, - useMemo, - type ComponentType, - type CSSProperties, -} from 'react'; +import { useLayoutEffect, useMemo, type ComponentType, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import typedMemo from '../../../utils/typed-react'; import { useGraphStore } from '../../../hooks/use-graph-store'; @@ -27,20 +22,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 +47,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-cells.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx index 69593db8d4..ee6cc6fc84 100644 --- a/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-cells.test.tsx @@ -1310,3 +1310,78 @@ describe('useCells (selector reactivity — no collection)', () => { expect(renderedIds).toEqual(['a', 'b']); }); }); + +describe('useCells — what a re-render changes', () => { + const element = (id: string | number, x: number): CellRecord => + ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, + }) as CellRecord; + const cells: readonly CellRecord[] = [element(1, 100), element('1', 200), element('a|b', 300)]; + const selectXs = (picked: readonly CellRecord[]) => + picked.map((cell) => (cell as { readonly position?: { readonly x: number } }).position?.x); + function cellsWrapper({ children }: { readonly children: React.ReactNode }) { + return {children}; + } + + // Regression: ids were joined into the key that says "same target", so the + // number 1 and the string '1' were one target and the hook kept the first. + it('tells a numeric id from the same id as a string', () => { + const { result, rerender } = renderHook( + ({ ids }: { readonly ids: ReadonlyArray }) => useCells(ids, selectXs), + { wrapper: cellsWrapper, initialProps: { ids: [1] as ReadonlyArray } } + ); + expect(result.current).toEqual([100]); + + rerender({ ids: ['1'] }); + + expect(result.current).toEqual([200]); + }); + + // Regression: the selector was read through a ref and run on a store change + // only, so an inline selector returned what the previous props selected. + it('runs an inline selector that captured a changed prop, without a graph change', () => { + const { result, rerender } = renderHook( + ({ minimumX }: { readonly minimumX: number }) => + useCells( + (all) => + all.filter( + (cell) => + ((cell as { readonly position?: { readonly x: number } }).position?.x ?? 0) >= + minimumX + ).length + ), + { wrapper: cellsWrapper, initialProps: { minimumX: 0 } } + ); + expect(result.current).toBe(3); + + rerender({ minimumX: 250 }); + + expect(result.current).toBe(1); + }); +}); + +describe('useCells dev warnings', () => { + // Regression: the warning named `useGraphState`, which useCells runs on, not + // the hook the application called. + it('names useCells when its selector returns new objects on every run', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { result } = renderHook( + () => { + useCells((cells) => cells.map((cell) => ({ id: cell.id }))); + return useGraphStore().graph; + }, + { wrapper } + ); + + await act(async () => { + (result.current.getCell('a') as dia.Element).position(5, 0); + await flush(); + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[useCells] Selector returns')); + warn.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx new file mode 100644 index 0000000000..c0e0adcd1b --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx @@ -0,0 +1,328 @@ +import React, { Suspense, startTransition, useState } from 'react'; +import { render, renderHook, act, screen } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { useGraphState } from '../use-graph-state'; +import { useGraphStore } from '../use-graph-store'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { DEFAULT_CELL_NAMESPACE } from '../../store/graph-store'; +import type { GraphState } from '../../store/graph-state'; +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 selectGraphSizingVersion = (state: GraphState) => state.sizingVersion; +const selectIsGraphSizingComplete = (state: GraphState) => state.sizingVersion > 0; +const selectCount = (state: GraphState) => state.cells.length; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const selectXOf = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectAX = selectXOf('a'); +// Custom selectors derived from the shipped ones: plain functions compose. +const selectHasSettledTwice = (state: GraphState) => selectGraphSizingVersion(state) > 1; +const selectMeasuredCount = (state: GraphState) => + selectIsGraphSizingComplete(state) ? state.cells.length : 0; + +/** Mounts a hook next to a handle on the graph, counting renders. */ +function mount(useHook: () => Result) { + let renderCount = 0; + const view = renderHook( + () => { + renderCount += 1; + return { value: useHook(), graph: useGraphStore().graph }; + }, + { wrapper } + ); + const getElement = (id: string) => view.result.current.graph.getCell(id) as dia.Element; + return { + ...view, + value: () => view.result.current.value, + graph: () => view.result.current.graph, + getElement, + renders: () => renderCount, + }; +} + + +describe('useGraphState', () => { + it('selects from the cells and re-renders when the selected value changes', async () => { + const { value, graph } = mount(() => useGraphState(selectCount)); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + + expect(value()).toBe(3); + }); + + it('does not re-render for a change that leaves the selected value equal', async () => { + const { getElement, renders } = mount(() => useGraphState(selectCount)); + const rendersBefore = renders(); + + await commit(() => getElement('a').position(30, 30)); + + expect(renders()).toBe(rendersBefore); + }); + + it('reads one cell without running its selector when another cell changes', async () => { + const selector = jest.fn(selectAX); + const { value, getElement, renders } = mount(() => useGraphState(selector)); + const rendersBefore = renders(); + const callsBefore = selector.mock.calls.length; + + await commit(() => getElement('b').position(70, 0)); + expect(selector).toHaveBeenCalledTimes(callsBefore); + expect(renders()).toBe(rendersBefore); + + await commit(() => getElement('a').position(30, 0)); + expect(value()).toBe(30); + }); + + it('keeps the selected reference stable across re-renders and equal results', async () => { + const { value, getElement, rerender } = mount(() => useGraphState(selectIds)); + const before = value(); + + rerender(); + await commit(() => getElement('a').position(30, 0)); + + expect(value()).toBe(before); + }); + + it('uses the custom equality to decide what a change is', async () => { + const { value, graph } = mount(() => useGraphState(selectCount, isSameParity)); + + await commit(() => graph().addCells([element('c'), element('d')])); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('e'))); + expect(value()).toBe(5); + }); + + // The selector is part of what the hook renders: a new one (a changed prop + // captured by an inline selector) is read at once, not on the next store change. + it('runs a new selector on re-render, without a store change', () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId }; + }, + { wrapper } + ); + expect(result.current.x).toBe(0); + + act(() => result.current.setId('b')); + + expect(result.current.x).toBe(50); + }); + + it('follows the cell a re-rendered selector reads, and drops the previous one', async () => { + const { result } = renderHook( + () => { + const [id, setId] = useState('a'); + const { graph } = useGraphStore(); + return { x: useGraphState((state) => state.getCell(id)?.position?.x), setId, graph }; + }, + { wrapper } + ); + act(() => result.current.setId('b')); + + await commit(() => (result.current.graph.getCell('b') as dia.Element).position(70, 0)); + + expect(result.current.x).toBe(70); + }); + + // Regression: a consumer mounted after the graph settled has to see the + // settled state on its first render, with no change to wake it. + it('reads the current state when mounted after the graph settled', async () => { + const { result } = renderHook( + () => { + const [isShown, setIsShown] = useState(false); + return { isShown, setIsShown }; + }, + { wrapper } + ); + await act(async () => flush()); + expect(result.current.isShown).toBe(false); + + const late = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + + expect(late.value()).toBe(true); + }); + + describe('sizing version', () => { + it('re-renders its reader once per settled change, not per commit', async () => { + const { value, getElement, graph, renders } = mount(() => + useGraphState(selectGraphSizingVersion) + ); + await act(async () => flush()); + expect(value()).toBe(1); + const rendersBefore = renders(); + + // A move is a commit, but not a measurement. + await commit(() => getElement('a').position(9, 9)); + expect(value()).toBe(1); + expect(renders()).toBe(rendersBefore); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(2); + }); + + it('selectIsGraphSizingComplete turns true once sizes settle and re-renders its reader only then', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectIsGraphSizingComplete)); + await act(async () => flush()); + expect(value()).toBe(true); + const rendersBefore = renders(); + + // A later settled change bumps the sizing version, but the diagram stays measured. + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersBefore); + + // A reset to a diagram without sizes is not measured. + await commit(() => graph().resetCells([])); + expect(value()).toBe(false); + }); + + 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(() => useGraphState(selectIsGraphSizingComplete), { + 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); + }); + + // A selector derived from a shipped one is a selector like any other. + it('re-renders a custom selector derived from selectGraphSizingVersion only when its value changes', async () => { + const { value, graph, renders } = mount(() => useGraphState(selectHasSettledTwice)); + await act(async () => flush()); + expect(value()).toBe(false); + const rendersBefore = renders(); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(true); + const rendersAfterChange = renders(); + expect(rendersAfterChange).toBeGreaterThan(rendersBefore); + + // Settles a third time: the sizing version changes, the derived value does not. + await commit(() => graph().addCell(element('d'))); + expect(value()).toBe(true); + expect(renders()).toBe(rendersAfterChange); + }); + + it('combines the sizing version with the cells in one selector', async () => { + const { value, graph } = mount(() => useGraphState(selectMeasuredCount)); + await act(async () => flush()); + expect(value()).toBe(2); + + await commit(() => graph().addCell(element('c'))); + expect(value()).toBe(3); + + await commit(() => graph().resetCells([])); + expect(value()).toBe(0); + }); + + // Regression: read through the cells, a measurement selector made every + // commit rebuild the cells array (O(n) per drag frame). A selector that + // reads only the sizing version is not even run on such a commit. + it.each<[string, () => unknown]>([ + ['useGraphState(selectGraphSizingVersion)', () => useGraphState(selectGraphSizingVersion)], + [ + 'useGraphState(selectIsGraphSizingComplete)', + () => useGraphState(selectIsGraphSizingComplete), + ], + ['useGraphState(a derived selector)', () => useGraphState(selectHasSettledTwice)], + ])('%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(); + }); + }); +}); + +// Regression: the subscription followed every read, and React reads while +// rendering. A render it never commits (here a transition that suspends) moved +// the subscription away from the cell the committed tree shows. +describe('useGraphState — a render React throws away', () => { + const NEVER = new Promise(() => {}); + let liveGraph: dia.Graph | undefined; + let startSuspendedTransition: (() => void) | undefined; + + function Suspender({ isSuspended }: Readonly<{ isSuspended: boolean }>) { + // Throwing a promise is how a component suspends. + if (isSuspended) throw NEVER; + return null; + } + function PositionReader({ id }: Readonly<{ id: string }>) { + const x = useGraphState((state: GraphState) => state.getCell(id)?.position?.x); + return {String(x)}; + } + function TransitionApp() { + liveGraph = useGraphStore().graph; + const [id, setId] = useState('a'); + const [isSuspended, setIsSuspended] = useState(false); + startSuspendedTransition = () => + startTransition(() => { + setId('b'); + setIsSuspended(true); + }); + return ( + + + + + ); + } + + it('keeps following the cell the committed tree reads', async () => { + render( + + + + ); + expect(screen.getByTestId('x').textContent).toBe('0'); + + // Renders the reader with `b`, suspends, and keeps showing `a`. + await commit(() => startSuspendedTransition?.()); + expect(screen.getByTestId('x').textContent).toBe('0'); + + await commit(() => (liveGraph?.getCell('a') as dia.Element).position(33, 0)); + expect(screen.getByTestId('x').textContent).toBe('33'); + }); +}); 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..79bb7ef3d5 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); @@ -135,7 +138,8 @@ describe('useMeasureElement', () => { expect(() => render( - @@ -172,7 +176,11 @@ describe('useMeasureElement', () => { } render( - + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -184,7 +192,11 @@ describe('useMeasureElement', () => { it('returns size record without registering when ref.current is null at mount', async () => { const { container } = render( - + ); await waitFor(() => { @@ -193,6 +205,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-elements-measured-events.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx index ac86c8f011..94b23c8d09 100644 --- a/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured-events.test.tsx @@ -1,5 +1,5 @@ /** - * Specification for when `useOnElementsMeasured` delivers an event. + * Specification for when `useOnElementsMeasured` delivers an event (#3520). * * 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 @@ -26,7 +26,7 @@ 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 { AUTO_SIZE_OPTION } from '../../store/measurement'; import type { CellRecord } from '../../types/cell.types'; import type { dia } from '@joint/core'; @@ -121,11 +121,9 @@ function renderGraph(initialCells: CellRecord[]): Harness { 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 - ); + (graph.getCell(id) as dia.Element).set('size', { width: 120, height: 40 }, { + [AUTO_SIZE_OPTION]: true, + } as object); } }); } 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 index 326e43c1fd..43a507a001 100644 --- 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 @@ -160,6 +160,49 @@ 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('useOnElementsMeasured — 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('useOnElementsMeasured — 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 @@ -194,15 +237,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 +309,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-on-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-on-elements-measured.test.tsx index e1256086a0..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 @@ -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,7 +142,43 @@ 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) { + it('does not fire when the application resizes an element', async () => { + const callback = jest.fn(); + const getGraph = renderMeasuredProbe(callback); + const getElement = () => getGraph().getCell('a') as dia.Element; + 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 getGraph = renderMeasuredProbe(callback); + const getElement = () => getGraph().getCell('a') as dia.Element; + 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); + }); + }); + + // 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 renderResizeProbe(callback: jest.Mock) { let graphRef: dia.Graph | undefined; function Probe() { const store = useGraphStore(); @@ -146,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(); @@ -161,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(); 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-cell.ts b/packages/joint-react/src/hooks/use-cell.ts index 7582bd76b3..25dc5b36f9 100644 --- a/packages/joint-react/src/hooks/use-cell.ts +++ b/packages/joint-react/src/hooks/use-cell.ts @@ -159,8 +159,8 @@ export function useCell | Selected>( diff --git a/packages/joint-react/src/hooks/use-cells-state-selector.ts b/packages/joint-react/src/hooks/use-cells-state-selector.ts new file mode 100644 index 0000000000..da565353e6 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells-state-selector.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import type { AnyCellRecord } from '../types/cell.types'; +import type { GraphStateSelector } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSource, CellsTarget } from './use-cells.types'; + +/** + * The graph state selector of a cells hook: reads what `target` names and + * hands it to `selector`. The same function for as long as the target and the + * selector are. + * @param target - What to read: all cells (`undefined`), no cell (`null`), a cell id, ids or a collection. + * @param targetKey - `toTargetKey(target)`, computed once by the caller. + * @param selector - Derives the value from what was read; the read itself when omitted. + * @returns The selector to pass to `useGraphState`. + * @internal + */ +export function useCellsStateSelector( + target: CellsTarget, + targetKey: unknown, + selector?: AnyCellsSelector +): GraphStateSelector> { + return useMemo( + () => (state) => { + let source: CellsSource; + if (target === undefined) source = state.cells; + else if (target === null) source = undefined; + else if (typeof target === 'object') source = state.getCells(target); + else source = state.getCell(target); + return selector ? selector(source) : source; + }, + // `targetKey` stands in for `target`, see `toTargetKey` in `use-cells.utils`. + // eslint-disable-next-line react-hooks/exhaustive-deps + [targetKey, selector] + ); +} diff --git a/packages/joint-react/src/hooks/use-cells.ts b/packages/joint-react/src/hooks/use-cells.ts index 5f0631785b..6222446688 100644 --- a/packages/joint-react/src/hooks/use-cells.ts +++ b/packages/joint-react/src/hooks/use-cells.ts @@ -1,115 +1,23 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; 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 { isCollection } from '../utils/is'; -import { subscribeToCollection } from '../utils/collection-subscription'; -import { parseUseCellsArgs } from './use-cells.utils'; -import { warnUnstableSelector } from '../utils/dev-warnings'; -import { toCellRecord } from '../state/data-mapping/cell-record-merge'; - -// ── Types ─────────────────────────────────────────────────────────────────── - -/** - * Union of all possible `useCells` return shapes (depends on argument form). - * `Cell` here is the already-resolved (Computed) record the helpers work with. - */ -type CellsResult = - | readonly Cell[] - | Cell - | undefined - | Selected; - -/** Equality function on raw `unknown` values. */ -type UnknownEqual = (a: unknown, b: unknown) => boolean; - -/** Selector over the resolved cells array (the array forms of `useCells`). */ -type CellsSelector = ( - cells: ReadonlyArray> -) => Selected; - -/** Equality test that short-circuits a re-render when the selected value is unchanged. */ -type SelectedEqual = (a: Selected, b: Selected) => boolean; - -// ── Module-scoped helpers ─────────────────────────────────────────────────── - -/** - * Resolves a single cell record for the collection form. Tries the graph - * container first; falls back to the collection itself for cells that exist - * outside the graph (e.g. `ui.Clipboard` clones). Fallback records are cached - * by `dia.Cell` identity so repeat renders share the same reference and the - * outer shallow-equality short-circuit can fire. - */ -function resolveCell( - id: CellId, - container: ReadonlyContainer, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): Cell | undefined { - const fromContainer = container.get(id); - if (fromContainer !== undefined) return fromContainer; - if (!collection || !fallbackCache) return undefined; - const rawCell = collection.get(id); - if (!rawCell) return undefined; - const cached = fallbackCache.get(rawCell); - if (cached !== undefined) return cached as Cell; - const record = toCellRecord(rawCell) as Cell; - fallbackCache.set(rawCell, record); - return record; -} - -/** - * Picks cells named by `subscribedIds` in order. Missing ids (not in the - * graph container and not in the optional fallback `collection`) are skipped. - */ -function pickCells( - container: ReadonlyContainer, - subscribedIds: readonly CellId[], - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined -): readonly Cell[] { - const picked: Cell[] = []; - for (const id of subscribedIds) { - const cell = resolveCell(id, container, collection, fallbackCache); - if (cell !== undefined) picked.push(cell); - } - return picked; -} - -/** - * Builds the next selector result for {@link useCells}. Module-scoped so the - * closure does not nest deeply inside {@link useCells}.select. - */ -function computeNext( - container: ReadonlyContainer, - targetId: CellId | undefined, - subscribedIds: readonly CellId[] | undefined, - collection: mvc.Collection | undefined, - fallbackCache: WeakMap | undefined, - arraySelector: ((cells: readonly Cell[]) => Selected) | undefined, - cellSelector: ((cell: Cell | undefined) => Selected) | undefined -): CellsResult { - // Single-cell-with-selector form: a nullish id resolves to no cell, so the - // selector runs against `undefined` instead of falling through to all cells. - if (cellSelector) { - const cell = - targetId === undefined - ? undefined - : resolveCell(targetId, container, collection, fallbackCache); - return cellSelector(cell); - } - if (targetId !== undefined) { - return resolveCell(targetId, container, collection, fallbackCache); - } - const source: readonly Cell[] = - subscribedIds === undefined - ? container.getSnapshot() - : pickCells(container, subscribedIds, collection, fallbackCache); - return arraySelector ? arraySelector(source) : source; -} +import type { SelectedEqual } from '../store/graph-state'; +import type { AnyCellsSelector, CellsSelector, CellsSource, CellsTarget } from './use-cells.types'; +import { useCellsStateSelector } from './use-cells-state-selector'; +import { toTargetKey } from './use-cells.utils'; +import { useSelectGraphState } from './use-graph-state'; + +export type { SelectedEqual } from '../store/graph-state'; +export type { CellsSelector } from './use-cells.types'; + +/** 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, +]; // ── Overloads ─────────────────────────────────────────────────────────────── @@ -149,8 +57,8 @@ export function useCells< /** * Subscribe to the full cells array. * - * Returned array reference is stable across data-only mutations (the internal - * container mutates items in-place). Size changes produce a new snapshot token. + * The array is a new reference on every graph change; the records in it keep + * their reference until they change themselves. * @title Subscribe to all cells * @template Cell - input cell record shape (defaults to CellRecord); reads resolve to its Computed form * @returns readonly resolved cells array @@ -261,158 +169,26 @@ 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> { - const store = useGraphStore(); - // The store holds resolved (Computed) records; the public `Cell` generic is the - // input record shape, so internally we work in `Computed`. - const container = store.graphProjection.cells as ReadonlyContainer>; - - const collectionArgument = isCollection(argument1) ? argument1 : undefined; - - const { targetId, ids, arraySelector, cellSelector, isEqual } = parseUseCellsArgs< - Computed, - Selected - >( - argument1, - argument2, - argument3 - ); - const hasSelector = arraySelector !== undefined || cellSelector !== undefined; - - const arraySelectorRef = useRef(arraySelector); - arraySelectorRef.current = arraySelector; - const cellSelectorRef = useRef(cellSelector); - cellSelectorRef.current = cellSelector; - - /** Local alias for the hook's return shape so the cache type stays readable. */ - type Result = CellsResult, Selected>; - const cachedRef = useRef<{ hasValue: boolean; value: Result }>({ - hasValue: false, - value: undefined, - }); - - const idsKey = useMemo(() => (ids ? ids.join('|') : ''), [ids]); - const idsRef = useRef(ids); - idsRef.current = ids; - - // ── Collection refs (no-op when not in collection form) ── - - const collectionIdsRef = useRef([]); - const collectionVersionRef = useRef(0); - const fallbackCacheRef = useRef>(new WeakMap()); - - // Use `null` sentinel so the init-block runs on first render too, picking up - // any models that already exist in the collection at subscribe time. - const previousCollectionRef = useRef | undefined | null>(null); - if (previousCollectionRef.current !== collectionArgument) { - previousCollectionRef.current = collectionArgument; - collectionIdsRef.current = collectionArgument - ? collectionArgument.models.map((m) => m.id as CellId) - : []; - collectionVersionRef.current++; - } - - // ── Subscribe ── - - const subscribe = useCallback( - (listener: () => void) => { - if (collectionArgument) { - return subscribeToCollection( - collectionArgument, - container, - listener, - collectionIdsRef, - collectionVersionRef - ); - } - if (targetId !== undefined) return container.subscribeById(targetId, listener); - const subscribedIds = idsRef.current; - if (subscribedIds && subscribedIds.length > 0) { - const unsubscribers = subscribedIds.map((id) => container.subscribeById(id, listener)); - return () => { - for (const unsubscribe of unsubscribers) unsubscribe(); - }; - } - return container.subscribe(listener); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey] - ); - - // ── Snapshot ── - - const getSnapshot = useCallback(() => { - // 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 - // stable between changes, so useSyncExternalStore stays consistent. - if (targetId !== undefined) return container.get(targetId); - if (collectionArgument) return collectionVersionRef.current; - // 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]); - - // ── Equality ── - - const isEqualCallback = useMemo(() => { - if (isEqual) return isEqual as unknown as UnknownEqual; - 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]); - - // ── Selector ── - - const select = useCallback( - (): Result => { - const subscribedIds = collectionArgument ? collectionIdsRef.current : idsRef.current; - const next = computeNext, Selected>( - container, - targetId, - subscribedIds, - collectionArgument, - fallbackCacheRef.current, - arraySelectorRef.current, - cellSelectorRef.current - ); - // Reuse the cached value when equal so the returned reference stays stable - // across commits that didn't affect this subscription. - if (cachedRef.current.hasValue && isEqualCallback(cachedRef.current.value, next)) { - return cachedRef.current.value; - } - if (hasSelector && cachedRef.current.hasValue) { - warnUnstableSelector('useCells', cachedRef.current.value, next, !!isEqual); - } - // 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 - // safe to return as-is. - cachedRef.current = { hasValue: true, value: next }; - return next; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [container, collectionArgument, targetId, idsKey, isEqualCallback] - ); - - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - select, - isEqualCallback +>(...args: CellsArguments): Selected | CellsSource { + const [argument1, argument2, argument3] = args; + const isSelectorFirst = typeof argument1 === 'function'; + // The overloads pair each target with its selector shape, which the argument + // unions hide from TypeScript. + const selector = (isSelectorFirst ? argument1 : argument2) as + | AnyCellsSelector + | undefined; + const hasSelector = typeof selector === 'function'; + // A nullish id next to a selector reads no cell; on its own it reads all cells. + let target: CellsTarget; + if (!isSelectorFirst) target = argument1 ?? (hasSelector ? null : undefined); + const isEqual = (isSelectorFirst ? argument2 : argument3) as + | SelectedEqual> + | undefined; + + const select = useCellsStateSelector( + target, + toTargetKey(target), + hasSelector ? selector : undefined ); + return useSelectGraphState('useCells', select, isEqual); } diff --git a/packages/joint-react/src/hooks/use-cells.types.ts b/packages/joint-react/src/hooks/use-cells.types.ts new file mode 100644 index 0000000000..1e9a485fb8 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cells.types.ts @@ -0,0 +1,24 @@ +import { type mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, Computed } from '../types/cell.types'; + +/** Selector over the resolved cells array (the array forms of `useCells`). */ +export type CellsSelector = ( + cells: ReadonlyArray> +) => Selected; + +/** + * What a cells hook is scoped to: one cell, several cells, or a JointJS + * collection. `undefined` is all cells, `null` is no cell. + */ +export type CellsTarget = CellId | null | undefined | readonly CellId[] | mvc.Collection; + +/** What a target resolves to: the cells, one cell, or nothing. */ +export type CellsSource = + | ReadonlyArray> + | Computed + | undefined; + +/** A selector of any `useCells` form; the overloads pair each target with its own shape. */ +export type AnyCellsSelector = ( + source: CellsSource +) => Selected; diff --git a/packages/joint-react/src/hooks/use-cells.utils.ts b/packages/joint-react/src/hooks/use-cells.utils.ts index c1d49c2019..05a4f4fe5d 100644 --- a/packages/joint-react/src/hooks/use-cells.utils.ts +++ b/packages/joint-react/src/hooks/use-cells.utils.ts @@ -1,129 +1,13 @@ -import { type mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import { isCollection } from '../utils/is'; - -/** Normalised arguments after dispatching by the runtime call shape. */ -export interface ParsedCellsArgs { - readonly targetId: CellId | undefined; - readonly ids: readonly CellId[] | undefined; - readonly isCollectionForm: boolean; - readonly arraySelector: ((cells: readonly Cell[]) => Selected) | undefined; - readonly cellSelector: ((cell: Cell | undefined) => Selected) | undefined; - readonly isEqual: ((a: Selected, b: Selected) => boolean) | undefined; -} - -/** Builds default parsed result with no selectors. */ -function defaultParsedArgs( - targetId: CellId | undefined, - ids?: readonly CellId[] -): ParsedCellsArgs { - return { - targetId, - ids, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: undefined, - isEqual: undefined, - }; -} - -/** - * Parses the collection form: `useCells(collection)` or `useCells(collection, selector, isEqual?)`. - */ -function parseCollectionArgs( - argument2: unknown, - argument3: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: true, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: - typeof argument3 === 'function' - ? (argument3 as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} - -/** Parses the `useCells(selector, isEqual?)` form. */ -function parseSelectorFirstArgs( - selectorArgument: (cells: readonly Cell[]) => Selected, - isEqualArgument: unknown -): ParsedCellsArgs { - return { - targetId: undefined, - ids: undefined, - isCollectionForm: false, - arraySelector: selectorArgument, - cellSelector: undefined, - isEqual: - typeof isEqualArgument === 'function' - ? (isEqualArgument as (a: Selected, b: Selected) => boolean) - : undefined, - }; -} +import type { CellsTarget } from './use-cells.types'; /** - * Classifies the runtime arguments of {@link useCells} into a normalised shape so - * the hook body can stay flat and cheap to read. - * @param argument1 - first positional arg (id, ids, collection, or selector) - * @param argument2 - second positional arg (selector or isEqual depending on form) - * @param argument3 - third positional arg (isEqual when the form admits it) - * @returns the normalised input + * A stable stand-in for a target in a dependency list: ids passed inline are a + * new array on every render. Serialised rather than joined, so that `[1]`, + * `['1']` and the single id `'1'` stay apart. + * @param target - The target of a cells hook. + * @returns The serialised ids of an ids target, otherwise the target itself. + * @internal */ -export function parseUseCellsArgs( - argument1?: - | CellId - | null - | readonly CellId[] - | ((cells: readonly Cell[]) => Selected) - | mvc.Collection, - argument2?: - | ((cells: readonly Cell[]) => Selected) - | ((cell: Cell | undefined) => Selected) - | ((a: Selected, b: Selected) => boolean), - argument3?: (a: Selected, b: Selected) => boolean -): ParsedCellsArgs { - if (isCollection(argument1)) { - return parseCollectionArgs(argument2, argument3); - } - if (typeof argument1 === 'function') { - return parseSelectorFirstArgs(argument1, argument2); - } - - if (Array.isArray(argument1)) { - return { - targetId: undefined, - ids: argument1 as readonly CellId[], - isCollectionForm: false, - arraySelector: - typeof argument2 === 'function' - ? (argument2 as (cells: readonly Cell[]) => Selected) - : undefined, - cellSelector: undefined, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - - // Single-cell form: an explicit id, or a nullish id treated as "no match" - // (the selector then receives `undefined`). A nullish id without a selector - // collapses to the no-arg full-array form. - const targetId: CellId | undefined = - argument1 === undefined || argument1 === null ? undefined : (argument1 as CellId); - if (typeof argument2 === 'function') { - return { - targetId, - ids: undefined, - isCollectionForm: false, - arraySelector: undefined, - cellSelector: argument2 as (cell: Cell | undefined) => Selected, - isEqual: typeof argument3 === 'function' ? argument3 : undefined, - }; - } - return defaultParsedArgs(targetId); +export function toTargetKey(target: CellsTarget): unknown { + return Array.isArray(target) ? JSON.stringify(target) : target; } 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-graph-state-reader.ts b/packages/joint-react/src/hooks/use-graph-state-reader.ts new file mode 100644 index 0000000000..164bd0d960 --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state-reader.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useGraphStore } from './use-graph-store'; +import type { AnyCellRecord } from '../types/cell.types'; +import { createGraphStateReader, type GraphStateReader } from '../store/graph-state'; + +/** + * The reader behind {@link useGraphState}, one + * per hook instance and per graph store. + * @param hookName - The hook the application called, named in dev warnings. + * @returns The reader of the closest graph store. + * @internal + */ +export function useGraphStateReader( + hookName: string +): GraphStateReader { + const store = useGraphStore(); + // Should React drop this memo, the new reader selects afresh: correct, at the + // cost of one new reference for a selected array or object. + return useMemo(() => createGraphStateReader(store, hookName), [store, hookName]); +} diff --git a/packages/joint-react/src/hooks/use-graph-state.ts b/packages/joint-react/src/hooks/use-graph-state.ts new file mode 100644 index 0000000000..6da39ad09a --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-state.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import type { AnyCellRecord, CellRecord } from '../types/cell.types'; +import type { GraphStateSelector, SelectedEqual } from '../store/graph-state'; +import { useGraphStateReader } from './use-graph-state-reader'; + +/** + * Subscribe to a value selected from the graph state: the cells and the + * sizing version. The component re-renders when the selected value changes. + * The hook subscribes to exactly what the selector reads; `useCells` is built + * on it. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. Defaults to a shallow, + * array-aware comparison that falls back to `Object.is` for other values. + * @returns The selected value, the same reference for as long as it is equal. + * @internal + */ +export function useGraphState( + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + return useSelectGraphState('useGraphState', selector, isEqual); +} + +/** + * {@link useGraphState} for a hook built on it, which its dev warnings name. + * @param hookName - The hook the application called. + * @param selector - Derives the value from the graph state. + * @param isEqual - Equality test used to skip re-renders. + * @returns The selected value, the same reference for as long as it is equal. + * @internal + */ +export function useSelectGraphState( + hookName: string, + selector: GraphStateSelector, + isEqual?: SelectedEqual +): Selected { + const reader = useGraphStateReader(hookName); + const getSnapshot = useCallback( + () => reader.read(selector, isEqual), + [reader, selector, isEqual] + ); + const selected = useSyncExternalStore(reader.subscribe, getSnapshot, getSnapshot); + // A committed selector may read other parts of the state than the one before + // it. The subscription follows it here, never while rendering: a render that + // React throws away must leave it where the committed one needs it. Declared + // after the store subscription, so on mount it runs once that is in place. + useEffect(() => { + reader.sync(selector, isEqual); + }, [reader, selector, isEqual]); + return selected; +} 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-elements-measured.ts b/packages/joint-react/src/hooks/use-on-elements-measured.ts index 0715133e97..bb12f00745 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 @@ -33,9 +34,8 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; /** * Calls a callback once element sizes are known, so a layout can run on them. * - * 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. An added element waits until the paper has rendered it; if + * Delivers one event per settled change: the first pass, and each later + * addition, removal or re-measurement, once no element is 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 @@ -46,8 +46,9 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * 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 - * after a graph reset (`resetCells()`), which replaces the diagram. + * for the first event after the hook mounts, and again for the first event + * after a graph reset (`resetCells()`), which replaces the diagram, or after + * the last element was removed. * @title On the current paper * @param callback - Called each time element sizes are measured. * @group Hooks @@ -98,34 +99,35 @@ 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; + // 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 = measurement.getSizingVersion(); + 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 +137,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 measurement.subscribe(handleChanges); + }, [paperStore, measurement, graph, callbackRef]); } diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 9ae4148f34..368aabb4f4 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() 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__/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..9907166368 100644 --- a/packages/joint-react/src/selectors/index.ts +++ b/packages/joint-react/src/selectors/index.ts @@ -1,25 +1,5 @@ -import type { GraphStoreInternalSnapshot } from '../store/graph-store'; - -// ── Public cell selectors ─────────────────────────────────────────────────── - +// Cell selectors (`selectCell*`, `selectElement*`), passed to `useCell` / `useCells`. export * from './cell-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/utils/selector-utils.ts b/packages/joint-react/src/selectors/selector-utils.ts similarity index 92% rename from packages/joint-react/src/utils/selector-utils.ts rename to packages/joint-react/src/selectors/selector-utils.ts index 82c20771c7..a78c635a13 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; @@ -55,7 +51,7 @@ export function isPositionEqual(a?: ElementPosition, b?: ElementPosition): boole * @param b - second array * @returns true when both arrays have the same length and the same element refs */ -export function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { +function areArraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { if (a === b) return true; if (a.length !== b.length) return false; for (const [index, value] of a.entries()) { 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..05c5b1ebc3 100644 --- a/packages/joint-react/src/store/__tests__/clear-view.test.ts +++ b/packages/joint-react/src/store/__tests__/clear-view.test.ts @@ -1,11 +1,5 @@ import { dia } from '@joint/core'; -import { - clearConnectedLinkViews, - executeClearViewForCell, - mergeClearViewValidators, - shouldClearLink, - type ClearViewCacheEntry, -} from '../clear-view'; +import { clearConnectedLinkViews, shouldClearLink } from '../clear-view'; import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; function createGraph(): dia.Graph { @@ -26,55 +20,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', @@ -84,7 +29,6 @@ function makeLink(sourceId: string, targetId: string): dia.Link { } describe('shouldClearLink', () => { - it('returns true when source matches', () => { const link = makeLink('a', 'b'); expect(shouldClearLink(link, 'a')).toBe(true); @@ -179,7 +123,9 @@ describe('clearConnectedLinkViews', () => { graph.addCell(link); // Override `findView` to simulate the no-view-registered case. - link.findView = jest.fn(() => undefined as unknown as dia.LinkView) as unknown as typeof link.findView; + link.findView = jest.fn( + () => undefined as unknown as dia.LinkView + ) as unknown as typeof link.findView; const paper = { findViewByModel: jest.fn(), @@ -226,74 +172,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-state.test.ts b/packages/joint-react/src/store/__tests__/graph-state.test.ts new file mode 100644 index 0000000000..dbc9d0d405 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/graph-state.test.ts @@ -0,0 +1,385 @@ +import { dia, mvc } from '@joint/core'; +import { GraphStore } from '../graph-store'; +import { createGraphStateReader, type GraphState } from '../graph-state'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import { AUTO_SIZE_OPTION } from '../measurement'; +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 flush = () => new Promise((resolve) => queueMicrotask(resolve)); +const selectX = (id: string) => (state: GraphState) => state.getCell(id)?.position?.x; +const selectCount = (state: GraphState) => state.cells.length; +const selectState = (state: GraphState) => state; +const selectMeasured = (state: GraphState) => state.sizingVersion; +const selectVersionAndWidth = (state: GraphState) => + `${state.sizingVersion > 0}:${state.getCell('a')?.size?.width}`; +const selectIds = (state: GraphState) => state.cells.map((cell) => cell.id); +const selectPicked = (state: GraphState) => + state.getCells(['b', 'missing', 'a']).map((cell) => cell.id); +const selectAAndLate = (state: GraphState) => state.getCells(['a', 'late']); +/** Reads the cells only once the diagram is measured. */ +const selectCountOnceMeasured = (state: GraphState) => + state.sizingVersion > 0 ? state.cells.length : -1; +const selectAndThrow = (state: GraphState) => { + state.getCell('b'); + throw new Error('selector failed'); +}; +const selectMemberIds = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection).map((cell) => cell.id); +const selectMembers = (collection: mvc.Collection) => (state: GraphState) => + state.getCells(collection); +const isSameParity = (a: number, b: number) => a % 2 === b % 2; +const MANY_IDS = Array.from({ length: 50 }, (_, index) => `n${index}`); +const MANY_ELEMENTS = MANY_IDS.map((id) => element(id)); +const createStore = () => new GraphStore({ initialCells: [element('a'), element('b', 50)] }); +/** Changes the graph and drains the store's microtask commit. */ +const commit = async (run: () => void) => { + run(); + await flush(); +}; + +describe('createGraphStateReader', () => { + let store: ReturnType; + const getElement = (id: string) => store.graph.getCell(id) as dia.Element; + + beforeEach(() => { + store = createStore(); + }); + afterEach(() => store.destroy(false)); + + describe('read', () => { + it('selects from all cells, one cell, several cells and the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + + expect(reader.read(selectCount)).toBe(2); + expect(reader.read(selectX('b'))).toBe(50); + expect(reader.read(selectX('missing'))).toBeUndefined(); + expect(reader.read(selectPicked)).toEqual(['b', 'a']); + expect(reader.read(selectMeasured)).toBe(store.measurement.getSizingVersion()); + }); + + it('does not run the selector again while what it read is unchanged', async () => { + const reader = createGraphStateReader(store); + const selector = jest.fn(selectX('a')); + + reader.read(selector); + reader.read(selector); + // Another cell changed: this selector read only `a`. + await commit(() => getElement('b').position(70, 0)); + reader.read(selector); + + expect(selector).toHaveBeenCalledTimes(1); + }); + + // Regression: the render-to-subscribe gap. Staleness is tested against the + // store, so a change is seen without any subscription delivering it. + it('sees a change that happened while nothing was subscribed', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selector)).toBe(30); + }); + + it('runs a new selector function even when the store did not change', () => { + const reader = createGraphStateReader(store); + + expect(reader.read(selectX('a'))).toBe(0); + expect(reader.read(selectX('b'))).toBe(50); + }); + + it('returns the previous reference while the new value is equal', async () => { + const reader = createGraphStateReader(store); + const before = reader.read(selectIds); + await commit(() => getElement('a').position(30, 0)); + + expect(reader.read(selectIds)).toBe(before); + }); + + it('uses a custom equality', async () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCells([element('c'), element('d')])); + expect(reader.read(selectCount, isSameParity)).toBe(2); + await commit(() => store.graph.addCell(element('e'))); + expect(reader.read(selectCount, isSameParity)).toBe(5); + }); + + it('recovers after a selector that throws', () => { + const reader = createGraphStateReader(store); + expect(reader.read(selectX('a'))).toBe(0); + + expect(() => reader.read(selectAndThrow)).toThrow('selector failed'); + expect(reader.read(selectX('a'))).toBe(0); + }); + }); + + // The reader follows what a selector reads while it runs. A state kept and + // read later would silently never update, so it says so. + it('warns, and follows nothing, when the state is read outside of a selector', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const leaked = reader.read(selectState); + reader.subscribe(listener); + + expect(leaked.getCell('a')?.id).toBe('a'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside of its selector')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('subscribe', () => { + it('subscribes only to what the selector read', async () => { + const { cells } = store.graphProjection; + const subscribeToAll = jest.spyOn(cells, 'subscribe'); + const subscribeById = jest.spyOn(cells, 'subscribeById'); + const subscribeToMeasurement = jest.spyOn(store.measurement, 'subscribe'); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + + reader.read(selectX('a')); + const unsubscribe = reader.subscribe(listener); + + expect(subscribeById).toHaveBeenCalledTimes(1); + expect(subscribeById).toHaveBeenCalledWith('a', expect.any(Function)); + expect(subscribeToAll).not.toHaveBeenCalled(); + expect(subscribeToMeasurement).not.toHaveBeenCalled(); + + await commit(() => getElement('b').position(70, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + await commit(() => getElement('a').position(40, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Regression: the sizing version was announced in the same scheduler round + // as the measured write, ahead of the projection commit of that write, so + // a selector reading cells next to the version saw the pre-measure sizes. + it('announces a new sizing version once the measured sizes are in the cells', async () => { + await flush(); + const reader = createGraphStateReader(store); + const seen: string[] = []; + reader.sync(selectVersionAndWidth); + reader.subscribe(() => seen.push(reader.sync(selectVersionAndWidth))); + + await commit(() => { + store.graph.startBatch('auto-size'); + getElement('a').set('size', { width: 99, height: 10 }, { [AUTO_SIZE_OPTION]: true }); + store.graph.stopBatch('auto-size'); + }); + await flush(); + + expect(seen).not.toContain('true:10'); + expect(seen.at(-1)).toBe('true:99'); + }); + + it('is not notified of a cell change when the selector reads the sizing version', async () => { + await flush(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMeasured); + reader.subscribe(listener); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + + await commit(() => store.graph.addCell(element('c'))); + expect(listener).toHaveBeenCalled(); + }); + + // Regression: the render-to-subscribe gap, with the subscription in place + // by the time of the next read. Nothing notified it of the change. + it('sees a change that happened between the read and the subscription', async () => { + const reader = createGraphStateReader(store); + const selector = selectX('a'); + expect(reader.read(selector)).toBe(0); + + await commit(() => getElement('a').position(30, 0)); + reader.subscribe(jest.fn()); + + expect(reader.read(selector)).toBe(30); + }); + + it('moves the subscription when the selector reads something else', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectX('a')); + reader.subscribe(listener); + + reader.sync(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).not.toHaveBeenCalled(); + await commit(() => getElement('b').position(70, 0)); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Regression: a read alone moved the subscription. React reads while + // rendering, and a render it throws away (a transition, a suspended tree) + // left the committed selector without its notifications. + it('keeps the subscription where it is on a read that does not sync', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const committed = selectX('a'); + reader.read(committed); + reader.subscribe(() => { + listener(); + reader.read(committed); + }); + + // What a discarded render does: reads with another selector, and no more. + reader.read(selectX('b')); + + await commit(() => getElement('a').position(30, 0)); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(committed)).toBe(30); + }); + + it('follows a selector that reads conditionally', async () => { + const emptyStore = new GraphStore({}); + const reader = createGraphStateReader(emptyStore); + const listener = jest.fn(); + const selector = selectCountOnceMeasured; + expect(reader.read(selector)).toBe(-1); + reader.subscribe(() => { + listener(); + reader.read(selector); + }); + + // Measured now: the listener's read picks up the cells as a second source. + emptyStore.graph.addCells([element('a'), element('b')]); + await flush(); + expect(reader.read(selector)).toBe(2); + listener.mockClear(); + + emptyStore.graph.getCell('a').set('position', { x: 30, y: 0 }); + await flush(); + expect(listener).toHaveBeenCalled(); + emptyStore.destroy(false); + }); + + // Regression: every notification scanned all the watched cells, so one + // commit moving k of N watched cells cost k scans of N. + it('reads each watched cell a constant number of times when all of them change at once', async () => { + const ids = MANY_IDS; + store.graph.addCells(MANY_ELEMENTS); + await flush(); + const reader = createGraphStateReader(store); + const selector = jest.fn((state: GraphState) => state.getCells(ids)); + reader.read(selector); + // What a subscribed hook does: read again on every notification. + reader.subscribe(() => reader.read(selector)); + selector.mockClear(); + const getCell = jest.spyOn(store.graphProjection.cells, 'get'); + + await commit(() => { + store.graph.startBatch('move'); + for (const id of ids) getElement(id).position(5, 5); + store.graph.stopBatch('move'); + }); + + expect(selector).toHaveBeenCalledTimes(1); + // One lookup per notification and one per id in the selector run, not ids². + expect(getCell.mock.calls.length).toBeLessThanOrEqual(ids.length * 3); + }); + + it('is notified when a missing cell it asked for is added', async () => { + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectAAndLate; + expect(reader.read(selector)).toHaveLength(1); + reader.subscribe(listener); + + await commit(() => store.graph.addCell(element('late'))); + + expect(listener).toHaveBeenCalled(); + expect(reader.read(selector)).toHaveLength(2); + }); + }); + + describe('collections', () => { + it('reads the members of a collection, in its order, and follows membership', () => { + const collection = new mvc.Collection([getElement('b')]); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual(['b']); + reader.subscribe(listener); + + collection.add(getElement('a')); + expect(listener).toHaveBeenCalledTimes(1); + expect(reader.read(selector)).toEqual(['b', 'a']); + + collection.remove(getElement('b')); + expect(reader.read(selector)).toEqual(['a']); + + collection.reset([]); + expect(reader.read(selector)).toEqual([]); + }); + + // Regression: membership was tracked by a counter the subscription bumped, + // so a change before the subscription existed was missed. + it('sees a membership change that happened while nothing was subscribed', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const selector = selectMemberIds(collection); + expect(reader.read(selector)).toEqual([]); + + collection.add(getElement('a')); + + expect(reader.read(selector)).toEqual(['a']); + }); + + it('is notified once for several cells added together', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + reader.subscribe(listener); + + collection.add([getElement('a'), getElement('b')]); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('reads a member the graph does not hold from the collection, as one stable record', () => { + const detached = new dia.Element({ id: 'clone', type: ELEMENT_MODEL_TYPE }); + const collection = new mvc.Collection([detached]); + const reader = createGraphStateReader(store); + const first = reader.read(selectMembers(collection)); + const second = reader.read(selectMembers(collection)); + + expect(first.map((cell) => (cell as CellRecord).id)).toEqual(['clone']); + expect(second[0]).toBe(first[0]); + }); + + it('stops listening to the collection on unsubscribe', () => { + const collection = new mvc.Collection(); + const reader = createGraphStateReader(store); + const listener = jest.fn(); + reader.read(selectMemberIds(collection)); + const unsubscribe = reader.subscribe(listener); + + unsubscribe(); + collection.add(getElement('a')); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); 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..6d4dcfdc04 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 sizingVersion = (store: { + readonly measurement: { readonly getSizingVersion: () => number }; +}) => store.measurement.getSizingVersion(); 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(sizingVersion(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(sizingVersion(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(sizingVersion(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(sizingVersion(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(sizingVersion(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(sizingVersion(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', @@ -294,6 +315,29 @@ describe('GraphStore', () => { }); }); + describe('removing a paper', () => { + // Regression: only papers hold an outstanding element back, and nothing + // re-checked the delivery when the last one went away, so the sizing + // version stayed `0` until the next graph change. + it('settles the elements that waited for the last paper', async () => { + const store = new GraphStore({}); + const { remove } = store.addPaper('p1', { paperOptions: {} }); + store.graph.addCell({ + id: 'a', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, + }); + await flush(); + expect(sizingVersion(store)).toBe(0); + + remove(); + await flush(); + expect(sizingVersion(store)).toBe(1); + store.destroy(false); + }); + }); + describe('destroy', () => { it('clears features, papers, and internal state', () => { const store = new GraphStore({}); @@ -317,7 +361,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..2d7b574b6a --- /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 }); + /** The sizing version right now. */ + const version = () => measurement.getSizingVersion(); + 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.getSizingVersion()).toBe(1); + + graph.getCell('a').remove(); + await flush(); + // One element is left, so the diagram is still measured. + expect(measurement.getSizingVersion()).toBe(2); + + graph.getCell('b').remove(); + await flush(); + expect(measurement.getSizingVersion()).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..01996c7cc1 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. @@ -236,6 +232,12 @@ export function graphProjection< } } + // 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(); + // Two commit modes, decided by `deferCommit` (see graph-changes): // // - Plain batches (interactive drags, `auto-size`, layout) and lone edits @@ -261,8 +263,6 @@ export function graphProjection< changed!.clear(); removed!.clear(); } - - changes.clear(); }, }); diff --git a/packages/joint-react/src/store/graph-state.ts b/packages/joint-react/src/store/graph-state.ts new file mode 100644 index 0000000000..4d20a8e8b9 --- /dev/null +++ b/packages/joint-react/src/store/graph-state.ts @@ -0,0 +1,339 @@ +import { mvc, type dia } from '@joint/core'; +import type { AnyCellRecord, CellId, CellRecord, Computed } from '../types/cell.types'; +import type { GraphStore } from './graph-store'; +import type { ReadonlyContainer } from './state-container'; +import { toCellRecord } from '../state/data-mapping/cell-record-merge'; +import { isCollection } from '../utils/is'; +import { warnGraphStateReadOutsideSelector, warnUnstableSelector } from '../utils/dev-warnings'; +import { arrayAwareEqual } from '../selectors/selector-utils'; + +/** + * The state of a graph, as a selector of {@link useGraphState} sees it. Read + * it inside the selector only: the hook subscribes to exactly what the + * selector read, so a selector that reads one cell is not run when another + * cell changes, and one that reads `sizingVersion` is not run on a drag. + * @template Cell - Input cell record shape (defaults to CellRecord); reads resolve to its Computed form. + * @internal + */ +export interface GraphState { + /** All cells. A new array on every graph change, so a selector reading it runs on each of them. */ + readonly cells: ReadonlyArray>; + /** + * One cell by id, or `undefined` when the graph does not hold it. + * @param id - The id of the cell to read. + */ + readonly getCell: (id: CellId) => Computed | undefined; + /** + * The cells with the given ids, or the cells of a JointJS collection, in + * that order. Cells the graph does not hold are skipped, except the members + * of a collection, which are read from the collection itself. + * @param target - Cell ids, or a `mvc.Collection` of cells. + */ + readonly getCells: ( + target: readonly CellId[] | mvc.Collection + ) => ReadonlyArray>; + /** + * `0` while the element sizes are not known, otherwise a number that changes + * each time they settle. See {@link createMeasurement}. + */ + readonly sizingVersion: number; +} + +/** + * Derives a value from the {@link GraphState}. + * @internal + */ +export type GraphStateSelector = ( + state: GraphState +) => Selected; + +/** + * Equality test that tells whether a newly selected value is a change. + * @group Types + */ +export type SelectedEqual = (a: Selected, b: Selected) => boolean; + +const ALL_CELLS = Symbol('cells'); +const SIZING_VERSION = Symbol('sizingVersion'); +/** Membership and order of a collection; `sort` reorders without adding or removing. */ +const COLLECTION_EVENTS = 'update reset sort'; + +/** A part of the graph state a selector can read: all cells, the sizing version, a collection or one cell. */ +type Source = typeof ALL_CELLS | typeof SIZING_VERSION | mvc.Collection | CellId; + +/** The parts of a {@link GraphStore} the reader reads, whatever its cell shapes are. */ +interface GraphStateStore { + readonly graphProjection: { readonly cells: ReadonlyContainer }; + readonly measurement: Pick; +} + +interface Subscription { + readonly listener: () => void; + readonly unsubscribers: Map void>; +} + +const collectionIds = new WeakMap, readonly CellId[]>(); +const detachedRecords = new WeakMap(); + +/** + * The ids of a collection's cells, the same array for as long as the + * membership and order are. O(members) per call, nothing allocated while unchanged. + * @param collection - The collection to read. + */ +function getCollectionIds(collection: mvc.Collection): readonly CellId[] { + const { models } = collection; + const known = collectionIds.get(collection); + if (known?.length === models.length) { + let isSame = true; + for (const [index, model] of models.entries()) { + if (model.id !== known[index]) { + isSame = false; + break; + } + } + if (isSame) return known; + } + const ids = models.map((model) => model.id); + collectionIds.set(collection, ids); + return ids; +} + +/** + * The record of a collection member the graph does not hold (a `ui.Clipboard` + * clone, for example), one per cell so repeated reads share a reference. + * @param collection - The collection the cell is a member of. + * @param id - The id of the cell. + */ +function getDetachedRecord( + collection: mvc.Collection, + id: CellId +): AnyCellRecord | undefined { + const cell = collection.get(id); + if (!cell) return undefined; + let record = detachedRecords.get(cell); + if (!record) { + record = toCellRecord(cell); + detachedRecords.set(cell, record); + } + return record; +} + +/** + * Reads a selected value from a graph store and keeps track of what the + * selector read, so that it is run again, and its subscriber notified, only + * when one of those parts changes. One reader serves one hook instance. + * + * The staleness test compares what the selector saw with the store itself, not + * with what the subscription delivered, so a change that lands between a + * render and its subscription is not missed. + * @param store - The graph store to read. + * @param hookName - The hook the application called, named in dev warnings. + * @returns `read`, `sync` and `subscribe`, all identity-stable. + * @internal + */ +export function createGraphStateReader( + store: GraphStateStore, + hookName = 'useGraphState' +) { + // The store holds resolved (Computed) records of the application's own cell + // shape; the `Cell` generic is how the caller declares that shape. + const cells = store.graphProjection.cells as ReadonlyContainer>; + const { measurement } = store; + // What the last selector run read, with the value it saw. + const seen = new Map(); + let subscription: Subscription | undefined; + // The selector `seen` belongs to. Unset from the start of a run until it + // returns, so a selector that throws leaves nothing to trust. + let lastSelector: GraphStateSelector | undefined; + let selected: { readonly value: Selected } | undefined; + // True only while a selector runs: the state is not to be read at any other time. + let isSelecting = false; + // True while the subscription listens to exactly what `seen` holds. Then a + // change cannot go unreported, and `isDirty` says whether one was: a read + // needs no scan of `seen`. Without it, k changed cells out of N watched ones + // cost k scans of N per commit. + let isListeningToSeen = false; + let isDirty = false; + + const current = (source: Source): unknown => { + if (source === ALL_CELLS) return cells.getSnapshot(); + if (source === SIZING_VERSION) return measurement.getSizingVersion(); + if (typeof source === 'object') return getCollectionIds(source); + return cells.get(source); + }; + + const subscribeTo = (source: Source, notify: () => void): (() => void) => { + // Compares its own source only: once a selector run has seen the new + // values, the rest of the notifications of the same commit find no change. + const listener = () => { + if (!Object.is(current(source), seen.get(source))) isDirty = true; + notify(); + }; + if (source === ALL_CELLS) return cells.subscribe(listener); + if (source === SIZING_VERSION) return measurement.subscribe(listener); + if (typeof source === 'object') { + const controller = new mvc.Listener(); + controller.listenTo(source, COLLECTION_EVENTS, listener); + return () => controller.stopListening(); + } + return cells.subscribeById(source, listener); + }; + + const hasChanged = (): boolean => { + if (isListeningToSeen && !isDirty) return false; + for (const [source, value] of seen) { + if (!Object.is(current(source), value)) return true; + } + isDirty = false; + return false; + }; + + /** Makes the subscription listen to what the last run read, and to nothing else. */ + const syncSubscription = ({ listener, unsubscribers }: Subscription): void => { + for (const [source, unsubscribe] of unsubscribers) { + if (seen.has(source)) continue; + unsubscribe(); + unsubscribers.delete(source); + } + for (const source of seen.keys()) { + if (unsubscribers.has(source)) continue; + unsubscribers.set(source, subscribeTo(source, listener)); + // It may have changed between being read and being listened to. + isDirty = true; + } + isListeningToSeen = true; + }; + + /** Records what a selector read. Outside of a selector run nothing would follow it. */ + const see = (source: Source, value: unknown): void => { + if (isSelecting) seen.set(source, value); + else warnGraphStateReadOutsideSelector(); + }; + + const getCell = (id: CellId): Computed | undefined => { + const cell = cells.get(id); + see(id, cell); + return cell; + }; + + const state: GraphState = { + get cells() { + const snapshot = cells.getSnapshot(); + see(ALL_CELLS, snapshot); + return snapshot; + }, + get sizingVersion() { + const sizingVersion = measurement.getSizingVersion(); + see(SIZING_VERSION, sizingVersion); + return sizingVersion; + }, + getCell, + getCells(target) { + let collection: mvc.Collection | undefined; + let ids: readonly CellId[]; + if (isCollection(target)) { + collection = target; + ids = getCollectionIds(target); + see(target, ids); + } else { + ids = target; + } + const picked: Array> = []; + for (const id of ids) { + // A detached record is of the application's cell shape too, like the + // records of the container above. + const cell = + getCell(id) ?? + (collection && (getDetachedRecord(collection, id) as Computed | undefined)); + if (cell !== undefined) picked.push(cell); + } + return picked; + }, + }; + + const read = ( + selector: GraphStateSelector, + isEqual: SelectedEqual = arrayAwareEqual + ): Selected => { + if (selected && selector === lastSelector && !hasChanged()) return selected.value; + lastSelector = undefined; + isListeningToSeen = false; + seen.clear(); + // Stays set if the selector throws: a read outside of a selector then goes + // unreported until the next run, which is all that is lost. + isSelecting = true; + const next = selector(state); + isSelecting = false; + lastSelector = selector; + isDirty = false; + if (selected) { + if (isEqual(selected.value, next)) return selected.value; + warnUnstableSelector(hookName, selected.value, next, isEqual !== arrayAwareEqual); + } + selected = { value: next }; + return next; + }; + + return { + /** + * The value `selector` selects. The selector runs when it is a new function + * or when something its last run read has changed; otherwise, and whenever + * the new value is equal to the last one, the last value is returned as is. + * + * Safe to call while rendering: it leaves the subscription alone, so a + * render React throws away cannot move it. See {@link sync}. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + read, + /** + * Reads like {@link read}, then makes the subscription listen to what that + * run read. Call it with the selector that is in effect, outside of render: + * once it has committed, or when a notification arrives. + * @param selector - derives the value from the graph state + * @param isEqual - tells whether a newly selected value is a change + * @returns the selected value, reference-stable while it is equal + */ + sync( + selector: GraphStateSelector, + isEqual?: SelectedEqual + ): Selected { + const value = read(selector, isEqual); + if (subscription && lastSelector && !isListeningToSeen) syncSubscription(subscription); + return value; + }, + /** + * Calls `listener` when something the selector read may have changed. The + * listener is expected to read again with the selector in effect; the + * subscription then moves to whatever that run read. + * @param listener - called on a change, synchronously with the store + * @returns the unsubscribe function + */ + subscribe(listener: () => void): () => void { + const own: Subscription = { + listener: () => { + listener(); + // The listener has read with the selector in effect: follow it. Not + // after unsubscribing, which the listener itself may have done. + if (subscription === own && lastSelector && !isListeningToSeen) syncSubscription(own); + }, + unsubscribers: new Map(), + }; + subscription = own; + syncSubscription(own); + return () => { + for (const unsubscribe of own.unsubscribers.values()) unsubscribe(); + own.unsubscribers.clear(); + if (subscription !== own) return; + subscription = undefined; + isListeningToSeen = false; + }; + }, + }; +} + +/** Reader returned by {@link createGraphStateReader}. */ +export type GraphStateReader = ReturnType< + typeof createGraphStateReader +>; diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 22d745f31f..e1837b4a76 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) { @@ -292,19 +140,6 @@ export class GraphStore< // (e.g. stencil drag's cloneView). 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 +205,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(); } @@ -465,6 +297,7 @@ export class GraphStore< const paperStore = this.paperStores.get(id); paperStore?.destroy(); this.paperStores.delete(id); + this.measurement.onPaperRemoved(); this.internalState.setState((previous) => { const newPapers: Record = {}; for (const [key, value] of Object.entries(previous.papers)) { @@ -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..f07a313a3a --- /dev/null +++ b/packages/joint-react/src/store/measurement.ts @@ -0,0 +1,373 @@ +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'; + +/** + * `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 useOnElementsMeasured}). + * + * The sizing version 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 `lastVersion`, which + // only grows, so a reset followed by a pass never repeats a value. + let version = 0; + let lastVersion = 0; + const versionListeners = new Set<() => void>(); + const subscribe = (versionListener: () => void) => { + versionListeners.add(versionListener); + return () => { + versionListeners.delete(versionListener); + }; + }; + const setVersion = (next: number) => { + version = next; + for (const versionListener of versionListeners) versionListener(); + }; + 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 (version > 0) setVersion(0); + return; + } + hasUndeliveredChange = false; + lastVersion += 1; + setVersion(lastVersion); + }; + // One round later than the graph events that settle an element: the + // projection commits those events in the round they are queued in, and a + // subscriber of the new version reads the committed cells. + const deliverNextRound = () => simpleScheduler(deliver); + const scheduleDelivery = () => simpleScheduler(deliverNextRound); + 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) => { + 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. + 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. + elementCount = 0; + outstanding.clear(); + hasUndeliveredChange = false; + if (version > 0) setVersion(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 = {}) => { + 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 sizing version: `0` while nothing is measured, otherwise a number + * that changes once per settled change. See {@link useOnElementsMeasured}. + */ + getSizingVersion: () => version, + /** + * Calls `versionListener` each time the sizing version changes. + * @param versionListener - called synchronously with the change + * @returns the unsubscribe function + */ + subscribe, + /** + * 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); + } + }, + /** + * A paper was removed. Only papers hold outstanding elements back, so with + * the last one gone the event is due. Checked on the next pass, not now: a + * paper re-created right away (StrictMode, a hot reload) keeps them waiting. + */ + onPaperRemoved: scheduleDelivery, + /** 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/collection-subscription.ts b/packages/joint-react/src/utils/collection-subscription.ts deleted file mode 100644 index 14d267b3e5..0000000000 --- a/packages/joint-react/src/utils/collection-subscription.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { mvc, type dia } from '@joint/core'; -import type { AnyCellRecord, CellId } from '../types/cell.types'; -import type { ReadonlyContainer } from '../store/state-container'; - -/** - * Creates a self-managing subscription for the collection form of {@link useCells}. - * - * Subscribes to each cell ID in the container (per-ID granularity) AND to - * collection membership events (`add`/`remove`/`reset`). On membership - * change, resubscribes to the new set of IDs. - * - * All changes bump `versionRef` so `getSnapshot` returns a new value, - * causing `useSyncExternalStoreWithSelector` to re-evaluate `select`. - */ -export function subscribeToCollection( - collection: mvc.Collection, - container: ReadonlyContainer, - listener: () => void, - collectionIdsRef: { current: readonly CellId[] }, - versionRef: { current: number } -): () => void { - let cellUnsubs: Array<() => void> = []; - - const bumpAndNotify = () => { - versionRef.current++; - listener(); - }; - - const resubscribeCells = () => { - for (const unsub of cellUnsubs) unsub(); - const ids = collection.models.map((m) => m.id as CellId); - collectionIdsRef.current = ids; - cellUnsubs = ids.map((id) => container.subscribeById(id, bumpAndNotify)); - }; - - resubscribeCells(); - - const controller = new mvc.Listener(); - const onMembershipChange = () => { - resubscribeCells(); - bumpAndNotify(); - }; - controller.listenTo(collection, 'add remove reset', onMembershipChange); - - return () => { - for (const unsub of cellUnsubs) unsub(); - controller.stopListening(); - }; -} diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts index 85ca3b68fa..692f260a2b 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -72,7 +72,7 @@ export function warnUnstableSelector( 'Options to fix:\n' + ' 1. Return primitives: (cells) => cells.map(c => c.id)\n' + ' 2. Return cell records directly: (cells) => cells.filter(predicate)\n' + - ' 3. Provide a custom isEqual: useCells(input, selector, shallowEqual)\n' + ' 3. Provide a custom isEqual as the last argument of the hook\n' ); } @@ -125,3 +125,40 @@ export function warnResizeOnAutoSizedElement(cellId: dia.Cell.ID): void { 'explicit size (e.g. from FreeTransform / Halo).' ); } + +/** + * Warns once when the graph state is read outside of the selector it was + * handed to (kept in a variable and read later, or after an `await`). The hook + * subscribes to what the selector reads while it runs, so such a read is not + * followed by any update. Dev-only, tree-shaken in production. + */ +export function warnGraphStateReadOutsideSelector(): void { + if (process.env.NODE_ENV === 'production') return; + const key = 'graph-state-read-outside-selector'; + if (WARNED.has(key)) return; + WARNED.add(key); + + console.warn( + '[useGraphState] The graph state was read outside of its selector. The hook follows only ' + + 'what the selector reads while it runs, so this read will not update.\n\n' + + 'Fix: read everything you need inside the selector and return 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; From 08b8e7cbafd5480f1612ca764042de8020c9546a Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 22 Oct 2026 16:20:44 +0700 Subject: [PATCH 14/15] docs(joint-react): describe the settled-change contract of useOnElementsMeasured; format --- .../__tests__/graph-provider-hot-reload.test.tsx | 4 +--- .../paper/render-element/paper-element-item.tsx | 7 +------ .../hooks/__tests__/measurement-events.test.tsx | 8 +++----- .../src/hooks/__tests__/use-graph-state.test.tsx | 14 +++++++++++--- .../hooks/__tests__/use-measure-element.test.tsx | 15 ++++++++++++--- .../src/hooks/use-on-elements-measured.ts | 10 +++++----- .../src/store/__tests__/clear-view.test.ts | 10 ++++------ .../src/store/__tests__/measurement.test.ts | 2 +- .../examples/automatic-layout-storage/code.tsx | 6 +----- .../stories/examples/automatic-layout/code.tsx | 6 +++++- .../examples/dynamic-status-icons/code.tsx | 5 +---- .../stories/examples/introduction/code.tsx | 16 ++-------------- .../stories/examples/measured-state/code.tsx | 4 +++- 13 files changed, 50 insertions(+), 57 deletions(-) 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 8bf304908b..02a75b297a 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 @@ -91,9 +91,7 @@ const { useCellIds }: typeof UseCellIdsModule = require('../../../hooks/use-cell const { useOnGraphStateChange, }: typeof UseOnGraphStateChangeModule = require('../../../hooks/use-on-graph-state-change'); -const { - selectGraphSizingVersion, -}: typeof SelectorsModule = require('../../../selectors'); +const { selectGraphSizingVersion }: typeof SelectorsModule = require('../../../selectors'); const graphProviderV1: typeof GraphProviderModule = require('../graph-provider'); const paperV1: typeof PaperModule = require('../../paper/paper'); 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 f5bffd4a7a..e4341e65a0 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 @@ -1,9 +1,4 @@ -import { - useLayoutEffect, - useMemo, - type ComponentType, - type CSSProperties, -} from 'react'; +import { useLayoutEffect, useMemo, type ComponentType, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import typedMemo from '../../../utils/typed-react'; import { useGraphStore } from '../../../hooks/use-graph-store'; diff --git a/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx index 7381735eb1..b36c61a342 100644 --- a/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/measurement-events.test.tsx @@ -127,11 +127,9 @@ function renderGraph(initialCells: CellRecord[]): Harness { 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 - ); + (graph.getCell(id) as dia.Element).set('size', { width: 120, height: 40 }, { + [AUTO_SIZE_OPTION]: true, + } as object); } }); } diff --git a/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx index eb9c098ea5..fa74bd1912 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-graph-state.test.tsx @@ -179,7 +179,9 @@ describe('useGraphState', () => { describe('sizing version', () => { it('re-renders its reader once per settled change, not per commit', async () => { - const { value, getElement, graph, renders } = mount(() => useGraphState(selectGraphSizingVersion)); + const { value, getElement, graph, renders } = mount(() => + useGraphState(selectGraphSizingVersion) + ); await act(async () => flush()); expect(value()).toBe(1); const rendersBefore = renders(); @@ -263,7 +265,10 @@ describe('useGraphState', () => { () => useOnGraphStateChange(selectGraphSizingVersion, () => {}), ], ['useGraphState(selectGraphSizingVersion)', () => useGraphState(selectGraphSizingVersion)], - ['useGraphState(selectIsGraphSizingComplete)', () => useGraphState(selectIsGraphSizingComplete)], + [ + 'useGraphState(selectIsGraphSizingComplete)', + () => useGraphState(selectIsGraphSizingComplete), + ], ['useGraphState(a derived selector)', () => useGraphState(selectHasSettledTwice)], ])('%s does no work on a commit that is not a measurement', async (_name, useReader) => { const { getElement, renders } = mount(useReader); @@ -442,7 +447,10 @@ describe('useOnGraphStateChange', () => { const { result } = renderHook( () => { useOnGraphStateChange(selectGraphSizingVersion, onChange); - return { isSizingComplete: useGraphState(selectIsGraphSizingComplete), graph: useGraphStore().graph }; + return { + isSizingComplete: useGraphState(selectIsGraphSizingComplete), + graph: useGraphStore().graph, + }; }, { wrapper } ); 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 1ed53e8ac0..79bb7ef3d5 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 @@ -138,7 +138,8 @@ describe('useMeasureElement', () => { expect(() => render( - @@ -175,7 +176,11 @@ describe('useMeasureElement', () => { } render( - + ); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -187,7 +192,11 @@ describe('useMeasureElement', () => { it('returns size record without registering when ref.current is null at mount', async () => { const { container } = render( - + ); await waitFor(() => { 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 d3620a30cc..32eb4e86b5 100644 --- a/packages/joint-react/src/hooks/use-on-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-on-elements-measured.ts @@ -34,9 +34,8 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; /** * Calls a callback once element sizes are known, so a layout can run on them. * - * 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. An added element waits until the paper has rendered it; if + * Delivers one event per settled change: the first pass, and each later + * addition, removal or re-measurement, once no element is 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 @@ -47,8 +46,9 @@ export type OnElementsMeasured = (params: ElementsMeasuredParams) => void; * 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 - * after a graph reset (`resetCells()`), which replaces the diagram. + * for the first event after the hook mounts, and again for the first event + * after a graph reset (`resetCells()`), which replaces the diagram, or after + * the last element was removed. * @deprecated Use {@link useOnGraphStateChange} with {@link selectGraphSizingVersion} * to react to every settled change, or {@link useGraphState} with * {@link selectIsGraphSizingComplete} to act once the sizes are known. 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 0afbb16fa5..05c5b1ebc3 100644 --- a/packages/joint-react/src/store/__tests__/clear-view.test.ts +++ b/packages/joint-react/src/store/__tests__/clear-view.test.ts @@ -1,8 +1,5 @@ import { dia } from '@joint/core'; -import { - clearConnectedLinkViews, - shouldClearLink, -} from '../clear-view'; +import { clearConnectedLinkViews, shouldClearLink } from '../clear-view'; import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; function createGraph(): dia.Graph { @@ -32,7 +29,6 @@ function makeLink(sourceId: string, targetId: string): dia.Link { } describe('shouldClearLink', () => { - it('returns true when source matches', () => { const link = makeLink('a', 'b'); expect(shouldClearLink(link, 'a')).toBe(true); @@ -127,7 +123,9 @@ describe('clearConnectedLinkViews', () => { graph.addCell(link); // Override `findView` to simulate the no-view-registered case. - link.findView = jest.fn(() => undefined as unknown as dia.LinkView) as unknown as typeof link.findView; + link.findView = jest.fn( + () => undefined as unknown as dia.LinkView + ) as unknown as typeof link.findView; const paper = { findViewByModel: jest.fn(), diff --git a/packages/joint-react/src/store/__tests__/measurement.test.ts b/packages/joint-react/src/store/__tests__/measurement.test.ts index 13ff3b2371..2d7b574b6a 100644 --- a/packages/joint-react/src/store/__tests__/measurement.test.ts +++ b/packages/joint-react/src/store/__tests__/measurement.test.ts @@ -45,7 +45,7 @@ type Transform = Parameters['observe']>[0][ function setup(autoSizeOrigin: AutoSizeOrigin = 'top-left') { const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); const measurement = createMeasurement({ graph, autoSizeOrigin, paperStores: NO_PAPERS }); - /** What `selectGraphSizingVersion` reads right now. */ + /** The sizing version right now. */ const version = () => measurement.getSizingVersion(); const nodesById = new Map(); const writes = jest.fn(); 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 652b04f294..7c45402e9b 100644 --- a/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx +++ b/packages/joint-react/stories/examples/automatic-layout-storage/code.tsx @@ -264,11 +264,7 @@ function NodeCard({ title, owner }: Readonly) { }`} > {isEditing ? ( -
+
Title { nextIdRef.current += 1; - setCell({ id: `${nextIdRef.current}`, type: 'element', data: { label: `Node ${elementCount + 1}` } }); + setCell({ + id: `${nextIdRef.current}`, + type: 'element', + data: { label: `Node ${elementCount + 1}` }, + }); }, [elementCount, setCell]); return ( 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 7999a391a1..629d03e14b 100644 --- a/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx +++ b/packages/joint-react/stories/examples/dynamic-status-icons/code.tsx @@ -20,7 +20,6 @@ const SHAPE_FILL_COLOR = '#1c2836'; const STATUS_STROKE_COLOR = '#3c4f63'; const TEXT_COLOR = '#DDE6ED'; - const ShapeTypes = { rectangle: 'rectangle', circle: 'circle', @@ -229,9 +228,7 @@ function Main() { setRandomStatuses(); }, [isSizingComplete, paper, graph, setRandomStatuses]); - return ( - - ); + return ; } export default function App() { diff --git a/packages/joint-react/stories/examples/introduction/code.tsx b/packages/joint-react/stories/examples/introduction/code.tsx index 4af14307d8..58c03991de 100644 --- a/packages/joint-react/stories/examples/introduction/code.tsx +++ b/packages/joint-react/stories/examples/introduction/code.tsx @@ -279,14 +279,7 @@ function TableElementComponent({ columnNames, rows }: Readonly function MinimapRenderElement() { const { width, height } = useCell(selectElementSize); return ( - + ); } @@ -396,12 +389,7 @@ function ToolBar(props: Readonly) { Toggle minimap - diff --git a/packages/joint-react/stories/examples/measured-state/code.tsx b/packages/joint-react/stories/examples/measured-state/code.tsx index 4db18a2bd3..b505a9d6a6 100644 --- a/packages/joint-react/stories/examples/measured-state/code.tsx +++ b/packages/joint-react/stories/examples/measured-state/code.tsx @@ -172,7 +172,9 @@ function Inspector({ entries, moves }: Readonly<{ entries: readonly LogEntry[];
- + {isSizingComplete ? 'measured' : 'measuring…'}
From bbac804578384c7266bc46d7db799b0247ba3135 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 29 Oct 2026 16:05:15 +0700 Subject: [PATCH 15/15] fix(react): distinguish cell targets and collection replacements --- .changeset/react-cells-target-membership.md | 5 + .../collection-target-regressions.test.tsx | 150 ++++++++++++++++++ .../joint-react/src/hooks/use-cells.utils.ts | 10 +- packages/joint-react/src/store/graph-state.ts | 17 +- 4 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 .changeset/react-cells-target-membership.md create mode 100644 packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx diff --git a/.changeset/react-cells-target-membership.md b/.changeset/react-cells-target-membership.md new file mode 100644 index 0000000000..1a1dd6aed7 --- /dev/null +++ b/.changeset/react-cells-target-membership.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + +useCells - disambiguate target kinds and refresh same-id collection replacements diff --git a/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx b/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx new file mode 100644 index 0000000000..ec4f0184ca --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/collection-target-regressions.test.tsx @@ -0,0 +1,150 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { dia, mvc } from '@joint/core'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model'; +import type { ElementRecord } from '../../types/cell.types'; +import type { GraphState } from '../../store/graph-state'; +import { useCells } from '../use-cells'; +import { useGraphState } from '../use-graph-state'; +import { useOnCellsChange } from '../use-on-cells-change'; +import { useOnGraphStateChange } from '../use-on-graph-state-change'; +import { useGraphStore } from '../use-graph-store'; + +const element = (id: string, x: number): ElementRecord => ({ + id, + type: ELEMENT_MODEL_TYPE, + position: { x, y: 0 }, + size: { width: 10, height: 10 }, +}); +const INITIAL_CELLS = [ + element('a', 10), + element('["a"]', 80), + element('ids:["a"]', 120), + element('id:ids:["a"]', 160), +]; +function wrapper({ children }: Readonly<{ children: ReactNode }>) { + return {children}; +} +function selectX(source: unknown): number | undefined { + const cell: unknown = Array.isArray(source) ? source[0] : source; + if (!cell || typeof cell !== 'object' || !('position' in cell)) return undefined; + const { position } = cell; + if (!position || typeof position !== 'object' || !('x' in position)) return undefined; + return typeof position.x === 'number' ? position.x : undefined; +} +const flushMutation = (mutate: () => void) => act(async () => { + mutate(); + await new Promise((resolve) => queueMicrotask(resolve)); +}); + +it.each([false, true])('preserves the selector-free return shape when starting with array=%s', (arrayFirst) => { + const initialTarget: string | readonly string[] = arrayFirst ? ['a'] : '["a"]'; + const nextTarget: string | readonly string[] = arrayFirst ? '["a"]' : ['a']; + const { result, rerender } = renderHook( + ({ target }: { readonly target: string | readonly string[] }) => ({ + // @ts-expect-error Each target is valid; the overloads cannot express their JavaScript union. + value: useCells(target), + cells: useGraphStore().graphProjection.cells, + }), + { wrapper, initialProps: { target: initialTarget } } + ); + const arrayMember = result.current.cells.get('a'); + const scalarRecord = result.current.cells.get('["a"]'); + expect(Array.isArray(result.current.value)).toBe(arrayFirst); + expect(result.current.value).toEqual(arrayFirst ? [arrayMember] : scalarRecord); + + rerender({ target: nextTarget }); + + expect(Array.isArray(result.current.value)).toBe(!arrayFirst); + if (arrayFirst) expect(result.current.value).toBe(scalarRecord); + else expect(result.current.value).toEqual([arrayMember]); + const selected = result.current.value; + rerender({ target: nextTarget }); + expect(result.current.value).toBe(selected); +}); + +describe('serialized ids do not alias literal cell ids', () => { + it.each([ + { scalar: '["a"]', scalarX: 80, arrayFirst: false }, + { scalar: '["a"]', scalarX: 80, arrayFirst: true }, + { scalar: 'ids:["a"]', scalarX: 120, arrayFirst: false }, + { scalar: 'ids:["a"]', scalarX: 120, arrayFirst: true }, + { scalar: 'id:ids:["a"]', scalarX: 160, arrayFirst: false }, + { scalar: 'id:ids:["a"]', scalarX: 160, arrayFirst: true }, + ])('retargets $scalar with array first=$arrayFirst', async ({ scalar, scalarX, arrayFirst }) => { + const onChange = jest.fn(); + const initialTarget: string | readonly string[] = arrayFirst ? ['a'] : scalar; + const nextTarget: string | readonly string[] = arrayFirst ? scalar : ['a']; + const nextId = arrayFirst ? scalar : 'a'; + const oldId = arrayFirst ? 'a' : scalar; + const { result, rerender } = renderHook( + ({ target }: { readonly target: string | readonly string[] }) => { + // @ts-expect-error Each target is valid; existing overloads cannot express this union. + const value = useCells(target, selectX); + // @ts-expect-error Exercise the same mounted watcher with either valid JavaScript target. + useOnCellsChange(target, selectX, onChange); + return { value, graph: useGraphStore().graph }; + }, + { wrapper, initialProps: { target: initialTarget } } + ); + expect(result.current.value).toBe(arrayFirst ? 10 : scalarX); + onChange.mockClear(); + + rerender({ target: nextTarget }); + expect(result.current.value).toBe(arrayFirst ? scalarX : 10); + expect(onChange.mock.calls).toEqual([[arrayFirst ? scalarX : 10]]); + onChange.mockClear(); + + await flushMutation(() => result.current.graph.getCell(oldId).set('position', { x: 99, y: 0 })); + expect(onChange).not.toHaveBeenCalled(); + await flushMutation(() => result.current.graph.getCell(nextId).set('position', { x: 45, y: 0 })); + expect(result.current.value).toBe(45); + expect(onChange.mock.calls).toEqual([[45, arrayFirst ? scalarX : 10]]); + onChange.mockClear(); + rerender({ target: arrayFirst ? scalar : ['a'] }); + expect(onChange).not.toHaveBeenCalled(); + expect(result.current.value).toBe(45); + }); +}); + +describe('same-id collection model replacement', () => { + it.each([false, true])('reads replacement models while graph-bound=%s', async (bound) => { + const id = bound ? 'a' : 'detached'; + const first = new dia.Element(element(id, 10)); + const collection = new mvc.Collection([first]); + const onCellsChange = jest.fn(); + const onStateChange = jest.fn(); + const selectCollection = (state: GraphState) => state.getCells(collection); + const selectCollectionX = (state: GraphState) => selectX(state.getCells(collection)); + const { result, rerender } = renderHook(() => { + const cells = useCells(collection); + const stateCells = useGraphState(selectCollection); + useOnCellsChange(collection, selectX, onCellsChange); + useOnGraphStateChange(selectCollectionX, onStateChange); + return { cells, stateCells }; + }, { wrapper }); + expect(selectX(result.current.cells)).toBe(10); + expect(selectX(result.current.stateCells)).toBe(10); + onCellsChange.mockClear(); + onStateChange.mockClear(); + + await flushMutation(() => collection.reset([new dia.Element(element(id, 80))])); + + expect(selectX(result.current.cells)).toBe(bound ? 10 : 80); + expect(selectX(result.current.stateCells)).toBe(bound ? 10 : 80); + expect(onCellsChange.mock.calls).toEqual(bound ? [] : [[80, 10]]); + expect(onStateChange.mock.calls).toEqual(bound ? [] : [[80, 10]]); + const previousCells = result.current.cells; + const previousStateCells = result.current.stateCells; + onCellsChange.mockClear(); + onStateChange.mockClear(); + + rerender(); + + expect(result.current.cells).toBe(previousCells); + expect(result.current.stateCells).toBe(previousStateCells); + expect(onCellsChange).not.toHaveBeenCalled(); + expect(onStateChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/joint-react/src/hooks/use-cells.utils.ts b/packages/joint-react/src/hooks/use-cells.utils.ts index 05a4f4fe5d..f5a2771b54 100644 --- a/packages/joint-react/src/hooks/use-cells.utils.ts +++ b/packages/joint-react/src/hooks/use-cells.utils.ts @@ -2,12 +2,14 @@ import type { CellsTarget } from './use-cells.types'; /** * A stable stand-in for a target in a dependency list: ids passed inline are a - * new array on every render. Serialised rather than joined, so that `[1]`, - * `['1']` and the single id `'1'` stay apart. + * new array on every render. Serialised rather than joined, so that `[1]` + * and `['1']` stay apart. Both arrays and scalar strings are tagged, so a + * literal id containing serialized ids or either prefix cannot alias an array. * @param target - The target of a cells hook. - * @returns The serialised ids of an ids target, otherwise the target itself. + * @returns A tagged key for string/array targets, otherwise the target itself. * @internal */ export function toTargetKey(target: CellsTarget): unknown { - return Array.isArray(target) ? JSON.stringify(target) : target; + if (Array.isArray(target)) return `ids:${JSON.stringify(target)}`; + return typeof target === 'string' ? `id:${target}` : target; } diff --git a/packages/joint-react/src/store/graph-state.ts b/packages/joint-react/src/store/graph-state.ts index 69e675ba3e..6f2acef3a9 100644 --- a/packages/joint-react/src/store/graph-state.ts +++ b/packages/joint-react/src/store/graph-state.ts @@ -90,28 +90,33 @@ interface Subscription { readonly unsubscribers: Map void>; } -const collectionIds = new WeakMap, readonly CellId[]>(); +const collectionIds = new WeakMap< + mvc.Collection, + { readonly models: readonly dia.Cell[]; readonly ids: readonly CellId[] } +>(); /** * The ids of a collection's cells, the same array for as long as the - * membership and order are. O(members) per call, nothing allocated while unchanged. + * membership, model identities and order are. Replacing a detached model with + * another of the same id must invalidate its cached record too. + * O(members) per call, nothing allocated while unchanged. * @param collection - The collection to read. */ function getCollectionIds(collection: mvc.Collection): readonly CellId[] { const { models } = collection; const known = collectionIds.get(collection); - if (known?.length === models.length) { + if (known?.models.length === models.length) { let isSame = true; for (const [index, model] of models.entries()) { - if (model.id !== known[index]) { + if (model !== known.models[index] || model.id !== known.ids[index]) { isSame = false; break; } } - if (isSame) return known; + if (isSame) return known.ids; } const ids = models.map((model) => model.id); - collectionIds.set(collection, ids); + collectionIds.set(collection, { models: [...models], ids }); return ids; }