Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3694337
fix(joint-react): ensure useOnElementsMeasured fires only for measure…
samuelgja Sep 24, 2026
641a361
feat: enhance measurement handling in graph store and related hooks
samuelgja Sep 29, 2026
e902506
fix(joint-react): update renderElement to accept a label prop for bet…
samuelgja Sep 30, 2026
d4f6835
refactor: enhance useOnElementsMeasured to handle zero-sized and cull…
samuelgja Oct 1, 2026
5ea9221
fix(joint-react): ensure useOnElementsMeasured fires once per settled…
samuelgja Oct 1, 2026
4e7e7de
feat(joint-react): measurement handling in joint-react
samuelgja Oct 2, 2026
478aed9
Merge remote-tracking branch 'upstream/dev' into feat/new-react-measu…
samuelgja Oct 2, 2026
4cd0f80
refactor(tests): remove obsolete tests for unsized element measurement
samuelgja Oct 2, 2026
8cd4a8d
refactor(tests): rename renderMeasuredProbe to renderResizeProbe for …
samuelgja Oct 2, 2026
d543877
feat(story): add measured state example with dynamic layout and measu…
samuelgja Oct 6, 2026
d992078
feat: introduce GraphState management and update measurement handling
samuelgja Oct 22, 2026
f52acb4
fix(joint-react): announce the sizing version after the measured size…
samuelgja Oct 22, 2026
1fd55d1
chore(joint-react): restore the dev bench baseline and consolidate ch…
samuelgja Oct 22, 2026
9fe6856
fix(joint-react): move element measuring into one module and settle u…
samuelgja Oct 22, 2026
2faf4e8
Merge branch 'fix/react-measurement-module' into feat/new-react-measu…
samuelgja Oct 22, 2026
08b8e7c
docs(joint-react): describe the settled-change contract of useOnEleme…
samuelgja Oct 22, 2026
b746b58
chore(joint-react): sync dev and preserve measurement fixes
samuelgja Oct 29, 2026
bbac804
fix(react): distinguish cell targets and collection replacements
samuelgja Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/react-cells-detached-remount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

useCells - read current detached collection members when the hook remounts
5 changes: 5 additions & 0 deletions .changeset/react-cells-equality-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

useCells - refresh a cached selection when its equality function changes
5 changes: 5 additions & 0 deletions .changeset/react-cells-inline-selector.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/react-cells-target-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

useCells - disambiguate target kinds and refresh same-id collection replacements
5 changes: 5 additions & 0 deletions .changeset/react-elements-measured-deprecate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

useOnElementsMeasured - deprecate in favor of `useOnGraphStateChange()` with `selectGraphSizingVersion`
5 changes: 5 additions & 0 deletions .changeset/react-graph-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

useGraphState - add the hook, with `useOnGraphStateChange()`, `useOnCellsChange()` and the `selectGraphSizingVersion` / `selectIsGraphSizingComplete` selectors
5 changes: 5 additions & 0 deletions .changeset/react-store-callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

<GraphProvider /> - fix a store callback that throws or changes the graph breaking later updates
Original file line number Diff line number Diff line change
Expand Up @@ -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 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';

Expand Down Expand Up @@ -88,8 +89,9 @@ 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');
useOnGraphStateChange,
}: typeof UseOnGraphStateChangeModule = require('../../../hooks/use-on-graph-state-change');
const { selectGraphSizingVersion }: typeof SelectorsModule = require('../../../selectors');

const graphProviderV1: typeof GraphProviderModule = require('../graph-provider');
const paperV1: typeof PaperModule = require('../../paper/paper');
Expand Down Expand Up @@ -191,8 +193,8 @@ function Probe() {
mountSequence += 1;
return mountSequence;
});
useOnElementsMeasured(({ isInitial }) => {
measuredCalls.push(isInitial);
useOnGraphStateChange(selectGraphSizingVersion, (version, previousSizingVersion) => {
if (version) measuredCalls.push(!previousSizingVersion);
});
const ids = useCellIds();
return h(
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <GraphProvider initialCells={INITIAL_CELLS}>{children}</GraphProvider>;
}
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<void>((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<ElementRecord>(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<dia.Cell>([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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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, SelectedEqual } from '../../store/graph-state';
import { useGraphState } from '../use-graph-state';
import { useOnGraphStateChange } from '../use-on-graph-state-change';
import { useOnCellsChange } from '../use-on-cells-change';
import { useGraphStore } from '../use-graph-store';

const element = (id: string): ElementRecord => ({
id,
type: ELEMENT_MODEL_TYPE,
position: { x: 0, y: 0 },
size: { width: 10, height: 10 },
});
const INITIAL_CELLS = [element('a'), element('b')];
function wrapper({ children }: Readonly<{ children: ReactNode }>) {
return <GraphProvider initialCells={INITIAL_CELLS}>{children}</GraphProvider>;
}
const commit = (mutate: () => void) =>
act(async () => {
mutate();
await new Promise<void>((resolve) => queueMicrotask(resolve));
});
const selectCount = (state: GraphState) => state.cells.length;
const selectCellsCount = (cells: readonly unknown[]) => cells.length;
const alwaysEqual: SelectedEqual<number> = () => true;
const selectNaN = (state: GraphState) => Number.NaN + state.cells.length;
const selectCellsNaN = (cells: readonly unknown[]) => Number.NaN + cells.length;
const selectSignedZero = (state: GraphState) => (state.cells.length === 2 ? 0 : -0);
const selectCellsSignedZero = (cells: readonly unknown[]) => (cells.length === 2 ? 0 : -0);

type Selection = 'nan' | 'zero' | 'count';
type Callback = (value: number, previous?: number) => void;
const GRAPH_SELECTORS = { nan: selectNaN, zero: selectSignedZero, count: selectCount };
const CELL_SELECTORS = { nan: selectCellsNaN, zero: selectCellsSignedZero, count: selectCellsCount };
function useGraphSelection(kind: Selection, callback: Callback, equal?: SelectedEqual<number>) {
useOnGraphStateChange(GRAPH_SELECTORS[kind], callback, equal);
}
function useCellsSelection(kind: Selection, callback: Callback, equal?: SelectedEqual<number>) {
useOnCellsChange(CELL_SELECTORS[kind], callback, equal);
}

for (const hook of ['graph', 'cells'] as const) {
const useSelection = hook === 'graph' ? useGraphSelection : useCellsSelection;
describe(`${hook} callback selected-value identity`, () => {
it('does not deliver unchanged NaN selections after a watched source changes', async () => {
const onChange = jest.fn();
const { result } = renderHook(
() => {
useSelection('nan', onChange);
return useGraphStore().graph;
},
{ wrapper }
);
onChange.mockClear();

await commit(() => result.current.addCell(element('c')));

expect(onChange).not.toHaveBeenCalled();
});

it('delivers the change from positive zero to negative zero', async () => {
const onChange = jest.fn();
const { result } = renderHook(
() => {
useSelection('zero', onChange);
return useGraphStore().graph;
},
{ wrapper }
);
onChange.mockClear();

await commit(() => result.current.addCell(element('c')));

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toBe(-0);
expect(onChange.mock.calls[0][1]).toBe(0);
});

it('rechecks a stable selector when only its equality function changes', async () => {
const onChange = jest.fn();
const { result, rerender } = renderHook(
({ equal }: { readonly equal: SelectedEqual<number> }) => {
useSelection('count', onChange, equal);
return useGraphStore().graph;
},
{ wrapper, initialProps: { equal: alwaysEqual } }
);
onChange.mockClear();
await commit(() => result.current.addCell(element('c')));
expect(onChange).not.toHaveBeenCalled();

rerender({ equal: Object.is });

expect(onChange.mock.calls).toEqual([[3, 2]]);
});
});
}

it('useGraphState refreshes a cached selection when only its equality changes', async () => {
const { result, rerender } = renderHook(
({ equal }: { readonly equal: SelectedEqual<number> }) => ({
value: useGraphState(selectCount, equal),
graph: useGraphStore().graph,
}),
{ wrapper, initialProps: { equal: alwaysEqual } }
);
await commit(() => result.current.graph.addCell(element('c')));
expect(result.current.value).toBe(2);

rerender({ equal: Object.is });

expect(result.current.value).toBe(3);
});

it('a fresh hook reads current detached model attributes after an earlier hook unmounts', () => {
const model = new dia.Element({ ...element('detached'), position: { x: 10, y: 0 } });
const collection = new mvc.Collection<dia.Cell>([model]);
const selectDetachedX = (state: GraphState) => state.getCells(collection)[0]?.position?.x;
const first = renderHook(() => useGraphState(selectDetachedX), { wrapper });
expect(first.result.current).toBe(10);
first.unmount();
model.position(80, 0);

const second = renderHook(() => useGraphState(selectDetachedX), { wrapper });

expect(second.result.current).toBe(80);
});
Loading
Loading