Skip to content

Commit 634fb9f

Browse files
authored
fix(joint-react): add tests for container key updates on membership changes (#3462)
1 parent f378ff8 commit 634fb9f

6 files changed

Lines changed: 256 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@joint/react": patch
3+
---
4+
5+
fix stale cell keys when a single commit swaps ids without changing the count (membership changes now notify key-list subscribers, and large-graph rendering no longer defers id updates)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { Component, type ReactNode } from 'react';
2+
import { act, render, waitFor } from '@testing-library/react';
3+
import { dia } from '@joint/core';
4+
import { GraphProvider, Paper } from '../..';
5+
import { useCell } from '../../../hooks/use-cell';
6+
import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model';
7+
import { DEFAULT_CELL_NAMESPACE } from '../../../store/graph-store';
8+
import type { Computed, ElementRecord } from '../../../types/cell.types';
9+
10+
interface NodeData {
11+
readonly label: string;
12+
}
13+
14+
// Surfaces an error thrown anywhere in its subtree so the test can assert on
15+
// it instead of the throw tearing down the whole test render.
16+
class CatchErrorBoundary extends Component<
17+
Readonly<{ onCatch: (error: Error) => void; children: ReactNode }>,
18+
{ hasError: boolean }
19+
> {
20+
state = { hasError: false };
21+
static getDerivedStateFromError() {
22+
return { hasError: true };
23+
}
24+
componentDidCatch(error: Error) {
25+
this.props.onCatch(error);
26+
}
27+
render() {
28+
return this.state.hasError ? null : this.props.children;
29+
}
30+
}
31+
32+
const caughtState: { error: Error | null } = { error: null };
33+
function captureError(error: Error) {
34+
caughtState.error = error;
35+
}
36+
37+
// Subscribes to its own cell — the same shape a content-sized node gets from
38+
// useMeasureElement, so the scenario needs no explicit useCell in app code.
39+
function SubscribingNode() {
40+
const label = useCell((cell: Computed<ElementRecord<NodeData>>) => cell.data.label);
41+
return <text>{label}</text>;
42+
}
43+
const renderSubscribing = () => <SubscribingNode />;
44+
45+
const PAPER_STYLE = { width: 400, height: 400 } as const;
46+
47+
function elementJSON(id: string, index = 0): dia.Cell.JSON {
48+
return {
49+
id,
50+
type: ELEMENT_MODEL_TYPE,
51+
position: { x: (index % 10) * 20, y: Math.floor(index / 10) * 20 },
52+
size: { width: 10, height: 10 },
53+
data: { label: `label-${id}` },
54+
};
55+
}
56+
57+
function createExternalGraph(): dia.Graph {
58+
return new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE });
59+
}
60+
61+
// Customer scenario: the app owns an external dia.Graph and mutates it
62+
// imperatively (fromJSON load, stencil drop add→remove→re-add churn,
63+
// CommandManager undo/redo). React content must follow every membership
64+
// change of the graph — even when a coalesced commit keeps the cell COUNT
65+
// unchanged while swapping ids.
66+
describe('GraphProvider — imperative external-graph churn', () => {
67+
afterEach(() => {
68+
jest.restoreAllMocks();
69+
});
70+
71+
it('paints a cell swapped in during a same-tick remove+add (stencil churn)', async () => {
72+
const graph = createExternalGraph();
73+
graph.addCell(elementJSON('a'));
74+
75+
const { container } = render(
76+
<GraphProvider graph={graph}>
77+
<Paper style={PAPER_STYLE} id="churn-swap-paper" renderElement={renderSubscribing} />
78+
</GraphProvider>
79+
);
80+
81+
await waitFor(() => {
82+
expect(container.textContent).toContain('label-a');
83+
});
84+
85+
// Same tick: remove 'a', add 'b'. Both mutations coalesce into a single
86+
// container commit whose net size is unchanged.
87+
await act(async () => {
88+
graph.removeCells([graph.getCell('a')]);
89+
graph.addCell(elementJSON('b', 1));
90+
});
91+
92+
await waitFor(() => {
93+
expect(container.textContent).toContain('label-b');
94+
expect(container.textContent).not.toContain('label-a');
95+
});
96+
});
97+
98+
it('paints new cells after graph.fromJSON reload with the same cell count', async () => {
99+
const graph = createExternalGraph();
100+
graph.addCell(elementJSON('a'));
101+
graph.addCell(elementJSON('b', 1));
102+
103+
const { container } = render(
104+
<GraphProvider graph={graph}>
105+
<Paper style={PAPER_STYLE} id="churn-fromjson-paper" renderElement={renderSubscribing} />
106+
</GraphProvider>
107+
);
108+
109+
await waitFor(() => {
110+
expect(container.textContent).toContain('label-a');
111+
expect(container.textContent).toContain('label-b');
112+
});
113+
114+
// Imperative reload: same count, entirely different ids.
115+
await act(async () => {
116+
graph.fromJSON({ cells: [elementJSON('c'), elementJSON('d', 1)] });
117+
});
118+
119+
await waitFor(() => {
120+
expect(container.textContent).toContain('label-c');
121+
expect(container.textContent).toContain('label-d');
122+
expect(container.textContent).not.toContain('label-a');
123+
});
124+
});
125+
126+
it('survives delete + undo-style re-add at scale (120 cells)', async () => {
127+
jest.spyOn(console, 'error').mockImplementation(() => {});
128+
caughtState.error = null;
129+
130+
const graph = createExternalGraph();
131+
const cellCount = 120;
132+
for (let index = 0; index < cellCount; index++) {
133+
graph.addCell(elementJSON(`el-${index}`, index));
134+
}
135+
136+
const { container } = render(
137+
<CatchErrorBoundary onCatch={captureError}>
138+
<GraphProvider graph={graph}>
139+
<Paper style={PAPER_STYLE} id="churn-undo-paper" renderElement={renderSubscribing} />
140+
</GraphProvider>
141+
</CatchErrorBoundary>
142+
);
143+
144+
await waitFor(() => {
145+
expect(container.textContent).toContain('label-el-119');
146+
});
147+
148+
const removedJSON = graph.getCell('el-115').toJSON();
149+
150+
// Delete at scale — regression for the historical `useCell(): no cell with
151+
// id` crash, where a stale render pass hit the already-removed cell.
152+
await act(async () => {
153+
graph.removeCells([graph.getCell('el-115')]);
154+
});
155+
156+
await waitFor(() => {
157+
expect(container.textContent).not.toContain('label-el-115');
158+
});
159+
expect(caughtState.error).toBeNull();
160+
161+
// Undo — CommandManager restores the cell by re-adding the same JSON.
162+
await act(async () => {
163+
graph.addCell(removedJSON);
164+
});
165+
166+
await waitFor(() => {
167+
expect(container.textContent).toContain('label-el-115');
168+
});
169+
expect(caughtState.error).toBeNull();
170+
});
171+
});

packages/joint-react/src/hooks/__tests__/use-container-keys.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ describe('useContainerKeys', () => {
5656
expect(result.current).toBe(first);
5757
});
5858

59+
it('updates keys when one id is swapped for another in a single commit', async () => {
60+
const container = createContainer<TestItem>();
61+
const readOnly = asReadonlyContainer(container);
62+
container.set('a', { id: 'a', value: 1, type: 'item' });
63+
container.commitChanges();
64+
await flush();
65+
66+
const { result } = renderHook(() => useContainerKeys(readOnly));
67+
expect(result.current).toEqual(['a']);
68+
69+
// Remove 'a' and add 'b' in ONE commit — the count is unchanged but the
70+
// key set is not. This is the shape of a stencil drop (temp cell swap) or
71+
// an undo that replaces a cell in the same tick.
72+
await act(async () => {
73+
container.delete('a');
74+
container.set('b', { id: 'b', value: 2, type: 'item' });
75+
container.commitChanges();
76+
await flush();
77+
});
78+
79+
expect(result.current).toEqual(['b']);
80+
});
81+
5982
it('updates the array when a key is added or removed', async () => {
6083
const container = createContainer<TestItem>();
6184
const readOnly = asReadonlyContainer(container);

packages/joint-react/src/hooks/use-create-portal-paper.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
/* eslint-disable @typescript-eslint/no-shadow */
33
import { dia, util } from '@joint/core';
44
import {
5-
useDeferredValue,
65
useEffect,
76
useLayoutEffect,
87
useMemo,
@@ -239,14 +238,8 @@ export function useCreatePortalPaper(
239238
return { elementIds: elements, linkIds: links };
240239
}, [allCellIds, graphStore]);
241240

242-
const deferredElementIdsRaw = useDeferredValue(elementIds);
243-
const deferredLinkIdsRaw = useDeferredValue(linkIds);
244-
const shouldDefer = elementIds.length > 100 || linkIds.length > 100;
245241
const featuresContext = useContext(PaperFeaturesContext);
246242

247-
const deferredElementIds = shouldDefer ? deferredElementIdsRaw : elementIds;
248-
const deferredLinkIds = shouldDefer ? deferredLinkIdsRaw : linkIds;
249-
250243
const selectPaperVersion = useMemo(() => createSelectPaperVersion(id), [id]);
251244

252245
// Subscribe to paper version to trigger re-renders on view mount/unmount changes
@@ -463,7 +456,7 @@ export function useCreatePortalPaper(
463456
if (!hasRenderElement) {
464457
return null;
465458
}
466-
return deferredElementIds.map((elementId) => {
459+
return elementIds.map((elementId) => {
467460
const elementView = paperStore?.getElementView(elementId);
468461
if (!elementView?.paper) {
469462
return null;
@@ -508,7 +501,7 @@ export function useCreatePortalPaper(
508501
version,
509502
HTMLRendererContainer,
510503
areElementsMeasured,
511-
deferredElementIds,
504+
elementIds,
512505
hasRenderElement,
513506
paperStore,
514507
renderElement,
@@ -520,7 +513,7 @@ export function useCreatePortalPaper(
520513
return null;
521514
}
522515

523-
return deferredLinkIds.map((linkId) => {
516+
return linkIds.map((linkId) => {
524517
const linkView = paperStore?.getLinkView(linkId);
525518
if (!linkView?.paper) {
526519
return null;
@@ -542,7 +535,7 @@ export function useCreatePortalPaper(
542535
);
543536
});
544537
// eslint-disable-next-line react-hooks/exhaustive-deps
545-
}, [deferredLinkIds, version, hasRenderLink, paperStore, renderLink]);
538+
}, [linkIds, version, hasRenderLink, paperStore, renderLink]);
546539

547540
const content = useMemo(
548541
() => (

packages/joint-react/src/store/__tests__/state-container.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,46 @@ describe('createContainer', () => {
433433
expect(listener).not.toHaveBeenCalled();
434434
});
435435

436+
it('notifies when membership changes but the count stays the same (swap in one commit)', async () => {
437+
const container = setup();
438+
container.set('a', { id: 'a', x: 1, y: 2, type: 'item' });
439+
container.commitChanges();
440+
await flush();
441+
442+
const listener = jest.fn();
443+
container.subscribeToSize(listener);
444+
445+
// One coalesced commit: remove 'a' + add 'b' → net size unchanged, but
446+
// the key set changed. Subscribers building id lists must be notified.
447+
container.delete('a');
448+
container.set('b', { id: 'b', x: 3, y: 4, type: 'item' });
449+
container.commitChanges();
450+
await flush();
451+
452+
expect(listener).toHaveBeenCalledTimes(1);
453+
});
454+
455+
it('notifies on reset to the same count with different ids', async () => {
456+
const container = setup();
457+
container.set('a', { id: 'a', x: 1, y: 2, type: 'item' });
458+
container.set('b', { id: 'b', x: 3, y: 4, type: 'item' });
459+
container.commitChanges();
460+
await flush();
461+
462+
const listener = jest.fn();
463+
container.subscribeToSize(listener);
464+
465+
// Same count, entirely new ids (e.g. graph.fromJSON reload).
466+
container.reset([
467+
{ id: 'c', x: 5, y: 6, type: 'item' },
468+
{ id: 'd', x: 7, y: 8, type: 'item' },
469+
]);
470+
container.commitChanges();
471+
await flush();
472+
473+
expect(listener).toHaveBeenCalledTimes(1);
474+
});
475+
436476
it('returns an unsubscribe function', async () => {
437477
const container = setup();
438478
const listener = jest.fn();

packages/joint-react/src/store/state-container.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ReadonlyContainer<Cell extends AnyCellRecord> {
3232
has: (id: CellId) => boolean;
3333
getSize: () => number;
3434
subscribe: (id: CellId, listener: () => void) => () => void;
35+
/** Notifies on membership changes (ids added / removed), even when the net count is unchanged. */
3536
subscribeToSize: (listener: () => void) => () => void;
3637
subscribeToAll: (listener: () => void) => () => void;
3738
}
@@ -82,7 +83,11 @@ export function createContainer<Cell extends AnyCellRecord>(): Container<Cell> {
8283
// because `Set.add` is measurably slower than `Array.push` when many
8384
// unique ids accumulate between commits (each add does a hash lookup).
8485
let changes: CellId[] = [];
85-
let previousSize = 0;
86+
// True when the id SET changed since the last commit (add/remove/reset), not
87+
// just a value. Gating size-listener notifications on a net count change
88+
// would miss same-count membership swaps (remove A + add B in one commit,
89+
// same-count reset), leaving key-list subscribers stale.
90+
let hasMembershipChanged = false;
8691
let version = 0;
8792
return {
8893
get(id: CellId): Cell | undefined {
@@ -108,6 +113,7 @@ export function createContainer<Cell extends AnyCellRecord>(): Container<Cell> {
108113
if (index === undefined) {
109114
indexById.set(id, items.length);
110115
items.push(value);
116+
hasMembershipChanged = true;
111117
} else {
112118
items[index] = value;
113119
}
@@ -130,6 +136,7 @@ export function createContainer<Cell extends AnyCellRecord>(): Container<Cell> {
130136
items.pop();
131137
indexById.delete(id);
132138
changes.push(id);
139+
hasMembershipChanged = true;
133140
version++;
134141
},
135142
reset(next: readonly Cell[]) {
@@ -145,6 +152,9 @@ export function createContainer<Cell extends AnyCellRecord>(): Container<Cell> {
145152
changes.push(item.id as CellId);
146153
index++;
147154
}
155+
// Reset is a cold path — always flag it. Even when the id set happens to
156+
// be identical, key-list subscribers bail out on the stable keys array.
157+
hasMembershipChanged = true;
148158
version++;
149159
},
150160
getVersion() {
@@ -169,8 +179,8 @@ export function createContainer<Cell extends AnyCellRecord>(): Container<Cell> {
169179
listener();
170180
}
171181
}
172-
if (previousSize !== items.length) {
173-
previousSize = items.length;
182+
if (hasMembershipChanged) {
183+
hasMembershipChanged = false;
174184
for (const listener of sizeListeners) {
175185
listener();
176186
}

0 commit comments

Comments
 (0)