Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3789606
feat(layers): introduce layer management in JointJS integration
samuelgja Sep 10, 2026
16a48eb
feat(layers): add warnings for non-empty layers during removal
samuelgja Sep 10, 2026
3ab30ba
feat(layers): refine layer management and visibility handling in Grap…
samuelgja Sep 17, 2026
7ac6c62
feat(layers): add layer existence checks and improve attribute handli…
samuelgja Sep 17, 2026
96f28a3
Merge remote-tracking branch 'upstream/dev' into feat/layers-support-…
samuelgja Sep 17, 2026
b7e61ae
feat(layers): add validation for layer records and improve patch hand…
samuelgja Sep 22, 2026
982d1e2
Merge remote-tracking branch 'upstream/dev' into feat/layers-support-…
samuelgja Sep 24, 2026
047e395
feat(layers): implement layer model and view with namespace support
samuelgja Sep 29, 2026
d2074fd
Merge remote-tracking branch 'upstream/dev' into feat/layers-support-…
samuelgja Oct 7, 2026
5b0ab31
feat(layers): enhance layer model and view with namespace support and…
samuelgja Oct 7, 2026
26e920c
Merge latest dev into feat/layers-support-for-react
samuelgja Oct 29, 2026
c4ca998
fix(react): preserve layer state and visibility
samuelgja Oct 29, 2026
e2f2c7f
refactor(joint-react): remove layer visibility, consolidate layer cha…
samuelgja Nov 3, 2026
d3c1588
docs(joint-react): correct layer docs, use the suggested layers chang…
samuelgja Nov 3, 2026
5d2ca04
fix(joint-react): report outside layer changes without an echo flag
samuelgja Nov 3, 2026
30a8a47
fix(joint-react): construct pre-built layers like joint-core, documen…
samuelgja Nov 3, 2026
2dda742
docs(joint-react): clarify pre-built layer constructor options
samuelgja Nov 3, 2026
49361e6
fix(joint-react): handle layer membership, nested applies and rollback
samuelgja Nov 3, 2026
68a1c36
fix(joint-react): land controlled cells after a superseded sync, keep…
samuelgja Nov 3, 2026
90a5af4
fix(joint-react): record only what a controlled sync landed
samuelgja Nov 3, 2026
0f6ced7
fix(dia.Graph): type getCellLayerId() as taking a cell or a cell JSON…
samuelgja Nov 4, 2026
4fdfe50
fix(joint-react): reject new layers under the ids the paper keeps for…
samuelgja Nov 4, 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/core-cell-layer-id-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/core": patch
---

dia.Graph - fix the `getCellLayerId()` type to take a cell or a cell JSON, not a cell id
5 changes: 5 additions & 0 deletions .changeset/react-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

<GraphProvider /> - add layers support (`initialLayers`, controlled `layers` with `onLayersChange`, `useLayers()`, `useLayer()`, `useGraph().setLayers()` / `setLayer()`)
2 changes: 1 addition & 1 deletion packages/joint-core/types/dia.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ export class Graph<A extends ObjectHash = Graph.Attributes, S = ModelSetOptions>

getLayers(): GraphLayer[];

getCellLayerId(cell: Graph.CellRef): GraphLayer.ID;
getCellLayerId(cell: Graph.CellInit): GraphLayer.ID;

getCellNamespace(): Record<string, any>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { act, render, waitFor } from '@testing-library/react';
import { GraphProvider } from '../graph-provider';
import { useGraphStore } from '../../../hooks/use-graph-store';
import { useGraph } from '../../../hooks/use-graph';
import type { GraphStore } from '../../../store';
import type { CellRecord, ElementRecord } from '../../../types/cell.types';
import type { LayerRecord } from '../../../types/layer.types';
import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model';

const element = (layer: string) => ({
id: 'node',
type: ELEMENT_MODEL_TYPE,
layer,
position: { x: 0, y: 0 },
size: { width: 10, height: 10 },
} satisfies ElementRecord);
const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'notes' }];
const REMAINING_LAYERS: readonly LayerRecord[] = [];
const INITIAL_CELLS: readonly CellRecord[] = [element('notes')];
const MOVED_CELLS: readonly CellRecord[] = [element('cells')];
const NO_CELLS: readonly CellRecord[] = [];
interface Captured { store?: GraphStore; api?: ReturnType<typeof useGraph> }
function Capture({ captured }: Readonly<{ captured: Captured }>) {
captured.store = useGraphStore();
captured.api = useGraph();
return null;
}

it.each(['move', 'delete'] as const)('prunes a retained layer after a controlled cells-only %s with the same layers array', async (mode) => {
const captured: Captured = {};
const onLayersChange = jest.fn();
const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
const tree = (cells: readonly CellRecord[], layers: readonly LayerRecord[]) => (
<GraphProvider cells={cells} layers={layers} onLayersChange={onLayersChange}>
<Capture captured={captured} />
</GraphProvider>
);
const view = render(tree(INITIAL_CELLS, INITIAL_LAYERS));
try {
await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true));
onLayersChange.mockClear();
await act(async () => {
view.rerender(tree(INITIAL_CELLS, REMAINING_LAYERS));
});
expect(captured.store?.graph.hasLayer('notes')).toBe(true);

await act(async () => {
view.rerender(tree(mode === 'move' ? MOVED_CELLS : NO_CELLS, REMAINING_LAYERS));
});

if (mode === 'move') expect(captured.store?.graph.getCell('node').get('layer')).toBe('cells');
else expect(captured.store?.graph.getCell('node')).toBeUndefined();
expect(captured.store?.graph.hasLayer('notes')).toBe(false);
expect(captured.store?.graphProjection.layers.getLayer('notes')).toBeUndefined();
expect(onLayersChange).not.toHaveBeenCalled();
} finally {
view.unmount();
warn.mockRestore();
}
});

it.each(['api move', 'native move', 'native delete'] as const)(
'prunes a retained controlled layer after an uncontrolled %s without a parent update',
async (mode) => {
const captured: Captured = {};
const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
const tree = (layers: readonly LayerRecord[]) => (
<GraphProvider initialCells={INITIAL_CELLS} layers={layers}>
<Capture captured={captured} />
</GraphProvider>
);
const view = render(tree(INITIAL_LAYERS));
try {
await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true));
await act(async () => {
view.rerender(tree(REMAINING_LAYERS));
});
const { store, api } = captured;
if (!store || !api) throw new Error('graph not ready');
expect(store.graph.hasLayer('notes')).toBe(true);

await act(async () => {
if (mode === 'api move') api.setCell(element('cells'));
else if (mode === 'native move') store.graph.getCell('node').layer('cells');
else store.graph.removeCells([store.graph.getCell('node')]);
await new Promise<void>((resolve) => queueMicrotask(resolve));
});

expect(store.graph.hasLayer('notes')).toBe(false);
expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined();
if (mode === 'native delete') expect(store.graph.getCell('node')).toBeUndefined();
else expect(store.graph.getCell('node').layer()).toBe('cells');
} finally {
view.unmount();
warn.mockRestore();
}
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { dia } from '@joint/core';
import { act, render } from '@testing-library/react';
import { useLayoutEffect } from 'react';
import { GraphProvider } from '../graph-provider';
import { useGraph } from '../../../hooks/use-graph';
import type { GraphApi } from '../../../hooks/use-graph';
import type { LayerRecord } from '../../../types/layer.types';

const UPDATED_LAYERS = [{ id: 'cells' }, { id: 'notes', name: 'Updated' }];
const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'cells' }, { id: 'notes' }];

function MountMutation({ mutate }: { readonly mutate: (api: GraphApi) => void }) {
const api = useGraph();
useLayoutEffect(() => {
mutate(api);
}, [api, mutate]);
return null;
}

const disableNotes = (api: GraphApi) => api.setLayer('notes', { enabled: false });
const addLayer = (api: GraphApi) => {
if (!api.graph.hasLayer('child')) api.graph.addLayer({ id: 'child' });
};

describe('GraphProvider layer subscription ownership', () => {
it.each([
['setLayer', disableNotes, { id: 'notes', enabled: false }],
['graph.addLayer', addLayer, { id: 'child' }],
] as const)('reports %s from a child mount layout effect', (_name, mutate, expected) => {
const onLayersChange = jest.fn();
render(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={onLayersChange}>
<MountMutation mutate={mutate} />
</GraphProvider>
);

expect(onLayersChange).toHaveBeenCalledTimes(1);
expect(onLayersChange).toHaveBeenLastCalledWith(expect.arrayContaining([expected]));
});

it('restores controlled layers changed by a child mount layout effect without a handler', async () => {
const graph = new dia.Graph();
render(
<GraphProvider graph={graph} layers={INITIAL_LAYERS}>
<MountMutation mutate={disableNotes} />
</GraphProvider>
);

await act(async () => {
await Promise.resolve();
});
expect(graph.getLayer('notes').get('enabled')).not.toBe(false);
});

it('does not revert external graph layers after the provider unmounts', async () => {
const graph = new dia.Graph();
const { unmount } = render(<GraphProvider graph={graph} layers={INITIAL_LAYERS} />);

act(() => {
graph.getLayer('notes').set('enabled', false);
});
expect(graph.getLayer('notes').get('enabled')).toBe(false);
unmount();

await act(async () => {
await Promise.resolve();
});
expect(graph.getLayer('notes').get('enabled')).toBe(false);
});

it('uses the latest callback and does not echo controlled prop updates', () => {
const graph = new dia.Graph();
const initialHandler = jest.fn();
const nextHandler = jest.fn();
const { rerender } = render(
<GraphProvider graph={graph} layers={INITIAL_LAYERS} onLayersChange={initialHandler} />
);

rerender(<GraphProvider graph={graph} layers={UPDATED_LAYERS} onLayersChange={nextHandler} />);
expect(initialHandler).not.toHaveBeenCalled();
expect(nextHandler).not.toHaveBeenCalled();

act(() => {
graph.getLayer('notes').set('enabled', false);
});
expect(initialHandler).not.toHaveBeenCalled();
expect(nextHandler).toHaveBeenCalledTimes(1);
expect(nextHandler).toHaveBeenLastCalledWith(
expect.arrayContaining([{ id: 'notes', name: 'Updated', enabled: false }])
);
});
});


function CommitLayerChange({ enabled }: { readonly enabled: boolean }) {
const { setLayer } = useGraph();
useLayoutEffect(() => { setLayer('notes', { enabled }); }, [setLayer, enabled]);
return null;
}

it('uses the replacement handler for a child layout-effect write in the same commit', () => {
const oldHandler = jest.fn();
const nextHandler = jest.fn();
const { rerender } = render(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={oldHandler}>
<CommitLayerChange enabled />
</GraphProvider>
);
oldHandler.mockClear();
rerender(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={nextHandler}>
<CommitLayerChange enabled={false} />
</GraphProvider>
);
expect(oldHandler).not.toHaveBeenCalled();
expect(nextHandler).toHaveBeenCalledTimes(1);
expect(nextHandler).toHaveBeenLastCalledWith(
expect.arrayContaining([{ id: 'notes', enabled: false }])
);
});
Loading
Loading