+
,
portalElement
diff --git a/packages/joint-react/src/hooks/__tests__/use-latest-ref.test.tsx b/packages/joint-react/src/hooks/__tests__/use-latest-ref.test.tsx
new file mode 100644
index 0000000000..b8bf1db77a
--- /dev/null
+++ b/packages/joint-react/src/hooks/__tests__/use-latest-ref.test.tsx
@@ -0,0 +1,92 @@
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
+import { Suspense, startTransition, useCallback, useLayoutEffect, useState, type RefObject } from 'react';
+import { useLatestRef } from '../use-latest-ref';
+
+function LayoutReader({ valueRef, tick, record }: Readonly<{
+ valueRef: RefObject
;
+ tick: number;
+ record: (value: string) => void;
+}>) {
+ useLayoutEffect(() => {
+ record(valueRef.current);
+ }, [valueRef, tick, record]);
+ return null;
+}
+
+function Parent({ value, tick, record }: Readonly<{
+ value: string;
+ tick: number;
+ record: (value: string) => void;
+}>) {
+ const valueRef = useLatestRef(value);
+ return ;
+}
+
+describe('useLatestRef commit timing', () => {
+ it('publishes the new value before a descendant layout effect reads it', () => {
+ const record = jest.fn();
+ const { rerender } = render();
+ record.mockClear();
+
+ rerender();
+
+ expect(record).toHaveBeenCalledTimes(1);
+ expect(record).toHaveBeenLastCalledWith('after');
+ });
+
+ it('keeps its ref identity while updating the committed value', () => {
+ const { result, rerender } = renderHook(({ value }) => useLatestRef(value), {
+ initialProps: { value: 'before' },
+ });
+ const first = result.current;
+
+ rerender({ value: 'after' });
+
+ expect(result.current).toBe(first);
+ expect(first.current).toBe('after');
+ });
+
+ it('keeps the committed value when a transition renders and suspends', async () => {
+ const never = new Promise(() => {
+ // This transition deliberately never commits.
+ });
+ const attempted = jest.fn();
+ let committedRef: RefObject | undefined;
+ function Reader({ value }: Readonly<{ value: string }>) {
+ const valueRef = useLatestRef(value);
+ useLayoutEffect(() => {
+ committedRef = valueRef;
+ }, [valueRef]);
+ attempted(value);
+ if (value === 'pending') throw never;
+ return {value};
+ }
+ function App() {
+ const [value, setValue] = useState('before');
+ const suspend = useCallback(() => startTransition(() => setValue('pending')), []);
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+ render();
+ const first = committedRef;
+ expect(first?.current).toBe('before');
+ attempted.mockClear();
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'suspend' }));
+ });
+
+ expect(attempted).toHaveBeenCalledWith('pending');
+ expect(screen.getByTestId('committed-value').textContent).toBe('before');
+ expect(committedRef).toBe(first);
+ expect(first?.current).toBe('before');
+ });
+});
diff --git a/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts b/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts
new file mode 100644
index 0000000000..d664947790
--- /dev/null
+++ b/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts
@@ -0,0 +1,97 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+/**
+ * Type-only tests for `useLayers` / `useLayer`. If the file compiles, the
+ * contract holds; the dummy `describe`/`it` keeps Jest from failing an empty
+ * test file.
+ *
+ * Locked patterns (DX contract):
+ * - `useLayers()` → `readonly LayerRecord[]`
+ * - `useLayers<'a' | 'b'>()` → ids narrowed to the union
+ * - `useLayer('a')` → `LayerRecord | undefined` (never throws for a missing id)
+ * - `useLayer<'a' | 'b'>('a', (layer) => layer.visible !== false)` → `boolean | undefined`
+ * - `useLayer<'a' | 'b'>('nope')` is a type error
+ * - `useLayer(typedId, selector)` infers both generics — the everyday form
+ * - `LayerRecord` keeps custom attributes typed `unknown`, `visible` typed `boolean`
+ */
+import { useLayers } from '../use-layers';
+import { useLayer } from '../use-layer';
+import type { LayerPatch, LayerRecord } from '../../types/layer.types';
+import type { GraphProviderProps } from '../../components/graph/graph-provider';
+
+type DiagramLayerId = 'background' | 'cells' | 'foreground';
+
+/**
+ * Compile-time assertion helper: `T` must be exactly assignable to `Expected`.
+ * @param _value - the value whose type is checked
+ */
+function expectType(_value: Expected): void {
+ return;
+}
+
+function typeChecks() {
+ const untyped = useLayers();
+ expectType(untyped);
+ expectType(untyped[0].id);
+
+ const typed = useLayers();
+ expectType>>(typed);
+ expectType(typed[0].id);
+
+ const one = useLayer('anything');
+ expectType(one);
+
+ const narrow = useLayer('foreground');
+ expectType | undefined>(narrow);
+ expectType(narrow?.visible);
+ expectType(narrow?.name);
+
+ const selected = useLayer(
+ 'foreground',
+ (layer) => layer.visible !== false
+ );
+ expectType(selected);
+
+ // Everyday form: both generics inferred from a typed id and a selector.
+ const typedId: DiagramLayerId = 'foreground';
+ const selectVisible = (layer: LayerRecord) => layer.visible !== false;
+ const inferred = useLayer(typedId, selectVisible);
+ expectType(inferred);
+ // @ts-expect-error -- inferred as boolean, not a record
+ expectType(inferred);
+
+ // @ts-expect-error -- 'nope' is not a DiagramLayerId
+ useLayer('nope');
+
+ const record: LayerRecord = { id: 'background', visible: true, name: 'BG' };
+ const widened: LayerRecord = record;
+ expectType(widened);
+
+ // @ts-expect-error -- visible must be a boolean
+ const bad: LayerRecord = { id: 'x', visible: 'yes' };
+
+ // LayerPatch keeps `visible` typed even though it drops `id`.
+ const patch: LayerPatch = { visible: false, name: 'Notes' };
+ expectType(patch.visible);
+ // @ts-expect-error -- visible must be a boolean on a patch too
+ const badPatch: LayerPatch = { visible: 'yes' };
+ // The reserved fields are not patchable, index signature or not.
+ // @ts-expect-error -- a layer cannot be renamed through a patch
+ const renamePatch: LayerPatch = { id: 'b' };
+ // @ts-expect-error -- isDefault is derived from the graph
+ const defaultPatch: LayerPatch = { isDefault: true };
+
+ // Controlled mode with a typed union: the provider is generic over LayerId.
+ const controlled: GraphProviderProps = {
+ layers: [{ id: 'background' }],
+ onLayersChange: (next) => expectType>>(next),
+ };
+ // @ts-expect-error -- 'nope' is not a DiagramLayerId
+ const badControlled: GraphProviderProps = { layers: [{ id: 'nope' }] };
+ return [bad, badPatch, renamePatch, defaultPatch, controlled, badControlled];
+}
+
+describe('useLayer types', () => {
+ it('compiles', () => {
+ expect(typeof typeChecks).toBe('function');
+ });
+});
diff --git a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx
new file mode 100644
index 0000000000..06d7423394
--- /dev/null
+++ b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx
@@ -0,0 +1,492 @@
+import { act, render, renderHook, waitFor } from '@testing-library/react';
+import { useCallback, useState } from 'react';
+import { flushSync } from 'react-dom';
+import { GraphProvider } from '../../components/graph/graph-provider';
+import { graphProviderWrapper } from '../../utils/test-wrappers';
+import { useGraphStore } from '../use-graph-store';
+import { useGraph } from '../use-graph';
+import { useLayers } from '../use-layers';
+import { useLayer } from '../use-layer';
+import { useCell } from '../use-cell';
+import { selectCellLayer } from '../../selectors/cell-selectors';
+import type { CellRecord } from '../../types/cell.types';
+import type { LayerRecord } from '../../types/layer.types';
+
+const flush = () => new Promise((resolve) => queueMicrotask(resolve));
+/** Runs a mutation inside `act` and drains the store's microtask commit. */
+const commit = (run: () => void) =>
+ act(async () => {
+ run();
+ await flush();
+ });
+
+let storeRef: ReturnType | undefined;
+function StoreProbe() {
+ storeRef = useGraphStore();
+ return null;
+}
+
+const THREE_LAYERS: readonly LayerRecord[] = [
+ { id: 'background' },
+ { id: 'cells' },
+ { id: 'foreground', visible: false },
+];
+const CELLS: readonly CellRecord[] = [
+ { id: 'a', type: 'element', layer: 'background' } as CellRecord,
+ { id: 'b', type: 'element' } as CellRecord,
+];
+
+const selectIds = (layers: readonly LayerRecord[]) => layers.map((layer) => layer.id);
+const withDefaultLayer = graphProviderWrapper({ initialCells: [] });
+const withLayers = graphProviderWrapper({ initialLayers: THREE_LAYERS, initialCells: [] });
+const withLayersAndCells = graphProviderWrapper({
+ initialLayers: THREE_LAYERS,
+ initialCells: CELLS,
+});
+
+/** `renderHook` plus a render counter, for the re-render contract tests. */
+function renderCounted(hook: () => Result, wrapper: typeof withLayers) {
+ const renderSpy = jest.fn();
+ const view = renderHook(
+ () => {
+ renderSpy();
+ return hook();
+ },
+ { wrapper }
+ );
+ return { ...view, renderSpy };
+}
+const selectVisible = (layer: LayerRecord) => layer.visible !== false;
+
+/** Mounts a provider with BOTH `layers` and `cells` controlled from one state object. */
+async function mountBoth(initial: {
+ layers: readonly LayerRecord[];
+ cells: readonly CellRecord[];
+}) {
+ let setBoth!: (layers: readonly LayerRecord[], cells: readonly CellRecord[]) => void;
+ function Both() {
+ const [state, setState] = useState(initial);
+ setBoth = (layers, cells) => setState({ layers, cells });
+ return (
+
+
+
+ );
+ }
+ render();
+ await waitFor(() => expect(storeRef).toBeDefined());
+ return setBoth;
+}
+
+describe('useLayers — uncontrolled', () => {
+ it('reports only the default layer on a fresh provider', () => {
+ const { result } = renderHook(() => useLayers(), {
+ wrapper: withDefaultLayer,
+ });
+ expect(result.current).toEqual([{ id: 'cells', isDefault: true }]);
+ });
+
+ // React-way of core's "Changing default layer": the default is visible on the
+ // record, so an imperative setDefaultLayer() reaches useLayers.
+ it('marks the default layer and follows an imperative setDefaultLayer()', async () => {
+ const { result } = renderHook(() => ({ layers: useLayers(), store: useGraphStore() }), {
+ wrapper: withLayers,
+ });
+ expect(result.current.layers.map((layer) => layer.isDefault === true)).toEqual([
+ false,
+ true,
+ false,
+ ]);
+ await commit(() => {
+ result.current.store.graph.setDefaultLayer('background');
+ });
+ expect(result.current.layers.map((layer) => layer.isDefault === true)).toEqual([
+ true,
+ false,
+ false,
+ ]);
+ });
+
+ it('declares layers in paint order, with the default layer where the array names it', () => {
+ const { result } = renderHook(() => useLayers(), {
+ wrapper: withLayersAndCells,
+ });
+ expect(selectIds(result.current)).toEqual(['background', 'cells', 'foreground']);
+ expect(result.current[2]).toEqual({ id: 'foreground', visible: false });
+ });
+
+ it('assigns cells to their declared layer and leaves untagged cells on the default layer', () => {
+ const { result } = renderHook(() => useGraphStore(), {
+ wrapper: withLayersAndCells,
+ });
+ const { graph } = result.current;
+ expect(graph.getCell('a').layer()).toBe('background');
+ expect(graph.getCell('b').layer()).toBeNull();
+ expect(graph.getLayer('background').cellCollection.has('a')).toBe(true);
+ expect(graph.getDefaultLayer().cellCollection.has('b')).toBe(true);
+ });
+
+ it('reflects an imperative graph.addLayer()', async () => {
+ const { result } = renderHook(() => ({ layers: useLayers(), store: useGraphStore() }), {
+ wrapper: withDefaultLayer,
+ });
+ await commit(() => {
+ result.current.store.graph.addLayer({ id: 'late', name: 'Late' });
+ });
+ expect(result.current.layers).toEqual([
+ { id: 'cells', isDefault: true },
+ { id: 'late', name: 'Late' },
+ ]);
+ });
+
+ it('reflects an imperative graph.fromJSON() carrying layers', async () => {
+ const { result } = renderHook(() => ({ layers: useLayers(), store: useGraphStore() }), {
+ wrapper: withDefaultLayer,
+ });
+ await commit(() => {
+ result.current.store.graph.fromJSON({
+ cells: [],
+ layers: [{ id: 'cells' }, { id: 'imported' }],
+ defaultLayer: 'cells',
+ });
+ });
+ expect(selectIds(result.current.layers)).toEqual(['cells', 'imported']);
+ });
+});
+
+describe('useLayer', () => {
+ it('reads one layer by id, and undefined for an unknown id', () => {
+ const { result } = renderHook(() => [useLayer('foreground'), useLayer('nope')] as const, {
+ wrapper: withLayers,
+ });
+ expect(result.current[0]).toEqual({ id: 'foreground', visible: false });
+ expect(result.current[1]).toBeUndefined();
+ });
+
+ it('selects a primitive and re-renders only when it changes', async () => {
+ const { result, renderSpy } = renderCounted(
+ () => ({ isVisible: useLayer('foreground', selectVisible), api: useGraph() }),
+ withLayers
+ );
+ expect(result.current.isVisible).toBe(false);
+ const baseline = renderSpy.mock.calls.length;
+
+ // Unrelated attribute on the same layer: selected value unchanged → no render.
+ await commit(() => {
+ result.current.api.setLayer('foreground', { name: 'Annotations' });
+ });
+ expect(renderSpy.mock.calls).toHaveLength(baseline);
+
+ await commit(() => {
+ result.current.api.setLayer('foreground', { visible: true });
+ });
+ expect(result.current.isVisible).toBe(true);
+ expect(renderSpy.mock.calls.length).toBeGreaterThan(baseline);
+ });
+
+ it('does not re-render a sibling layer subscriber', async () => {
+ const { result, renderSpy } = renderCounted(
+ () => ({ background: useLayer('background'), api: useGraph() }),
+ withLayers
+ );
+ const baseline = renderSpy.mock.calls.length;
+ await commit(() => {
+ result.current.api.setLayer('foreground', { visible: true });
+ });
+ expect(renderSpy.mock.calls).toHaveLength(baseline);
+ });
+});
+
+describe('useLayers — re-render contract', () => {
+ it('does not re-render on a cell drag', async () => {
+ const { result, renderSpy } = renderCounted(
+ () => ({ layers: useLayers(), store: useGraphStore() }),
+ withLayersAndCells
+ );
+ const baseline = renderSpy.mock.calls.length;
+ const { layers } = result.current;
+ await commit(() => {
+ result.current.store.graph.getCell('a').set('position', { x: 999, y: 0 });
+ });
+ expect(renderSpy.mock.calls).toHaveLength(baseline);
+ expect(result.current.layers).toBe(layers);
+ });
+});
+
+describe('cell ↔ layer membership (React way)', () => {
+ it('moves a cell between layers via setCell and back to the default via layer: undefined', async () => {
+ const { result } = renderHook(
+ () => ({ layer: useCell('b', selectCellLayer), api: useGraph(), store: useGraphStore() }),
+ { wrapper: withLayersAndCells }
+ );
+ expect(result.current.layer).toBeNull();
+
+ await commit(() => {
+ result.current.api.setCell({ id: 'b', type: 'element', layer: 'foreground' });
+ });
+ expect(result.current.layer).toBe('foreground');
+ expect(result.current.store.graph.getLayer('foreground').cellCollection.has('b')).toBe(true);
+
+ await commit(() => {
+ result.current.api.setCell({ id: 'b', type: 'element', layer: undefined });
+ });
+ expect(result.current.layer).toBeNull();
+ expect(result.current.store.graph.getDefaultLayer().cellCollection.has('b')).toBe(true);
+ });
+});
+
+describe('setLayers (uncontrolled writes)', () => {
+ it('adds, reorders and removes in one call', async () => {
+ const { result } = renderHook(() => ({ layers: useLayers(), api: useGraph() }), {
+ wrapper: withLayers,
+ });
+ await commit(() => {
+ result.current.api.setLayers([{ id: 'foreground' }, { id: 'cells' }, { id: 'overlay' }]);
+ });
+ expect(selectIds(result.current.layers)).toEqual(['foreground', 'cells', 'overlay']);
+ });
+
+ it('keeps a dropped layer that still has cells and warns', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const { result } = renderHook(() => ({ layers: useLayers(), api: useGraph() }), {
+ wrapper: withLayersAndCells,
+ });
+ await commit(() => {
+ result.current.api.setLayers([{ id: 'cells' }]);
+ });
+ // Declared layers come first in declared order; the kept layer follows.
+ expect(selectIds(result.current.layers)).toEqual(['cells', 'background']);
+ expect(warn).toHaveBeenCalled();
+ expect(warn.mock.calls[0][0]).toContain('background');
+ warn.mockRestore();
+ });
+});
+
+describe('controlled layers', () => {
+ let setExternal!: (next: readonly LayerRecord[]) => void;
+ const onLayersChange = jest.fn();
+
+ const ONE_LAYER: readonly LayerRecord[] = [{ id: 'background' }];
+ const REORDERED: readonly LayerRecord[] = [{ id: 'background' }, { id: 'cells' }, { id: 'top' }];
+ /** Distinct object, identical content — what an inline prop produces each render. */
+ const ONE_LAYER_EQUAL: readonly LayerRecord[] = [{ id: 'background' }];
+
+ function App({ initial }: Readonly<{ initial: readonly LayerRecord[] }>) {
+ const [layers, setLayers] = useState(initial);
+ setExternal = setLayers;
+ const handleChange = useCallback((next: readonly LayerRecord[]) => {
+ onLayersChange(next);
+ setLayers(next);
+ }, []);
+ return (
+
+
+
+ );
+ }
+
+ beforeEach(() => onLayersChange.mockClear());
+
+ /** Renders the controlled App and waits for the store. */
+ async function mountControlled(initial: readonly LayerRecord[]) {
+ render();
+ await waitFor(() => expect(storeRef).toBeDefined());
+ }
+
+ it('applies the controlled array and re-applies when it changes', async () => {
+ render();
+ await waitFor(() =>
+ expect(storeRef!.graph.getLayers().map((l) => l.id)).toEqual(['cells', 'background'])
+ );
+ await commit(() => {
+ setExternal(REORDERED);
+ });
+ expect(storeRef!.graph.getLayers().map((l) => l.id)).toEqual(['background', 'cells', 'top']);
+ });
+
+ it('emits the full list — including the default layer — on an imperative change', async () => {
+ await mountControlled(ONE_LAYER);
+ onLayersChange.mockClear();
+ await commit(() => {
+ storeRef!.graph.getLayer('background').set('visible', false);
+ });
+ expect(onLayersChange).toHaveBeenCalled();
+ const [emitted] = onLayersChange.mock.calls.at(-1)!;
+ expect(emitted).toEqual([
+ { id: 'cells', isDefault: true },
+ { id: 'background', visible: false },
+ ]);
+ });
+
+ // Regression: layer events were coalesced onto a microtask, so a graph-origin
+ // add followed by a synchronous controlled apply (flushSync) was consumed under
+ // the React-origin guard — the layer was pruned and the parent never told.
+ it('reports a graph-origin layer added right before a synchronous controlled apply', async () => {
+ await mountControlled(ONE_LAYER);
+ onLayersChange.mockClear();
+ await commit(() => {
+ storeRef!.graph.addLayer({ id: 'plugin' });
+ // eslint-disable-next-line @eslint-react/dom/no-flush-sync -- the regression IS the synchronous commit
+ flushSync(() => setExternal(REORDERED));
+ });
+ // The parent was told about `plugin` before its own array (which omits it)
+ // was applied — it could have kept it. That is the contract.
+ const [[notified]] = onLayersChange.mock.calls;
+ expect(notified.map((layer: LayerRecord) => layer.id)).toContain('plugin');
+ });
+
+ it('does not echo the parent\'s own controlled write back through onLayersChange', async () => {
+ await mountControlled(ONE_LAYER);
+ onLayersChange.mockClear();
+ await commit(() => {
+ setExternal(REORDERED);
+ });
+ // The store re-read caused by our own apply is not a change to report.
+ expect(onLayersChange).not.toHaveBeenCalled();
+ });
+
+ it('skips the cells diff when only the layers array changed', async () => {
+ const setBoth = await mountBoth({ layers: ONE_LAYER, cells: CELLS });
+ const syncCells = jest.spyOn(storeRef!.graph, 'syncCells');
+
+ await commit(() => {
+ setBoth(REORDERED, CELLS); // same cells reference
+ });
+ expect(syncCells).not.toHaveBeenCalled();
+ expect(storeRef!.graph.getLayers().map((l) => l.id)).toEqual(['background', 'cells', 'top']);
+
+ await commit(() => {
+ setBoth(REORDERED, [...CELLS]); // new cells reference
+ });
+ expect(syncCells).toHaveBeenCalledTimes(1);
+ });
+
+ it('does no layer work on a cells-only commit when layers are controlled', async () => {
+ const setBoth = await mountBoth({ layers: ONE_LAYER, cells: CELLS });
+ // Every layer code path reads the default layer; a drag frame must read it 0 times.
+ const getDefaultLayer = jest.spyOn(storeRef!.graph, 'getDefaultLayer');
+ await commit(() => {
+ setBoth(ONE_LAYER, [...CELLS]); // same layers reference, new cells
+ });
+ expect(getDefaultLayer).not.toHaveBeenCalled();
+ });
+
+ it('reverts a burst of graph-origin changes with one apply when uncontrolled by a handler', async () => {
+ render(
+
+
+
+ );
+ await waitFor(() => expect(storeRef).toBeDefined());
+ // `updateGraph` is looked up at call time, so the spy sees every revert apply.
+ const updateGraph = jest.spyOn(storeRef!.graphProjection, 'updateGraph');
+ await commit(() => {
+ storeRef!.graph.addLayer({ id: 'p1' });
+ storeRef!.graph.addLayer({ id: 'p2' });
+ storeRef!.graph.addLayer({ id: 'p3' });
+ });
+ expect(updateGraph).toHaveBeenCalledTimes(1);
+ expect(storeRef!.graph.getLayers().map((l) => l.id)).toEqual(['cells', 'background']);
+ });
+
+ it('does not touch the graph for an equal-but-new array', async () => {
+ await mountControlled(ONE_LAYER);
+ const { graph } = storeRef!;
+ const spies = [
+ jest.spyOn(graph, 'addLayer'),
+ jest.spyOn(graph, 'moveLayer'),
+ jest.spyOn(graph, 'removeLayer'),
+ ];
+ await commit(() => {
+ setExternal(ONE_LAYER_EQUAL);
+ });
+ for (const spy of spies) expect(spy).not.toHaveBeenCalled();
+ });
+});
+
+describe('apply order (React way of core\'s throw cases)', () => {
+ it('accepts a layer and a cell naming it declared in the same commit', async () => {
+ const setBoth = await mountBoth({ layers: [], cells: [] });
+
+ // Both arrive in one render: the cell names a layer that does not exist yet.
+ await commit(() => {
+ setBoth([{ id: 'fresh' }], [{ id: 'x', type: 'element', layer: 'fresh' } as CellRecord]);
+ });
+ const { graph } = storeRef!;
+ expect(graph.getCell('x').layer()).toBe('fresh');
+ expect(graph.getLayer('fresh').cellCollection.has('x')).toBe(true);
+
+ // And the reverse in one render: the cell leaves and the layer is dropped.
+ await commit(() => {
+ setBoth([], []);
+ });
+ expect(graph.getLayers().map((layer) => layer.id)).toEqual(['cells']);
+ });
+});
+
+// Regression: on a graph-origin `reset` (`fromJSON`) the cells were published
+// before the layer snapshot was refreshed, so a controlled provider without
+// handlers re-applied its cells while its layers were still marked as applied
+// — and a cell naming the reset-away layer threw.
+describe('controlled cells + layers on an imperative fromJSON()', () => {
+ it('re-applies the layers together with the cells', async () => {
+ const LAYERS: readonly LayerRecord[] = [{ id: 'x' }];
+ const CELLS_ON_X: readonly CellRecord[] = [
+ { id: 'c', type: 'element', layer: 'x' } as CellRecord,
+ ];
+ await mountBoth({ layers: LAYERS, cells: CELLS_ON_X });
+ const { graph } = storeRef!;
+
+ await commit(() => {
+ graph.fromJSON({ cells: [], layers: [{ id: 'cells' }], defaultLayer: 'cells' });
+ });
+
+ // The parent's arrays are the truth: both are back, in one apply.
+ expect(graph.getLayers().map((layer) => layer.id)).toEqual(['cells', 'x']);
+ expect(graph.getCell('c').layer()).toBe('x');
+ });
+});
+
+// Regression: the cell-commit re-apply of a controlled provider (no
+// `onCellsChange`) applied the layers outside the React-origin guard and never
+// recorded them as applied, so the parent's own array echoed through
+// `onLayersChange` and every later cell commit reconciled the layers again.
+const moveCell = () => storeRef!.graph.getCell('b').set('position', { x: 10, y: 10 });
+
+describe('controlled cells + layers after a graph-origin layer change', () => {
+ const LAYERS: readonly LayerRecord[] = [{ id: 'background' }];
+
+ it('re-applies the layers once, then does no layer work on later cell commits', async () => {
+ await mountBoth({ layers: LAYERS, cells: CELLS });
+ await commit(() => {
+ storeRef!.graph.addLayer({ id: 'stray' });
+ });
+ expect(storeRef!.graph.hasLayer('stray')).toBe(false);
+
+ const getDefaultLayer = jest.spyOn(storeRef!.graph, 'getDefaultLayer');
+ await commit(moveCell);
+ expect(getDefaultLayer).not.toHaveBeenCalled();
+ });
+
+ it('does not echo the re-applied array to a parent that ignored the change', async () => {
+ const onLayersChange = jest.fn();
+ render(
+
+
+
+ );
+ await waitFor(() => expect(storeRef).toBeDefined());
+ await commit(() => {
+ storeRef!.graph.addLayer({ id: 'stray' });
+ });
+ expect(onLayersChange).toHaveBeenCalledTimes(1);
+
+ await commit(moveCell);
+ expect(storeRef!.graph.hasLayer('stray')).toBe(false);
+ expect(onLayersChange).toHaveBeenCalledTimes(1);
+
+ const getDefaultLayer = jest.spyOn(storeRef!.graph, 'getDefaultLayer');
+ await commit(moveCell);
+ expect(getDefaultLayer).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts
index 8693acb52f..edd869852c 100644
--- a/packages/joint-react/src/hooks/index.ts
+++ b/packages/joint-react/src/hooks/index.ts
@@ -1,5 +1,7 @@
export * from './use-cells';
export * from './use-cell';
+export * from './use-layers';
+export * from './use-layer';
export * from './use-graph';
export * from './use-paper';
export * from './use-measure-element';
diff --git a/packages/joint-react/src/hooks/use-graph.ts b/packages/joint-react/src/hooks/use-graph.ts
index a6c703e210..048ebd761b 100644
--- a/packages/joint-react/src/hooks/use-graph.ts
+++ b/packages/joint-react/src/hooks/use-graph.ts
@@ -13,6 +13,7 @@ import {
type SetCellData,
} from './use-cell-setters';
import type { ArrayUpdate } from '../store/state-container';
+import type { LayerPatch, LayerRecord } from '../types/layer.types';
import type {
ElementJSONInit,
LinkJSONInit,
@@ -160,6 +161,21 @@ export interface GraphApi<
* {@link Transaction}.
*/
readonly transaction: Transaction;
+ /**
+ * Declare the layers, in paint order (index 0 at the bottom). Layers not yet
+ * in the graph are added, existing ones reordered and their attributes
+ * updated, and layers missing from the array removed once they hold no
+ * cells — a non-empty one is kept and a dev warning names its cells. The
+ * default `cells` layer is never removed: omit it and it stays at the bottom.
+ * Accepts an array or an updater receiving the current ordered layers:
+ * `setLayers((previous) => [...previous].reverse())`.
+ */
+ readonly setLayers: (layers: ArrayUpdate) => void;
+ /**
+ * Merge attributes into one layer, adding it (at the top) when it does not
+ * exist yet. `setLayer('notes', { visible: false })` hides a layer.
+ */
+ readonly setLayer: (id: string, patch: LayerPatch) => void;
}
/**
@@ -266,6 +282,18 @@ export function useGraph<
[graph]
);
+ // Not `useLayers()`/`useLayer()`: those subscribe, and a value only read
+ // inside a callback must not re-render every `useGraph()` consumer.
+ const setLayers = useCallback['setLayers']>(
+ (layers) => store.applyLayers(layers),
+ [store]
+ );
+
+ const setLayer = useCallback['setLayer']>(
+ (id, patch) => store.applyLayer(id, patch),
+ [store]
+ );
+
return useMemo(
() => ({
graph,
@@ -280,6 +308,8 @@ export function useGraph<
exportToJSON,
importFromJSON,
transaction,
+ setLayers,
+ setLayer,
}),
[
graph,
@@ -293,6 +323,8 @@ export function useGraph<
exportToJSON,
importFromJSON,
transaction,
+ setLayers,
+ setLayer,
]
);
}
diff --git a/packages/joint-react/src/hooks/use-latest-ref.ts b/packages/joint-react/src/hooks/use-latest-ref.ts
index 052174369c..9263be33e2 100644
--- a/packages/joint-react/src/hooks/use-latest-ref.ts
+++ b/packages/joint-react/src/hooks/use-latest-ref.ts
@@ -1,8 +1,9 @@
-import { useLayoutEffect, useRef, type RefObject } from 'react';
+import { useInsertionEffect, useRef, type RefObject } from 'react';
/**
* Returns a ref that always holds the latest `value`, updated in the commit
- * phase (render-phase ref writes are unsafe under the React Compiler).
+ * phase before descendant layout effects can publish changes. Render-phase
+ * ref writes are unsafe under the React Compiler and abandoned renders.
*
* Read `ref.current` inside event handlers or subscription dispatchers to get
* always-fresh values without listing them as effect dependencies, the
@@ -14,7 +15,7 @@ import { useLayoutEffect, useRef, type RefObject } from 'react';
*/
export function useLatestRef(value: Value): RefObject {
const ref = useRef(value);
- useLayoutEffect(() => {
+ useInsertionEffect(() => {
ref.current = value;
});
return ref;
diff --git a/packages/joint-react/src/hooks/use-layer.ts b/packages/joint-react/src/hooks/use-layer.ts
new file mode 100644
index 0000000000..b8fbedb8d0
--- /dev/null
+++ b/packages/joint-react/src/hooks/use-layer.ts
@@ -0,0 +1,89 @@
+import { useCallback } from 'react';
+import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector';
+import { useGraphStore } from './use-graph-store';
+import { arrayAwareEqual } from '../utils/selector-utils';
+import type { LayerRecord } from '../types/layer.types';
+
+/** What the implementation resolves to: the record, a selection from it, or nothing. */
+type LayerSelection = LayerRecord | Selected | undefined;
+
+/**
+ * Subscribe to one layer by id. Returns `undefined` while no layer with that
+ * id exists (for example before a controlled `layers` array adds it), so it
+ * never throws.
+ * @title Read a layer by id
+ * @template LayerId - Union of your layer ids. Defaults to `string`.
+ * @param id - The layer id to track.
+ * @returns The layer record, or `undefined`.
+ * @group Hooks
+ * @example
+ * ```tsx
+ * import { useLayer } from '@joint/react';
+ *
+ * function LayerBadge() {
+ * const layer = useLayer('foreground');
+ * return {layer?.visible === false ? 'hidden' : 'shown'};
+ * }
+ * ```
+ */
+export function useLayer(
+ id: LayerId
+): LayerRecord | undefined;
+/**
+ * Subscribe to a value derived from one layer. Re-renders only when the
+ * selected value changes — the same shallow, array-aware comparison `useCell`
+ * uses, or your `isEqual` — so select a primitive where you can:
+ * `useLayer('foreground', (layer) => layer.visible !== false)` does not
+ * re-render when the layer's name changes.
+ * @title Select from a layer by id
+ * @template LayerId - Union of your layer ids. Defaults to `string`.
+ * @template Selected - The selector's return type.
+ * @param id - The layer id to track.
+ * @param selector - Derives a value from the layer record. Hoist it to module
+ * scope or memoise it; an inline closure re-runs the selector each render.
+ * @param isEqual - Equality used to skip re-renders. Defaults to a shallow,
+ * array-aware comparison that falls back to `Object.is` for scalars.
+ * @returns The selected value, or `undefined` while the layer does not exist.
+ * @example
+ * ```tsx
+ * import { useLayer, type LayerRecord } from '@joint/react';
+ *
+ * const selectVisible = (layer: LayerRecord) => layer.visible !== false;
+ *
+ * function VisibilityToggle() {
+ * const isVisible = useLayer('foreground', selectVisible);
+ * return ;
+ * }
+ * ```
+ */
+export function useLayer>(
+ id: LayerId,
+ selector: (layer: LayerRecord) => Selected,
+ isEqual?: (a: Selected | undefined, b: Selected | undefined) => boolean
+): Selected | undefined;
+// Implementation on `string` ids; the overloads above narrow to the caller's
+// union without a cast.
+export function useLayer(
+ id: string,
+ selector?: (layer: LayerRecord) => Selected,
+ isEqual?: (a: LayerSelection, b: LayerSelection) => boolean
+): LayerSelection {
+ const { layers } = useGraphStore().graphProjection;
+ // The list is the subscription unit, but the read is by id against a
+ // per-snapshot index, so this stays O(1) however many layers exist.
+ const select = useCallback(
+ (): LayerSelection => {
+ const layer = layers.getLayer(id);
+ if (layer === undefined) return undefined;
+ return selector ? selector(layer) : layer;
+ },
+ [layers, id, selector]
+ );
+ return useSyncExternalStoreWithSelector>(
+ layers.subscribe,
+ layers.getSnapshot,
+ layers.getSnapshot,
+ select,
+ isEqual ?? arrayAwareEqual
+ );
+}
diff --git a/packages/joint-react/src/hooks/use-layers.ts b/packages/joint-react/src/hooks/use-layers.ts
new file mode 100644
index 0000000000..b1a948c2f7
--- /dev/null
+++ b/packages/joint-react/src/hooks/use-layers.ts
@@ -0,0 +1,40 @@
+import { useSyncExternalStore } from 'react';
+import { useGraphStore } from './use-graph-store';
+import type { LayerRecord } from '../types/layer.types';
+
+/**
+ * Subscribe to the graph's layers, bottom to top. Re-renders when a layer is
+ * added, removed, reordered, or has an attribute changed — never on a cell
+ * change, so a layers panel does no work during a drag.
+ *
+ * The array is the store's own reference-stable snapshot: equal across
+ * unrelated commits, so it is safe as an effect dependency.
+ * @template LayerId - Union of your layer ids, for autocomplete and narrowing.
+ * Defaults to `string`.
+ * @returns The ordered layers.
+ * @group Hooks
+ * @example
+ * ```tsx
+ * import { useLayers } from '@joint/react';
+ *
+ * type DiagramLayerId = 'background' | 'cells' | 'foreground';
+ *
+ * function LayersPanel() {
+ * const layers = useLayers();
+ * return (
+ *
+ * {layers.map((layer) => (
+ * - {layer.id}
+ * ))}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useLayers(): ReadonlyArray>;
+// The store holds `string` ids; the overload above narrows them to the union
+// the caller declares, the same unchecked narrowing `useCells` performs.
+export function useLayers(): readonly LayerRecord[] {
+ const { layers } = useGraphStore().graphProjection;
+ return useSyncExternalStore(layers.subscribe, layers.getSnapshot);
+}
diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts
index 368aabb4f4..8376d8344e 100644
--- a/packages/joint-react/src/index.ts
+++ b/packages/joint-react/src/index.ts
@@ -118,6 +118,19 @@ export { useCells } from './hooks/use-cells';
*/
export { useCell } from './hooks/use-cell';
+/**
+ * useLayers()
+ * @group Hooks
+ */
+export { useLayers } from './hooks/use-layers';
+/**
+ * useLayer()
+ * @group Hooks
+ */
+export { useLayer } from './hooks/use-layer';
+/** @group Types */
+export type { LayerRecord, LayerPatch } from './types/layer.types';
+
/**
* useCellId()
* @group Hooks
@@ -284,6 +297,14 @@ export type { LinkMarkerName, LinkMarker } from './theme/named-link-markers';
export { ElementModel, ELEMENT_MODEL_TYPE } from './mvc/element-model';
/** @group MVC */
export { LinkModel, LINK_MODEL_TYPE } from './mvc/link-model';
+/**
+ * Layer model and view
+ * @group MVC
+ */
+export { LayerModel, LAYER_MODEL_TYPE } from './mvc/layer-model';
+export type { LayerModelAttributes } from './mvc/layer-model';
+/** @group MVC */
+export { LayerView } from './mvc/layer-view';
// Utilities
// ---------
diff --git a/packages/joint-react/src/internal.ts b/packages/joint-react/src/internal.ts
index c6cbbe17a3..0ee58d7c4e 100644
--- a/packages/joint-react/src/internal.ts
+++ b/packages/joint-react/src/internal.ts
@@ -27,7 +27,7 @@ export type { FeaturesContext as FeaturesContextType } from './context';
export { FeaturesProvider } from './components';
// Store Classes
-export { GraphStore, DEFAULT_CELL_NAMESPACE } from './store/graph-store';
+export { GraphStore, DEFAULT_CELL_NAMESPACE, DEFAULT_LAYER_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';
diff --git a/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx b/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx
new file mode 100644
index 0000000000..13a8c5af71
--- /dev/null
+++ b/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx
@@ -0,0 +1,155 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { dia } from '@joint/core';
+import { paperRenderElementWrapper } from '../../utils/test-wrappers';
+import { usePaper } from '../../hooks/use-paper';
+import { useGraph } from '../../hooks/use-graph';
+import { useGraphStore } from '../../hooks/use-graph-store';
+import { LayerModel, LAYER_MODEL_TYPE } from '../layer-model';
+import { LayerView } from '../layer-view';
+import type { CellRecord } from '../../types/cell.types';
+import type { LayerRecord } from '../../types/layer.types';
+import type { GraphProviderProps } from '../../components/graph/graph-provider';
+import type { PaperProps } from '../../components/paper/paper.types';
+
+const flush = () => new Promise((resolve) => queueMicrotask(resolve));
+/** Runs a mutation inside `act` and drains the store's microtask commit. */
+const commit = (run: () => void) =>
+ act(async () => {
+ run();
+ await flush();
+ });
+
+const LAYERS: readonly LayerRecord[] = [
+ { id: 'cells' },
+ { id: 'notes', visible: false },
+ { id: 'overlay' },
+];
+const CELLS: readonly CellRecord[] = [
+ { id: 'n', type: 'element', size: { width: 10, height: 10 }, layer: 'notes' } as CellRecord,
+];
+const renderRect = () => ;
+
+interface MountOptions {
+ readonly graphProviderProps?: Partial;
+ readonly paperProps?: Partial;
+}
+
+function makeWrapper(id: string, { graphProviderProps, paperProps }: MountOptions) {
+ return paperRenderElementWrapper({
+ graphProviderProps: { initialLayers: LAYERS, initialCells: CELLS, ...graphProviderProps },
+ paperProps: { id, renderElement: renderRect, ...paperProps },
+ });
+}
+
+/** Mounts a paper with the layers fixture and resolves once the paper exists. */
+async function mountPaper(id: string, options: MountOptions = {}) {
+ const { result } = renderHook(
+ () => ({ paperApi: usePaper(id), api: useGraph(), store: useGraphStore() }),
+ { wrapper: makeWrapper(id, options) }
+ );
+ await waitFor(() => expect(result.current.paperApi.paper).not.toBeNull());
+ return { result, paper: result.current.paperApi.paper! };
+}
+
+describe('LayerModel / LayerView registration', () => {
+ it('keeps the plain layer type, so the graph JSON is unchanged', () => {
+ expect(LAYER_MODEL_TYPE).toBe(dia.GraphLayer.prototype.defaults().type);
+ });
+
+ it('constructs the default layer and declared layers as LayerModel, rendered by LayerView', async () => {
+ const { result, paper } = await mountPaper('reg');
+ const { graph } = result.current.store;
+ expect(graph.getDefaultLayer()).toBeInstanceOf(LayerModel);
+ expect(graph.getLayer('notes')).toBeInstanceOf(LayerModel);
+ expect(paper.getLayerView('cells')).toBeInstanceOf(LayerView);
+ expect(paper.getLayerView('notes')).toBeInstanceOf(LayerView);
+ });
+
+ it('merges a custom layerNamespace on top of the built-in', async () => {
+ class TintLayer extends LayerModel {
+ defaults() {
+ return { ...super.defaults(), type: 'TintLayer' };
+ }
+ }
+ // joint-core resolves a layer's view as `View`, so a custom type needs
+ // a view registered too. The wrapper mounts the hook inside `renderElement`,
+ // so the graph also needs a cell.
+ const { result, paper } = await mountPaper('custom-ns', {
+ graphProviderProps: {
+ layerNamespace: { TintLayer },
+ initialLayers: [{ id: 'cells' }, { id: 'tint', type: 'TintLayer', visible: false }],
+ initialCells: [{ id: 't', type: 'element', layer: 'tint' } as CellRecord],
+ },
+ paperProps: { layerViewNamespace: { TintLayerView: LayerView } },
+ });
+ const { graph } = result.current.store;
+ expect(graph.getLayer('tint')).toBeInstanceOf(TintLayer);
+ expect(graph.getDefaultLayer()).toBeInstanceOf(LayerModel);
+ expect(paper.getLayerView('tint')).toBeInstanceOf(LayerView);
+ expect(paper.getLayerView('tint').el.style.visibility).toBe('hidden');
+ });
+});
+
+describe('layer visibility on ', () => {
+ it('hides a layer declared with visible: false and keeps its cells mounted', async () => {
+ const { paper } = await mountPaper('vis-initial');
+ expect(paper.getLayerView('notes').el.style.visibility).toBe('hidden');
+ expect(paper.getLayerView('overlay').el.style.visibility).toBe('');
+ // Hidden means not painted, not unmounted: the cell view is still there
+ // and, unlike `display: none`, keeps its bounding box for measurement.
+ expect(paper.findViewByModel('n')).toBeDefined();
+ });
+
+ it('toggles visibility when visible changes, without touching cell views', async () => {
+ const { result, paper } = await mountPaper('vis-toggle');
+ const viewBefore = paper.findViewByModel('n');
+
+ await commit(() => {
+ result.current.api.setLayer('notes', { visible: true });
+ });
+ expect(paper.getLayerView('notes').el.style.visibility).toBe('');
+ expect(paper.findViewByModel('n')).toBe(viewBefore);
+
+ await commit(() => {
+ result.current.api.setLayer('notes', { visible: false });
+ });
+ expect(paper.getLayerView('notes').el.style.visibility).toBe('hidden');
+ });
+
+ it('applies visibility to a layer added after the paper mounted', async () => {
+ const { result, paper } = await mountPaper('vis-late');
+ await commit(() => {
+ result.current.api.setLayer('late', { visible: false });
+ });
+ expect(paper.getLayerView('late').el.style.visibility).toBe('hidden');
+ });
+});
+
+// Regression (fixed in joint-core's `dia.Paper.onGraphLayerAdd`): the paper
+// defers a layer view removal but early-returned on a re-add while that
+// removal was still pending, so dropping and re-declaring a layer within one
+// frame left it without a view — the next cell placed on it threw
+// `Unknown layer view` from the async update loop and never rendered.
+describe('re-declaring a layer within one frame', () => {
+ it('gets a view bound to the new layer and renders a cell added to it afterwards', async () => {
+ const { result, paper } = await mountPaper('vis-readd');
+
+ await commit(() => {
+ result.current.api.setLayers([{ id: 'cells' }, { id: 'notes' }]); // drops 'overlay'
+ result.current.api.setLayers([{ id: 'cells' }, { id: 'notes' }, { id: 'overlay' }]);
+ });
+ paper.updateViews();
+ const { graph } = result.current.store;
+ expect(graph.hasLayer('overlay')).toBe(true);
+ expect(paper.hasLayerView('overlay')).toBe(true);
+ expect(paper.getLayerView('overlay').model).toBe(graph.getLayer('overlay'));
+
+ await commit(() => {
+ result.current.api.setCell({ id: 'late', type: 'element', layer: 'overlay' });
+ });
+ paper.updateViews();
+ const view = paper.findViewByModel('late');
+ expect(view).toBeDefined();
+ expect(paper.getLayerView('overlay').el.contains(view.el)).toBe(true);
+ });
+});
diff --git a/packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts b/packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts
new file mode 100644
index 0000000000..ee425ec451
--- /dev/null
+++ b/packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts
@@ -0,0 +1,152 @@
+import { dia, shapes } from '@joint/core';
+import { GraphStore } from '../../store/graph-store';
+import { LayerView } from '../layer-view';
+import { PaperView } from '../paper';
+import type { LayerRecord } from '../../types/layer.types';
+
+describe('graph layer painting boundaries', () => {
+ let store: GraphStore;
+ let paper: PaperView;
+
+ function mount(
+ initialLayers?: readonly LayerRecord[],
+ labelsLayer = false,
+ cellViewNamespace?: dia.Paper.Options['cellViewNamespace']
+ ) {
+ store = new GraphStore({ initialLayers });
+ paper = new PaperView({
+ id: 'layer-visibility-regression',
+ model: store.graph,
+ async: false,
+ frozen: false,
+ labelsLayer,
+ cellViewNamespace,
+ });
+ document.body.append(paper.el);
+ }
+
+ function addLink(id: string, layer?: string, withLabel = true) {
+ const link = new shapes.standard.Link({
+ id,
+ layer,
+ source: { x: 10, y: 10 },
+ target: { x: 100, y: 100 },
+ labels: withLabel ? [{ attrs: { text: { text: id } } }] : [],
+ });
+ store.graph.addCell(link);
+ paper.updateViews();
+ return link;
+ }
+
+ function detachedLabels(id: string): SVGElement {
+ const labels = paper.getLayerView('labels').el.querySelector(
+ `.labels[model-id="${id}"]`
+ );
+ if (!labels) throw new Error(`Missing detached labels for ${id}`);
+ return labels;
+ }
+
+ afterEach(() => {
+ paper?.remove();
+ store?.destroy(false);
+ });
+
+ it('uses the registered graph layer view when only the implicit default layer exists', () => {
+ mount();
+ expect(paper.getLayerView('cells')).toBeInstanceOf(LayerView);
+ expect(paper.getLayerView('cells').el.classList.contains('joint-viewport')).toBe(true);
+ store.applyLayer('cells', { visible: false });
+ expect(paper.getLayerView('cells').el.style.visibility).toBe('hidden');
+ });
+
+ it('paints an explicitly hidden default-only initial layer as hidden', () => {
+ mount([{ id: 'cells', visible: false }]);
+ expect(paper.getLayerView('cells').el.style.visibility).toBe('hidden');
+ });
+
+ it('keeps the default layer hidden when a named layer is added after the paper mounted', () => {
+ mount();
+ store.applyLayer('cells', { visible: false });
+ store.applyLayer('notes', {});
+ paper.updateViews();
+ expect(paper.getLayerView('cells').el.style.visibility).toBe('hidden');
+ expect(paper.getLayerView('notes').el.style.visibility).toBe('');
+ expect(paper.getLayerView('cells').el.classList.contains('joint-viewport')).toBe(true);
+ expect(paper.getLayerView('notes').el.classList.contains('joint-viewport')).toBe(false);
+ });
+
+ it('preserves fresh default-layer z ordering and existing views when a cell is removed', () => {
+ mount();
+ const lower = new shapes.standard.Rectangle({ id: 'lower', z: 1 });
+ const upper = new shapes.standard.Rectangle({ id: 'upper', z: 2 });
+ store.graph.addCells([lower, upper]);
+ paper.updateViews();
+ const lowerView = paper.findViewByModel(lower);
+ const upperView = paper.findViewByModel(upper);
+ const layer = paper.getLayerView('cells');
+ const paintedIds = () =>
+ [...layer.el.children]
+ .map((node) => node.getAttribute('model-id'))
+ .filter((id) => id !== null);
+
+ expect(paintedIds()).toEqual(['lower', 'upper']);
+ lower.set('z', 3);
+ paper.updateViews();
+ expect(paintedIds()).toEqual(['upper', 'lower']);
+ expect(paper.findViewByModel(lower)).toBe(lowerView);
+ upper.remove();
+ paper.updateViews();
+ expect(paintedIds()).toEqual(['lower']);
+ expect(layer.el.contains(upperView.el)).toBe(false);
+ expect(paper.findViewByModel(lower)).toBe(lowerView);
+ });
+
+ it('hides detached labels with their own layer without hiding other links labels', () => {
+ mount([{ id: 'cells' }, { id: 'notes', visible: false }], true);
+ addLink('hidden-link', 'notes');
+ addLink('visible-link');
+ const hiddenLabels = detachedLabels('hidden-link');
+ const visibleLabels = detachedLabels('visible-link');
+ expect(getComputedStyle(hiddenLabels).visibility).toBe('hidden');
+ expect(getComputedStyle(visibleLabels).visibility).not.toBe('hidden');
+
+ store.applyLayer('notes', { visible: true });
+ expect(getComputedStyle(hiddenLabels).visibility).not.toBe('hidden');
+ store.applyLayer('notes', { visible: false });
+ expect(getComputedStyle(hiddenLabels).visibility).toBe('hidden');
+ expect(getComputedStyle(visibleLabels).visibility).not.toBe('hidden');
+ });
+
+ it('hides labels created after a link mounts in a hidden layer', () => {
+ mount([{ id: 'cells' }, { id: 'notes', visible: false }], true);
+ const link = addLink('late-label', 'notes', false);
+ link.labels([{}, { attrs: { text: { text: 'Created while hidden' } } }]);
+ paper.updateViews();
+ const labels = detachedLabels('late-label');
+ expect(labels.children).toHaveLength(2);
+ expect(getComputedStyle(labels).visibility).toBe('hidden');
+ });
+
+ it('updates detached label visibility when a link moves into and out of a hidden layer', () => {
+ mount([{ id: 'cells' }, { id: 'notes', visible: false }], true);
+ const link = addLink('moving-link');
+ link.layer('notes');
+ paper.updateViews();
+ expect(getComputedStyle(detachedLabels('moving-link')).visibility).toBe('hidden');
+
+ link.layer('cells');
+ paper.updateViews();
+ expect(getComputedStyle(detachedLabels('moving-link')).visibility).not.toBe('hidden');
+ });
+
+ it('applies detached label visibility to a user-registered core LinkView subclass', () => {
+ class CustomLinkView extends dia.LinkView {}
+ const namespace = { standard: { LinkView: CustomLinkView } };
+ mount([{ id: 'cells' }, { id: 'notes', visible: false }], true, namespace);
+ const link = addLink('custom-link', 'notes');
+ expect(paper.findViewByModel(link)).toBeInstanceOf(CustomLinkView);
+ expect(getComputedStyle(detachedLabels('custom-link')).visibility).toBe('hidden');
+ store.applyLayer('notes', { visible: true });
+ expect(getComputedStyle(detachedLabels('custom-link')).visibility).not.toBe('hidden');
+ });
+});
diff --git a/packages/joint-react/src/mvc/layer-model.ts b/packages/joint-react/src/mvc/layer-model.ts
new file mode 100644
index 0000000000..41cbc9ac99
--- /dev/null
+++ b/packages/joint-react/src/mvc/layer-model.ts
@@ -0,0 +1,36 @@
+import { dia } from '@joint/core';
+
+/**
+ * The `type` of a plain graph layer: joint-core's `'GraphLayer'`, which
+ * {@link LayerModel} keeps so that a graph's JSON is unchanged by it.
+ * @group MVC
+ */
+export const LAYER_MODEL_TYPE = 'GraphLayer';
+
+/**
+ * Attributes of a {@link LayerModel}: joint-core's `id` and `type` plus the
+ * attributes the `@joint/react` layers API interprets.
+ * @group MVC
+ */
+export interface LayerModelAttributes extends dia.GraphLayer.Attributes {
+ /** Whether the layer is painted. See {@link LayerRecord.visible}. */
+ readonly visible?: boolean;
+}
+
+/**
+ * The layer class `@joint/react` registers for plain graph layers: it replaces
+ * joint-core's `GraphLayer` under the same `type`, so the default `cells` layer
+ * and every layer declared without a `type` are instances of it and their JSON
+ * is unchanged. It declares the attributes the layers API interprets (today
+ * `visible`), so further layer options live here and in {@link LayerView}
+ * rather than on the paper. Extend it, or register your own `dia.GraphLayer`
+ * subclass under another `type` through the `layerNamespace` prop.
+ * @group MVC
+ * @example
+ * ```ts
+ * import { LayerModel } from '@joint/react';
+ *
+ * const notes = new LayerModel({ id: 'notes', visible: false });
+ * ```
+ */
+export class LayerModel extends dia.GraphLayer {}
diff --git a/packages/joint-react/src/mvc/layer-view.ts b/packages/joint-react/src/mvc/layer-view.ts
new file mode 100644
index 0000000000..1ea031f0a3
--- /dev/null
+++ b/packages/joint-react/src/mvc/layer-view.ts
@@ -0,0 +1,30 @@
+import { dia } from '@joint/core';
+import type { LayerModel } from './layer-model';
+
+/**
+ * The layer view `@joint/react` registers for plain graph layers (see
+ * {@link LayerModel}). It mirrors the layer's `visible` attribute onto its group
+ * as `visibility: hidden`, one style write per toggle: the cell views stay
+ * mounted, keep their bounding boxes and so stay measurable, and only the
+ * painting and pointer events stop. Extend it, or register your own view for
+ * a custom layer type through the paper's `layerViewNamespace`.
+ * @group MVC
+ */
+export class LayerView extends dia.GraphLayerView {
+ /**
+ * Subscribes to the layer's own `visible` changes (its model events carry the
+ * `layer:` prefix) and applies the current value; the base class stops
+ * listening to the model when the paper reference is unset.
+ * @param paper - The paper this view was added to.
+ */
+ protected afterPaperReferenceSet(paper: dia.Paper): void {
+ super.afterPaperReferenceSet(paper);
+ this.listenTo(this.model, 'layer:change:visible', this.updateVisibility);
+ this.updateVisibility();
+ }
+
+ /** Writes the layer's `visible` attribute to the group's `visibility` style. */
+ protected updateVisibility(): void {
+ this.el.style.visibility = this.model.get('visible') === false ? 'hidden' : '';
+ }
+}
diff --git a/packages/joint-react/src/mvc/paper.ts b/packages/joint-react/src/mvc/paper.ts
index 9536dbf854..89c06a44ad 100644
--- a/packages/joint-react/src/mvc/paper.ts
+++ b/packages/joint-react/src/mvc/paper.ts
@@ -1,15 +1,24 @@
-import type { dia, mvc } from '@joint/core';
+import { config, dia, type mvc } from '@joint/core';
import type { CellId } from '../types/cell.types';
import type { PortalHostCell, PortalSelector, PaperViewOptions } from './paper.types';
import type { IncrementalChange } from '../state/incremental.types';
import { simpleScheduler } from '../utils/scheduler';
import { Paper } from '../presets/paper';
import { MEASURING_CLASS_NAME } from '../utils/class-names';
+import { LayerView } from './layer-view';
export const CLEANUP_EVENT_NAME = 'cleanup';
const noopViewMountChange = (): void => {
// No-op default for onViewMountChange callback
};
+/**
+ * Layer views `@joint/react` registers on every paper, keyed as joint-core
+ * resolves them (`View`): plain graph layers, the default `cells`
+ * layer included, render through {@link LayerView}. A paper's own
+ * `layerViewNamespace` is merged on top.
+ */
+const DEFAULT_LAYER_VIEW_NAMESPACE = { GraphLayerView: LayerView };
+
/** Well-known paper ID used when no explicit `id` is provided to ``. */
export const DEFAULT_PAPER_ID = 'default-paper';
@@ -62,11 +71,69 @@ export class PaperView extends Paper {
constructor(options: PaperViewOptions) {
const { onViewMountChange, portalSelector, id, ...paperOptions } = options;
- super(paperOptions);
+ super({
+ ...paperOptions,
+ layerViewNamespace: { ...DEFAULT_LAYER_VIEW_NAMESPACE, ...paperOptions.layerViewNamespace },
+ });
this.id = id;
this.onViewMountChange = onViewMountChange ?? noopViewMountChange;
this.shouldPreserveHostElementOnRemove = !!paperOptions.el;
this.portalSelector = portalSelector;
+ this.listenTo(this.model, 'layer:change:visible', this.onLayerVisibilityChange);
+ }
+
+ /**
+ * Honor registered graph layer views even for core's legacy default layer.
+ * Core otherwise selects its legacy view before consulting the namespace.
+ * @param options - Layer construction options supplied by the paper.
+ * @returns The registered graph layer view, or the core fallback.
+ */
+ protected createLayerView(options: Omit): dia.LayerView {
+ const { model }: { readonly model?: unknown } = options;
+ if (model instanceof dia.GraphLayer) {
+ const { id }: { readonly id?: unknown } = options;
+ if (typeof id !== 'string') return super.createLayerView(options);
+ const namespace: Record = this.options.layerViewNamespace ?? {};
+ const type = `${model.get('type') || model.constructor.name}View`;
+ const View = namespace[type];
+ if (View) {
+ const view = new View({ ...options, id, paper: this });
+ // Keep the default group's existing CSS compatibility name while
+ // honoring its registered constructor instead of core's legacy one.
+ if (model === this.model.getDefaultLayer()) {
+ view.el.classList.add(`${config.classNamePrefix}viewport`);
+ }
+ return view;
+ }
+ }
+ return super.createLayerView(options);
+ }
+
+ /**
+ * A dedicated labels layer sits outside a link's own graph layer, so mirror
+ * its visibility on the detached label container without hiding other links.
+ * @param view - The link whose labels were mounted or updated.
+ */
+ private updateDetachedLabelVisibility(view: dia.LinkView): void {
+ if (!view.options.labelsLayer) return;
+ const labels = view.findLabelNode(0)?.parentElement;
+ if (!labels) return;
+ const layer = this.model.getLayer(this.model.getCellLayerId(view.model));
+ const visibility = layer.get('visible') === false ? 'hidden' : '';
+ if (labels.style.visibility !== visibility) labels.style.visibility = visibility;
+ }
+
+ /**
+ * Refresh only the changed layer's links; graph layers index their own cells.
+ * @param layer - The layer whose paint visibility changed.
+ */
+ private onLayerVisibilityChange(layer: dia.GraphLayer): void {
+ if (!this.options.labelsLayer) return;
+ for (const cell of layer.getCells()) {
+ if (!cell.isLink()) continue;
+ const view = this.findViewByModel(cell);
+ if (view instanceof dia.LinkView) this.updateDetachedLabelVisibility(view);
+ }
}
/**
@@ -394,6 +461,9 @@ export class PaperView extends Paper {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateView(view: mvc.View, flagIn: number, opt?: dia.Paper.UpdateViewOptions): number {
const flagOut = super.updateView(view, flagIn, opt);
+ if (view instanceof dia.LinkView && this.isViewMounted(view)) {
+ this.updateDetachedLabelVisibility(view);
+ }
if (flagIn & this.FLAG_MEASURE) {
view.el.classList.remove(MEASURING_CLASS_NAME);
return flagOut ^ (flagOut & this.FLAG_MEASURE);
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 5aefa16e49..eef8a5e772 100644
--- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts
+++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts
@@ -40,6 +40,68 @@ function addLink(graph: dia.Graph, id: string, source: string, target: string) {
/** Flush pending microtasks so scheduled callbacks execute. */
const flush = () => new Promise((resolve) => queueMicrotask(resolve));
+// Regression: a cell naming an undeclared layer made joint-core throw inside
+// the React-origin batch (and inside core's own `sync-cells` batch), so every
+// later change was reported as inside a batch. The input is rejected first.
+describe('updateGraph — a cell naming an undeclared layer', () => {
+ it('throws before any batch opens and leaves the graph usable', async () => {
+ const { graph, onChanges, controller } = setup();
+ const onBatchStart = jest.fn();
+ graph.on('batch:start', onBatchStart);
+
+ expect(() =>
+ controller.updateGraph({
+ cells: [{ id: 'orphan', type: ELEMENT_MODEL_TYPE, layer: 'missing' } as CellRecord],
+ flag: 'updateFromReact',
+ })
+ ).toThrow(/cell "orphan" names layer "missing"/);
+
+ expect(onBatchStart).not.toHaveBeenCalled();
+ expect(graph.hasActiveBatch('updateFromReact')).toBe(false);
+ expect(graph.hasActiveBatch('sync-cells')).toBe(false);
+
+ // A later graph-origin change is reported outside any batch again.
+ onChanges.mockClear();
+ addElement(graph, 'later');
+ await flush();
+ expect(onChanges).toHaveBeenCalled();
+ const lastCall = onChanges.mock.calls.at(-1)?.[0];
+ expect(lastCall).toMatchObject({ isInsideBatch: false });
+ });
+
+ // Regression: `reconcileLayers` could throw from joint-core after the batch
+ // had opened (an empty id, an unregistered `type`), leaking the batch.
+ it('throws before any batch opens on an invalid layer record', () => {
+ const { graph, controller } = setup();
+ const onBatchStart = jest.fn();
+ graph.on('batch:start', onBatchStart);
+
+ expect(() =>
+ controller.updateGraph({ layers: [{ id: '' }], flag: 'updateFromReact' })
+ ).toThrow(/empty layer id/);
+ expect(() =>
+ controller.updateGraph({ layers: [{ id: 'a', type: 'Nope' }], flag: 'updateFromReact' })
+ ).toThrow(/type "Nope"/);
+ expect(() =>
+ controller.updateGraph({ layers: [{ id: 'a' }, { id: 'a' }], flag: 'updateFromReact' })
+ ).toThrow(/declared twice/);
+
+ expect(onBatchStart).not.toHaveBeenCalled();
+ expect(graph.hasActiveBatch('updateFromReact')).toBe(false);
+ expect(graph.getLayers().map((layer) => layer.id)).toEqual(['cells']);
+ });
+
+ it('accepts a layer declared in the same commit', () => {
+ const { graph, controller } = setup();
+ controller.updateGraph({
+ layers: [{ id: 'fresh' }],
+ cells: [{ id: 'x', type: ELEMENT_MODEL_TYPE, layer: 'fresh' } as CellRecord],
+ flag: 'updateFromReact',
+ });
+ expect(graph.getCell('x').layer()).toBe('fresh');
+ });
+});
+
describe('graphChanges', () => {
describe('cell events', () => {
it('calls onChanges with "add" when element is added', async () => {
diff --git a/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts b/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts
new file mode 100644
index 0000000000..3ea6d79ce1
--- /dev/null
+++ b/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts
@@ -0,0 +1,271 @@
+import { dia } from '@joint/core';
+import { DEFAULT_CELL_NAMESPACE, GraphStore } from '../graph-store';
+import type { LayerRecord } from '../../types/layer.types';
+import type { ElementRecord } from '../../types/cell.types';
+import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model';
+
+const element = (layer: string, x = 0) => ({
+ id: 'node',
+ type: ELEMENT_MODEL_TYPE,
+ layer,
+ position: { x, y: 0 },
+ size: { width: 10, height: 10 },
+} satisfies ElementRecord);
+const DEFAULT_ATTRIBUTES: readonly LayerRecord[] = [
+ { id: 'cells', visible: false, label: 'Main diagram' },
+];
+
+it('publishes successful partial layer writes when a later valid constructor throws', () => {
+ class ThrowingLayer extends dia.GraphLayer {
+ constructor(...args: ConstructorParameters) {
+ super(...args);
+ throw new Error('later constructor failed');
+ }
+ }
+ const store = new GraphStore({ layerNamespace: { ThrowingLayer } });
+ const onLayers = jest.fn();
+ const unsubscribe = store.graphProjection.layers.subscribe(onLayers);
+ try {
+ expect(() => store.applyLayers([
+ { id: 'good', label: 'created before failure' },
+ { id: 'bad', type: 'ThrowingLayer' },
+ ])).toThrow('later constructor failed');
+
+ expect(store.graph.hasLayer('good')).toBe(true);
+ expect(store.graphProjection.layers.getLayer('good')).toMatchObject({
+ id: 'good', label: 'created before failure',
+ });
+ expect(onLayers).toHaveBeenCalledTimes(1);
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ unsubscribe();
+ store.destroy(false);
+ }
+});
+
+it.each(['initial', 'update'] as const)(
+ 'round-trips explicit default-layer attributes applied by %s through graph JSON',
+ (mode) => {
+ const store = new GraphStore(mode === 'initial' ? { initialLayers: DEFAULT_ATTRIBUTES } : {});
+ const restored = new GraphStore({});
+ try {
+ if (mode === 'update') store.applyLayers(DEFAULT_ATTRIBUTES);
+ const moveLayer = jest.spyOn(store.graph, 'moveLayer');
+ store.applyLayers(DEFAULT_ATTRIBUTES);
+ expect(moveLayer).not.toHaveBeenCalled();
+ const json = store.graph.toJSON();
+ expect(json.layers).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'cells', visible: false, label: 'Main diagram' }),
+ ]));
+
+ restored.graph.fromJSON(json);
+
+ expect(restored.graph.getDefaultLayer().get('visible')).toBe(false);
+ expect(restored.graph.getDefaultLayer().get('label')).toBe('Main diagram');
+ } finally {
+ restored.destroy(false);
+ store.destroy(false);
+ }
+ }
+);
+
+const BARE_DECLARATIONS: ReadonlyArray<{ readonly layers: readonly LayerRecord[] }> = [
+ { layers: [] },
+ { layers: [{ id: 'cells' }] },
+];
+it.each(BARE_DECLARATIONS)(
+ 'leaves a bare default declaration in legacy serialization mode (%#)',
+ ({ layers }) => {
+ const store = new GraphStore({});
+ const moveLayer = jest.spyOn(store.graph, 'moveLayer');
+ const before = store.graph.toJSON();
+ try {
+ store.applyLayers(layers);
+
+ expect(store.graph.toJSON()).toEqual(before);
+ expect(moveLayer).not.toHaveBeenCalled();
+ } finally {
+ store.destroy(false);
+ }
+ }
+);
+
+it('does no full layer read or reorder during a cell-only update with a retained nonempty layer', () => {
+ const store = new GraphStore({ initialLayers: [{ id: 'notes' }], initialCells: [element('notes')] });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ const getLayers = jest.spyOn(store.graph, 'getLayers');
+ const moveLayer = jest.spyOn(store.graph, 'moveLayer');
+
+ store.applyControlled([element('notes', 50)]);
+
+ expect(store.graph.hasLayer('notes')).toBe(true);
+ expect(store.graph.getCell('node').get('position')).toEqual({ x: 50, y: 0 });
+ expect(getLayers).not.toHaveBeenCalled();
+ expect(moveLayer).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ store.destroy(false);
+ }
+});
+
+it('does not prune an external graph from a queued callback after its store is destroyed', async () => {
+ const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE });
+ const store = new GraphStore({
+ graph,
+ initialLayers: [{ id: 'notes' }],
+ initialCells: [element('notes')],
+ });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ graph.getCell('node').layer('cells');
+ store.destroy(true);
+ await new Promise((resolve) => queueMicrotask(resolve));
+
+ expect(graph.hasLayer('notes')).toBe(true);
+ expect(graph.getCell('node').layer()).toBe('cells');
+ } finally {
+ warn.mockRestore();
+ graph.clear();
+ }
+});
+
+it('retains a pending id re-added with cells, then prunes its replacement once empty', async () => {
+ const store = new GraphStore({ initialLayers: [{ id: 'notes' }], initialCells: [element('notes')] });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ store.graph.getCell('node').layer('cells');
+ store.graph.removeLayer('notes');
+ store.graph.addLayer({ id: 'notes' });
+ store.graph.addCell({ ...element('notes'), id: 'replacement' });
+ await new Promise((resolve) => queueMicrotask(resolve));
+ expect(store.graph.hasLayer('notes')).toBe(true);
+
+ store.graph.getCell('replacement').layer('cells');
+ await new Promise((resolve) => queueMicrotask(resolve));
+
+ expect(store.graph.hasLayer('notes')).toBe(false);
+ expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined();
+ } finally {
+ warn.mockRestore();
+ store.destroy(false);
+ }
+});
+
+it('never prunes a pending layer that became the graph default before the retry', async () => {
+ const store = new GraphStore({ initialLayers: [{ id: 'notes' }], initialCells: [element('notes')] });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ store.graph.setDefaultLayer('notes');
+ store.graph.removeCells([store.graph.getCell('node')]);
+ await new Promise((resolve) => queueMicrotask(resolve));
+
+ expect(store.graph.getDefaultLayer().id).toBe('notes');
+ expect(store.graph.hasLayer('notes')).toBe(true);
+ } finally {
+ warn.mockRestore();
+ store.destroy(false);
+ }
+});
+
+it('waits for an open native batch, then prunes in one tagged bridge batch', async () => {
+ const store = new GraphStore({ initialLayers: [{ id: 'notes' }], initialCells: [element('notes')] });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ const startBatch = jest.spyOn(store.graph, 'startBatch');
+ const stopBatch = jest.spyOn(store.graph, 'stopBatch');
+ const removeLayer = jest.spyOn(store.graph, 'removeLayer');
+ store.graph.startBatch('native edit');
+ store.graph.getCell('node').layer('cells');
+ await new Promise((resolve) => queueMicrotask(resolve));
+ expect(store.graph.hasLayer('notes')).toBe(true);
+ expect(removeLayer).not.toHaveBeenCalled();
+
+ store.graph.stopBatch('native edit');
+ await new Promise((resolve) => queueMicrotask(resolve));
+
+ expect(store.graph.hasLayer('notes')).toBe(false);
+ expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined();
+ expect(startBatch).toHaveBeenCalledWith('updateFromReact', { isUpdateFromReact: true });
+ expect(stopBatch).toHaveBeenCalledWith('updateFromReact', { isUpdateFromReact: true });
+ expect(removeLayer).toHaveBeenCalledWith('notes', { isUpdateFromReact: true });
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ warn.mockRestore();
+ store.destroy(false);
+ }
+});
+
+describe.each(['pending', 'immediate'] as const)('%s layer pruning', (phase) => {
+ it.each(['change default', 'replace declaration', 'destroy owner'] as const)(
+ 'rechecks removals when a removal listener synchronously performs %s',
+ (reaction) => {
+ const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE });
+ const initialCells = [
+ { ...element('A'), id: 'first' },
+ { ...element('B'), id: 'second' },
+ ];
+ const store = new GraphStore({
+ graph,
+ initialLayers: [{ id: 'A' }, { id: 'B' }],
+ initialCells: phase === 'pending' ? initialCells : [],
+ });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ let destroyed = false;
+ const onRemove = (layer: dia.GraphLayer) => {
+ if (layer.id !== 'A') return;
+ if (reaction === 'change default') graph.setDefaultLayer('B');
+ else if (reaction === 'replace declaration') store.applyLayers([{ id: 'B' }]);
+ else {
+ store.destroy(true);
+ destroyed = true;
+ }
+ };
+ try {
+ if (phase === 'pending') store.applyLayers([]);
+ graph.on('layer:remove', onRemove);
+
+ expect(() => {
+ if (phase === 'pending') {
+ store.applyControlled(initialCells.map((cell) => ({ ...cell, layer: 'cells' })));
+ } else {
+ store.applyLayers([]);
+ }
+ }).not.toThrow();
+
+ expect(graph.hasLayer('A')).toBe(false);
+ expect(graph.hasLayer('B')).toBe(true);
+ expect(graph.hasActiveBatch()).toBe(false);
+ if (reaction === 'change default') expect(graph.getDefaultLayer().id).toBe('B');
+ if (!destroyed) expect(store.graphProjection.layers.getLayer('B')).toBeDefined();
+ } finally {
+ graph.off('layer:remove', onRemove);
+ if (!destroyed) store.destroy(true);
+ graph.clear();
+ warn.mockRestore();
+ }
+ }
+ );
+});
+
+it('preserves an existing pending removal when a new declaration fails prevalidation', () => {
+ const store = new GraphStore({ initialLayers: [{ id: 'notes' }], initialCells: [element('notes')] });
+ const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
+ try {
+ store.applyLayers([]);
+ expect(() => store.applyLayers([{ id: '' }])).toThrow();
+
+ store.applyControlled([element('cells')]);
+
+ expect(store.graph.hasLayer('notes')).toBe(false);
+ expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined();
+ } finally {
+ warn.mockRestore();
+ store.destroy(false);
+ }
+});
diff --git a/packages/joint-react/src/store/__tests__/layers-regressions.test.ts b/packages/joint-react/src/store/__tests__/layers-regressions.test.ts
new file mode 100644
index 0000000000..dc237983c5
--- /dev/null
+++ b/packages/joint-react/src/store/__tests__/layers-regressions.test.ts
@@ -0,0 +1,189 @@
+import { dia } from '@joint/core';
+import { GraphStore } from '../graph-store';
+import { ELEMENT_MODEL_TYPE } from '../../mvc/element-model';
+import type { ElementJSONInit, ElementRecord, LinkJSONInit } from '../../types/cell.types';
+
+const element = (id: string, layer = 'cells') => ({
+ id,
+ type: ELEMENT_MODEL_TYPE,
+ layer,
+ position: { x: 10, y: 20 },
+ size: { width: 100, height: 50 },
+} satisfies ElementRecord);
+const graphIds = (store: Pick) =>
+ store.graph.getCells().map((cell) => cell.id).toSorted((a, b) => String(a).localeCompare(String(b)));
+const projectedIds = (
+ store: GraphStore
+) =>
+ store.graphProjection.cells
+ .getSnapshot()
+ .map((cell) => cell.id)
+ .toSorted((a, b) => String(a).localeCompare(String(b)));
+
+it('projects initial cells from named layers as well as the default layer', () => {
+ const store = new GraphStore({
+ initialLayers: [{ id: 'notes' }],
+ initialCells: [element('plain'), element('note', 'notes')],
+ });
+ try {
+ expect(projectedIds(store)).toEqual(['note', 'plain']);
+ expect(projectedIds(store)).toEqual(graphIds(store));
+ expect(store.graphProjection.cells.get('note')).toMatchObject({ layer: 'notes' });
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('reconciles the projection against every layer after resetCells', () => {
+ const store = new GraphStore({
+ initialLayers: [{ id: 'notes' }],
+ initialCells: [element('old')],
+ });
+ try {
+ store.graph.resetCells([element('new-plain'), element('new-note', 'notes')]);
+
+ expect(projectedIds(store)).toEqual(['new-note', 'new-plain']);
+ expect(projectedIds(store)).toEqual(graphIds(store));
+ expect(store.graphProjection.cells.has('old')).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('projects all replacement layers and cells after fromJSON', () => {
+ const store = new GraphStore({ initialCells: [element('old')] });
+ try {
+ store.graph.fromJSON({
+ layers: [{ id: 'cells' }, { id: 'notes' }],
+ defaultLayer: 'cells',
+ cells: [element('new-plain'), element('new-note', 'notes')],
+ });
+
+ expect(projectedIds(store)).toEqual(['new-note', 'new-plain']);
+ expect(projectedIds(store)).toEqual(graphIds(store));
+ expect(store.graphProjection.layers.getLayer('notes')).toBeDefined();
+ expect(store.graphProjection.cells.has('old')).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('rejects registered constructors that are not GraphLayer subclasses before any write or batch', () => {
+ class NotALayer {
+ readonly label = 'not a layer';
+ }
+ const store = new GraphStore({ layerNamespace: { NotALayer } });
+ const startBatch = jest.spyOn(store.graph, 'startBatch');
+ try {
+ expect(() =>
+ store.applyLayers([{ id: 'good' }, { id: 'bad', type: 'NotALayer' }])
+ ).toThrow();
+
+ expect(startBatch).not.toHaveBeenCalled();
+ expect(store.graph.hasLayer('good')).toBe(false);
+ expect(store.graph.hasLayer('bad')).toBe(false);
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('closes the React-origin batch when a registered GraphLayer constructor throws', async () => {
+ class ThrowingLayer extends dia.GraphLayer {
+ constructor(...args: ConstructorParameters) {
+ super(...args);
+ throw new Error('layer construction failed');
+ }
+ }
+ const store = new GraphStore({ layerNamespace: { ThrowingLayer } });
+ const stopBatch = jest.spyOn(store.graph, 'stopBatch');
+ try {
+ expect(() => store.applyLayers([{ id: 'bad', type: 'ThrowingLayer' }])).toThrow(
+ 'layer construction failed'
+ );
+
+ expect(store.graph.hasActiveBatch('updateFromReact')).toBe(false);
+ expect(stopBatch).toHaveBeenCalledWith(
+ 'updateFromReact',
+ expect.objectContaining({ isUpdateFromReact: true })
+ );
+
+ store.applyLayers([{ id: 'after' }]);
+ store.graph.addCell(element('recovered', 'after'));
+ await new Promise((resolve) => queueMicrotask(resolve));
+ expect(store.graphProjection.layers.getLayer('after')).toBeDefined();
+ expect(store.graphProjection.cells.has('recovered')).toBe(true);
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('derives isDefault from the graph even when an imperative layer stores that attribute', () => {
+ const store = new GraphStore({});
+ try {
+ store.graph.addLayer({ id: 'notes', isDefault: true });
+
+ expect(store.graph.getDefaultLayer().id).toBe('cells');
+ expect(store.graphProjection.layers.getLayer('cells')?.isDefault).toBe(true);
+ expect(store.graphProjection.layers.getLayer('notes')?.isDefault).toBeUndefined();
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it('accepts registered ES subclasses and aliases of the base GraphLayer constructor', () => {
+ class ESLayer extends dia.GraphLayer {}
+ const BaseLayer = dia.GraphLayer;
+ const store = new GraphStore({ layerNamespace: { custom: { ESLayer, BaseLayer } } });
+ try {
+ store.applyLayers([
+ { id: 'es', type: 'custom.ESLayer' },
+ { id: 'base', type: 'custom.BaseLayer' },
+ ]);
+
+ expect(store.graph.getLayer('es')).toBeInstanceOf(ESLayer);
+ expect(store.graph.getLayer('base')).toBeInstanceOf(dia.GraphLayer);
+ expect(store.graphProjection.layers.getLayer('es')?.type).toBe('custom.ESLayer');
+ expect(store.graphProjection.layers.getLayer('base')?.type).toBe('custom.BaseLayer');
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
+
+it.each([42, {}, null, false])(
+ 'rejects a present non-string layer type %p before any write or batch',
+ (type) => {
+ const store = new GraphStore({});
+ const startBatch = jest.spyOn(store.graph, 'startBatch');
+ const before = store.graph.toJSON();
+ try {
+ expect(() => store.applyLayers([{ id: 'good' }, { id: 'bad', type }])).toThrow();
+
+ expect(startBatch).not.toHaveBeenCalled();
+ expect(store.graph.toJSON()).toEqual(before);
+ expect(store.graph.hasLayer('good')).toBe(false);
+ expect(store.graph.hasLayer('bad')).toBe(false);
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+ }
+);
+
+it('accepts omitted and explicitly undefined layer types as the default constructor', () => {
+ const store = new GraphStore({});
+ try {
+ store.applyLayers([{ id: 'omitted' }, { id: 'undefined', type: undefined }]);
+
+ const defaultType: unknown = dia.GraphLayer.prototype.defaults().type;
+ expect(store.graph.getLayer('omitted').get('type')).toBe(defaultType);
+ expect(store.graph.getLayer('undefined').get('type')).toBe(defaultType);
+ expect(store.graphProjection.layers.getLayer('omitted')).toEqual({ id: 'omitted' });
+ expect(store.graphProjection.layers.getLayer('undefined')).toEqual({ id: 'undefined' });
+ expect(store.graph.hasActiveBatch()).toBe(false);
+ } finally {
+ store.destroy(false);
+ }
+});
diff --git a/packages/joint-react/src/store/__tests__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts
new file mode 100644
index 0000000000..1ca6893018
--- /dev/null
+++ b/packages/joint-react/src/store/__tests__/layers.test.ts
@@ -0,0 +1,295 @@
+import { dia } from '@joint/core';
+import { DEFAULT_CELL_NAMESPACE } from '../graph-store';
+import { assertLayerRecords, readLayerRecords, reconcileLayers, removeEmptyLayers } from '../layers';
+import type { LayerRecord } from '../../types/layer.types';
+
+const EMPTY_LAYERS: readonly LayerRecord[] = [];
+
+function createGraph() {
+ return new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE });
+}
+
+const layerIds = (graph: dia.Graph) => graph.getLayers().map((layer) => layer.id);
+
+/** A graph with one named custom layer, the fixture most projection tests start from. */
+function createGraphWithLayer1() {
+ const graph = createGraph();
+ graph.addLayer({ id: 'layer1', name: 'Layer 1' });
+ return graph;
+}
+
+describe('readLayerRecords', () => {
+ it('projects the default layer of a fresh graph', () => {
+ expect(readLayerRecords(createGraph(), EMPTY_LAYERS)).toEqual([
+ { id: 'cells', isDefault: true },
+ ]);
+ });
+
+ it('projects layers in paint order with their custom attributes', () => {
+ const graph = createGraphWithLayer1();
+ graph.addLayer({ id: 'layer2', description: 'This is layer 2' });
+ expect(readLayerRecords(graph, EMPTY_LAYERS)).toEqual([
+ { id: 'cells', isDefault: true },
+ { id: 'layer1', name: 'Layer 1' },
+ { id: 'layer2', description: 'This is layer 2' },
+ ]);
+ });
+
+ it('keeps the previous array and record references when nothing changed', () => {
+ const graph = createGraphWithLayer1();
+ const first = readLayerRecords(graph, EMPTY_LAYERS);
+ const second = readLayerRecords(graph, first);
+ expect(second).toBe(first);
+ });
+
+ it('keeps untouched record references when one layer changes', () => {
+ const graph = createGraphWithLayer1();
+ const first = readLayerRecords(graph, EMPTY_LAYERS);
+ graph.getLayer('layer1').set('name', 'Renamed');
+ const second = readLayerRecords(graph, first);
+ expect(second).not.toBe(first);
+ expect(second[0]).toBe(first[0]);
+ expect(second[1]).toEqual({ id: 'layer1', name: 'Renamed' });
+ });
+});
+
+describe('reconcileLayers', () => {
+ it('adds declared layers in array order, keeping the default layer at the bottom when omitted', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }, { id: 'layer2' }]);
+ expect(layerIds(graph)).toEqual(['cells', 'layer1', 'layer2']);
+ });
+
+ it('positions the default layer where the array names it', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'background' }, { id: 'cells' }, { id: 'foreground' }]);
+ expect(layerIds(graph)).toEqual(['background', 'cells', 'foreground']);
+ });
+
+ it('reorders existing layers with moveLayer, never remove + add', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }, { id: 'layer2' }]);
+ const layer1 = graph.getLayer('layer1');
+ const removeLayer = jest.spyOn(graph, 'removeLayer');
+ const addLayer = jest.spyOn(graph, 'addLayer');
+
+ reconcileLayers(graph, [{ id: 'layer2' }, { id: 'cells' }, { id: 'layer1' }]);
+
+ expect(layerIds(graph)).toEqual(['layer2', 'cells', 'layer1']);
+ expect(graph.getLayer('layer1')).toBe(layer1);
+ expect(removeLayer).not.toHaveBeenCalled();
+ expect(addLayer).not.toHaveBeenCalled();
+ });
+
+ it('is a no-op for an equal-content array', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1', name: 'A' }]);
+ const moveLayer = jest.spyOn(graph, 'moveLayer');
+ const addLayer = jest.spyOn(graph, 'addLayer');
+ const onLayerEvent = jest.fn();
+ graph.on('layer:change layers:sort layer:add layer:remove', onLayerEvent);
+
+ reconcileLayers(graph, [{ id: 'layer1', name: 'A' }]);
+
+ expect(moveLayer).not.toHaveBeenCalled();
+ expect(addLayer).not.toHaveBeenCalled();
+ // `mvc.Model.set` diffs internally: equal values raise no change event.
+ expect(onLayerEvent).not.toHaveBeenCalled();
+ });
+
+ it('updates changed attributes and unsets dropped ones', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1', name: 'Layer 1', visible: true }]);
+ reconcileLayers(graph, [{ id: 'layer1', name: 'Renamed' }]);
+ const layer = graph.getLayer('layer1');
+ expect(layer.get('name')).toBe('Renamed');
+ expect(layer.has('visible')).toBe(false);
+ expect(graph.toJSON().layers).toEqual([
+ { type: 'GraphLayer', id: 'cells' },
+ { type: 'GraphLayer', id: 'layer1', name: 'Renamed' },
+ ]);
+ });
+
+ it('forwards the caller options into every layer event', () => {
+ const graph = createGraph();
+ const seen: unknown[] = [];
+ graph.on('layer:add layers:sort layer:change', (...args: unknown[]) => seen.push(args.at(-1)));
+ reconcileLayers(graph, [{ id: 'layer1' }], { isUpdateFromReact: true });
+ reconcileLayers(graph, [{ id: 'layer1', name: 'x' }], { isUpdateFromReact: true });
+ reconcileLayers(graph, [{ id: 'layer1' }, { id: 'cells' }], { isUpdateFromReact: true });
+ expect(seen.length).toBeGreaterThanOrEqual(3);
+ for (const options of seen) expect(options).toMatchObject({ isUpdateFromReact: true });
+ });
+
+ it('never unsets an attribute a custom layer class provides as a default', () => {
+ class TintLayer extends dia.GraphLayer {
+ defaults() {
+ return { ...super.defaults(), type: 'TintLayer', opacity: 1 };
+ }
+ }
+ const graph = new dia.Graph(
+ {},
+ { cellNamespace: DEFAULT_CELL_NAMESPACE, layerNamespace: { TintLayer } }
+ );
+ reconcileLayers(graph, [{ id: 'tint', type: 'TintLayer', tint: 'red' }]);
+ // A record that omits `opacity` must not strip the class default.
+ reconcileLayers(graph, [{ id: 'tint', type: 'TintLayer' }]);
+ const layer = graph.getLayer('tint');
+ expect(layer.get('opacity')).toBe(1);
+ expect(layer.has('tint')).toBe(false);
+ expect(readLayerRecords(graph, EMPTY_LAYERS)[1]).toEqual({
+ id: 'tint',
+ type: 'TintLayer',
+ opacity: 1,
+ });
+ });
+
+ // Regression: a dropped override of an attribute the layer class defaults was
+ // left in place, so the graph no longer matched the declared record.
+ it('restores the class default when a record drops an overridden attribute', () => {
+ class TintLayer extends dia.GraphLayer {
+ defaults() {
+ return { ...super.defaults(), type: 'TintLayer', opacity: 1 };
+ }
+ }
+ const graph = new dia.Graph(
+ {},
+ { cellNamespace: DEFAULT_CELL_NAMESPACE, layerNamespace: { TintLayer } }
+ );
+ reconcileLayers(graph, [{ id: 'tint', type: 'TintLayer', opacity: 0.5 }]);
+ expect(graph.getLayer('tint').get('opacity')).toBe(0.5);
+
+ reconcileLayers(graph, [{ id: 'tint', type: 'TintLayer' }]);
+
+ expect(graph.getLayer('tint').get('opacity')).toBe(1);
+ });
+
+ // Regression: an empty declaration returned early, so attributes given to the
+ // default layer earlier (e.g. `visible: false`) and its position were kept.
+ it('reconciles the default layer on an empty declaration', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }, { id: 'cells', visible: false }]);
+ expect(layerIds(graph)).toEqual(['layer1', 'cells']);
+
+ reconcileLayers(graph, EMPTY_LAYERS);
+
+ expect(graph.getDefaultLayer().has('visible')).toBe(false);
+ expect(layerIds(graph)).toEqual(['cells', 'layer1']);
+ });
+
+ // Regression: the add path copied a record's `isDefault` into the new layer,
+ // so it was stored, exported, and projected back as if the layer were default.
+ it('never stores isDefault on a newly added layer', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1', isDefault: true }]);
+ expect(graph.getLayer('layer1').has('isDefault')).toBe(false);
+ expect(readLayerRecords(graph, EMPTY_LAYERS)[1]).toEqual({ id: 'layer1' });
+ });
+
+ it('never exits legacy mode until a layer is actually declared', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, EMPTY_LAYERS);
+ expect(graph.toJSON().layers).toBeUndefined();
+ reconcileLayers(graph, [{ id: 'layer1' }]);
+ expect(graph.toJSON().layers).toBeDefined();
+ });
+});
+
+describe('removeEmptyLayers', () => {
+ it('removes layers absent from the array once they are empty', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }, { id: 'layer2' }]);
+ removeEmptyLayers(graph, [{ id: 'layer2' }]);
+ expect(layerIds(graph)).toEqual(['cells', 'layer2']);
+ });
+
+ it('never removes the default layer even when the array omits it', () => {
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }]);
+ removeEmptyLayers(graph, EMPTY_LAYERS);
+ expect(layerIds(graph)).toEqual(['cells']);
+ });
+
+ it('keeps a layer that still has cells and warns with their ids', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }]);
+ graph.addCells([
+ { id: 'rect1', type: 'element', layer: 'layer1' },
+ { id: 'rect2', type: 'element', layer: 'layer1' },
+ ]);
+
+ removeEmptyLayers(graph, EMPTY_LAYERS);
+
+ expect(layerIds(graph)).toEqual(['cells', 'layer1']);
+ expect(graph.getCell('rect1')).toBeDefined();
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0][0]).toContain('layer1');
+ expect(warn.mock.calls[0][0]).toContain('rect1');
+ expect(warn.mock.calls[0][0]).toContain('rect2');
+ warn.mockRestore();
+ });
+
+ it('removes the layer on a later pass once its cells are gone', () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const graph = createGraph();
+ reconcileLayers(graph, [{ id: 'layer1' }]);
+ graph.addCell({ id: 'rect1', type: 'element', layer: 'layer1' });
+ removeEmptyLayers(graph, EMPTY_LAYERS);
+ graph.removeCells(graph.getLayer('layer1').getCells());
+
+ removeEmptyLayers(graph, EMPTY_LAYERS);
+
+ expect(layerIds(graph)).toEqual(['cells']);
+ warn.mockRestore();
+ });
+});
+
+// Regression: duplicate ids, an empty id and an unregistered `type` went into
+// the graph unchecked — one silently collapsed to a single layer, the others
+// threw from joint-core halfway through the apply.
+describe('assertLayerRecords', () => {
+ it('rejects duplicate ids', () => {
+ expect(() => assertLayerRecords(createGraph(), [{ id: 'a' }, { id: 'a' }])).toThrow(
+ /layer id "a" is declared twice/
+ );
+ });
+
+ it('rejects an empty id', () => {
+ expect(() => assertLayerRecords(createGraph(), [{ id: '' }])).toThrow(/empty layer id/);
+ });
+
+ it('rejects a type missing from the graph layerNamespace', () => {
+ expect(() => assertLayerRecords(createGraph(), [{ id: 'a', type: 'Nope' }])).toThrow(
+ /layer "a" has type "Nope"/
+ );
+ });
+
+ it('rejects a type that only resolves through the prototype chain', () => {
+ expect(() => assertLayerRecords(createGraph(), [{ id: 'a', type: 'constructor' }])).toThrow(
+ /layer "a" has type "constructor"/
+ );
+ });
+
+ it('rejects a new type for a layer that already exists', () => {
+ class TintLayer extends dia.GraphLayer {}
+ const graph = new dia.Graph(
+ {},
+ { cellNamespace: DEFAULT_CELL_NAMESPACE, layerNamespace: { TintLayer } }
+ );
+ expect(() => assertLayerRecords(graph, [{ id: 'cells', type: 'TintLayer' }])).toThrow(
+ /layer "cells" already exists with another type/
+ );
+ });
+
+ it('accepts unique ids and registered types', () => {
+ class TintLayer extends dia.GraphLayer {}
+ const graph = new dia.Graph(
+ {},
+ { cellNamespace: DEFAULT_CELL_NAMESPACE, layerNamespace: { TintLayer } }
+ );
+ expect(() =>
+ assertLayerRecords(graph, [{ id: 'a' }, { id: 'b', type: 'TintLayer' }, { id: 'cells' }])
+ ).not.toThrow();
+ });
+});
diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts
index 3d87a4d6e1..a3c8a21dca 100644
--- a/packages/joint-react/src/store/graph-changes.ts
+++ b/packages/joint-react/src/store/graph-changes.ts
@@ -3,6 +3,9 @@ import type { IncrementalChange } from '../state/incremental.types';
import { simpleScheduler } from '../utils/scheduler';
import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types';
import { mapCellToAttributes } from '../state/data-mapping';
+import type { LayerRecord } from '../types/layer.types';
+import { assertLayerRecords, reconcileLayers, removeEmptyLayers, retryEmptyLayerRemovals } from './layers';
+import { isRecord } from '../utils/is';
/** Custom graph event signalling a layout-only update (position/size/angle change). */
export const LAYOUT_UPDATE_EVENT = 'layout:update';
@@ -33,6 +36,12 @@ export interface UpdateGraphOptions<
> {
/** Cell records to sync. If omitted, the current graph cells are preserved untouched. */
readonly cells?: ReadonlyArray;
+ /**
+ * Layers to reconcile, in paint order. Applied before the cells (so a cell
+ * may name a layer declared in the same commit) and pruned after them (so a
+ * layer emptied in the same commit is removed in it). Omitted → untouched.
+ */
+ readonly layers?: readonly LayerRecord[];
readonly flag?: 'updateFromReact';
/** Extra options forwarded verbatim into the `graph.syncCells` event opt. */
readonly metadata?: Record;
@@ -63,6 +72,8 @@ interface OnChangeOptions {
interface Options {
readonly graph: dia.Graph;
readonly onChanges: (options: OnChangeOptions) => void;
+ /** Fired synchronously after any graph-origin layer add / remove / change / reorder. */
+ readonly onLayersChange?: () => void;
}
interface JointJSEventOptions {
@@ -70,6 +81,39 @@ interface JointJSEventOptions {
readonly [key: string]: unknown;
}
+/**
+ * True when a graph event's trailing `opt` argument carries the React-origin
+ * tag. Layer events pass `opt` at differing positions, so callers hand in
+ * whatever came last.
+ * @param eventOptions - The last argument of a graph event.
+ */
+function isReactOrigin(eventOptions: unknown): boolean {
+ return isRecord(eventOptions) && eventOptions.isUpdateFromReact === true;
+}
+
+/**
+ * Throws when a cell names a layer that neither the graph nor the declared
+ * `layers` provide. O(n) property reads; the layer lookup is a Map hit.
+ * @param graph - The target graph.
+ * @param cells - The records about to be synced.
+ * @param layers - Layers declared in the same commit, if any.
+ */
+function assertLayersExist(
+ graph: dia.Graph,
+ cells: ReadonlyArray,
+ layers: readonly LayerRecord[] | undefined
+): void {
+ for (const cell of cells) {
+ const { layer } = cell;
+ if (layer == null || graph.hasLayer(layer)) continue;
+ if (layers?.some((record) => record.id === layer)) continue;
+ throw new Error(
+ `GraphProvider: cell "${String(cell.id)}" names layer "${layer}", which does not exist. ` +
+ 'Declare it in `layers` / `initialLayers`, or add it to the graph first.'
+ );
+ }
+}
+
/**
* Sets up listeners for JointJS graph mutations and translates them into incremental change events.
* Batching is always on: layout changes are immediate, data changes fire on batch:stop.
@@ -77,18 +121,46 @@ interface JointJSEventOptions {
* @returns Controller exposing updateGraph and destroy.
*/
export function graphChanges(options: Options) {
- const { graph } = options;
+ const { graph, onLayersChange } = options;
const changes = new Map>();
let batchDepth = 0;
let deferDepth = 0;
- let isSyncedWithReact = true;
+ let pendingLayerRemovals = new Set();
+ let isDestroyed = false;
+ const pendingRemovalOptions = { isUpdateFromReact: true };
/** True while a {@link DEFER_COMMIT_BATCH_OPTION} batch is open — commits are deferred. */
function isDeferring() {
return deferDepth > 0;
}
+ // Run after the native edit and projection commit: during a layer move,
+ // the source collection can be empty before the destination is populated.
+ // One identity dedupes a burst; only previously retained ids are inspected.
+ const retryPendingLayers = () => {
+ if (isDestroyed || isInsideBatch() || pendingLayerRemovals.size === 0) return;
+ const pending = pendingLayerRemovals;
+ const isCurrent = () => !isDestroyed && pendingLayerRemovals === pending;
+ let batchStarted = false;
+ let removed = false;
+ try {
+ removed = retryEmptyLayerRemovals(
+ graph,
+ pending,
+ pendingRemovalOptions,
+ () => {
+ graph.startBatch('updateFromReact', pendingRemovalOptions);
+ batchStarted = true;
+ },
+ isCurrent
+ );
+ } finally {
+ if (batchStarted) graph.stopBatch('updateFromReact', pendingRemovalOptions);
+ }
+ if (removed && !isDestroyed) onLayersChange?.();
+ };
+
/**
* Schedules onChanges to fire on next tick.
* @param data - Change options to pass to the handler.
@@ -121,9 +193,52 @@ export function graphChanges(options: Options) {
changes.set(cell.id, { type, data: cell });
if (sync) {
options.onChanges({ changes, isInsideBatch: false, deferCommit: isDeferring() });
- return;
+ } else {
+ onChanges({ changes, isInsideBatch: isInsideBatch(), deferCommit: isDeferring() });
}
- onChanges({ changes, isInsideBatch: isInsideBatch(), deferCommit: isDeferring() });
+ if (pendingLayerRemovals.size > 0) simpleScheduler(retryPendingLayers);
+ }
+
+ /**
+ * Syncs a React cells snapshot into the graph. Returns the synced ids for the
+ * caller's container diff; empty when there was nothing to sync.
+ * @param cells - The records to sync, or `undefined` to leave cells untouched.
+ * @param syncOptions - Forwarded as the `graph.syncCells` opt.
+ */
+ function syncCellsFromReact(
+ cells: ReadonlyArray | undefined,
+ syncOptions: Record
+ ): readonly CellId[] {
+ if (!cells) return [];
+
+ const cellIds: CellId[] = [];
+ const cellsToSync: dia.Cell.JSONInit[] = [];
+ for (const cell of cells) {
+ // Cells without an id are valid input — JointJS will assign one — but
+ // they cannot be tracked in `cellIds` (which is used for diffing).
+ if (cell.id !== undefined) cellIds.push(cell.id);
+ cellsToSync.push(mapCellToAttributes(cell, graph));
+ }
+ graph.syncCells(cellsToSync, { ...syncOptions, remove: true });
+ return cellIds;
+ }
+
+ /** Prunes only the declaration that still owns this completed cells sync. */
+ function pruneLayersAfterSync(
+ layers: readonly LayerRecord[] | undefined,
+ hasCells: boolean,
+ pending: Set,
+ isCurrent: (() => boolean) | undefined,
+ syncOptions: Record
+ ): boolean {
+ if (!isCurrent?.()) return false;
+ if (layers) {
+ const retained = removeEmptyLayers(graph, layers, syncOptions, isCurrent);
+ if (isCurrent()) pendingLayerRemovals = retained;
+ return false;
+ }
+ if (!hasCells || pending.size === 0) return false;
+ return retryEmptyLayerRemovals(graph, pending, syncOptions, undefined, isCurrent);
}
const controller = new mvc.Listener();
@@ -167,13 +282,20 @@ export function graphChanges(options: Options) {
controller.listenTo(
graph,
'reset',
- (collection: mvc.Collection, eventOptions: JointJSEventOptions = {}) => {
+ (_collection: mvc.Collection, eventOptions: JointJSEventOptions = {}) => {
if (eventOptions.isUpdateFromReact) return;
- isSyncedWithReact = true;
changes.clear();
- for (const cell of collection.models) {
+ // The reset event carries only the default layer's collection for
+ // backwards compatibility; the snapshot must include every layer.
+ for (const cell of graph.getCells()) {
changes.set(cell.id, { type: 'add', data: cell });
}
+ // `fromJSON` resets layers too, and joint-core does not forward
+ // `layers:reset` to the graph — the cell `reset` is the only signal.
+ // Layers first: publishing the cells notifies a controlled provider,
+ // which re-applies its arrays at once; it must already know the layers
+ // changed, or it re-applies cells onto layers the reset took away.
+ onLayersChange?.();
// Bypass the simpleScheduler wrapper used for normal cell events.
// `reset` is a one-shot bulk operation and callers (e.g. GraphStore
// constructor) expect the cells container to be observable
@@ -184,6 +306,7 @@ export function graphChanges(options: Options) {
deferCommit: isDeferring(),
isReset: true,
});
+ if (pendingLayerRemovals.size > 0) simpleScheduler(retryPendingLayers);
}
);
@@ -191,6 +314,24 @@ export function graphChanges(options: Options) {
onChanges({ changes: layoutChanges, isInsideBatch: true, deferCommit: isDeferring() });
});
+ if (onLayersChange) {
+ // Synchronous, not coalesced: a graph-origin layer change followed by a
+ // synchronous React commit (flushSync) would otherwise be re-read under the
+ // React-origin guard and lost. The read is O(L) and returns the same
+ // snapshot when nothing changed, and React-origin bursts are already
+ // filtered here, so a graph-origin burst costs a few tiny reads.
+ const scheduleLayersChange = (...args: unknown[]) => {
+ const eventOptions = args.at(-1);
+ if (isReactOrigin(eventOptions)) return;
+ onLayersChange();
+ };
+ controller.listenTo(
+ graph,
+ 'layer:add layer:remove layer:change layers:sort layer:default',
+ scheduleLayersChange
+ );
+ }
+
// 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.
@@ -206,10 +347,13 @@ export function graphChanges(options: Options) {
if (batchDepth > 0) return;
if (batchOptions.isUpdateFromReact) return;
onChanges({ changes, isInsideBatch: false, deferCommit: isDeferring() });
+ if (pendingLayerRemovals.size > 0) simpleScheduler(retryPendingLayers);
});
return {
destroy() {
+ isDestroyed = true;
+ pendingLayerRemovals.clear();
controller.stopListening();
},
@@ -217,35 +361,47 @@ export function graphChanges(options: Options) {
Element extends ElementJSONInit = ElementJSONInit,
Link extends LinkJSONInit = LinkJSONInit,
>(update: UpdateGraphOptions): UpdateGraphResult {
- const { cells, flag, metadata } = update;
- if (!isSyncedWithReact) {
- isSyncedWithReact = true;
- return { cellIds: [] };
- }
- if (!cells) {
- return { cellIds: [] };
- }
-
- const cellIds: CellId[] = [];
- const cellsToSync: dia.Cell.JSONInit[] = [];
+ const { cells, layers, flag, metadata } = update;
+ // Spread metadata first so the required sync flags always win.
+ const syncOptions = { ...metadata, isUpdateFromReact: flag === 'updateFromReact' };
- for (const cell of cells) {
- // Cells without an id are valid input — JointJS will assign one — but
- // they cannot be tracked in `cellIds` (which is used for diffing).
- if (cell.id !== undefined) cellIds.push(cell.id);
- cellsToSync.push(mapCellToAttributes(cell, graph));
- }
+ // Tagged batch: every event inside it carries the React-origin flag, so
+ // `batch:stop` schedules no redundant (empty) change pass for it.
+ // Reject before any batch opens: joint-core throws on an invalid layer
+ // record and on a cell naming a layer that does not exist, and a throw
+ // inside the batch would leave the graph reported as "inside a batch" for
+ // every later change.
+ if (layers) assertLayerRecords(graph, layers);
+ if (cells) assertLayersExist(graph, cells, layers);
- graph.startBatch('updateFromReact');
- // Spread metadata first so the required sync flags always win.
- graph.syncCells(cellsToSync, {
- ...metadata,
- remove: true,
- isUpdateFromReact: flag === 'updateFromReact',
- });
- graph.stopBatch('updateFromReact');
+ // A valid declaration owns pruning before any public mutation event.
+ // Nested declarations replace this identity; invalid input never does.
+ if (layers) pendingLayerRemovals = new Set();
+ const pending = pendingLayerRemovals;
+ const isCurrent = layers || pending.size > 0
+ ? () => !isDestroyed && pendingLayerRemovals === pending
+ : undefined;
- return { cellIds };
+ graph.startBatch('updateFromReact', syncOptions);
+ let removedPendingLayer = false;
+ try {
+ // Layers first: a cell may name a layer declared in this same commit.
+ if (layers) reconcileLayers(graph, layers, syncOptions);
+ const cellIds = syncCellsFromReact(cells, syncOptions);
+ // Layers last: joint-core refuses to remove a non-empty layer, so prune
+ // only after the cells that left it are gone.
+ removedPendingLayer = pruneLayersAfterSync(
+ layers,
+ Boolean(cells),
+ pending,
+ isCurrent,
+ syncOptions
+ );
+ return { cellIds };
+ } finally {
+ graph.stopBatch('updateFromReact', syncOptions);
+ if (removedPendingLayer && !isDestroyed) onLayersChange?.();
+ }
},
};
}
diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts
index 68a2ada1e1..e2e89c8ed7 100644
--- a/packages/joint-react/src/store/graph-projection.ts
+++ b/packages/joint-react/src/store/graph-projection.ts
@@ -3,6 +3,30 @@ import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types'
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';
+import type { LayerRecord } from '../types/layer.types';
+import { readLayerRecords } from './layers';
+
+const EMPTY_LAYERS: readonly LayerRecord[] = [];
+
+/**
+ * Per-snapshot id index, so `getLayer(id)` is O(1) per read while the list
+ * itself stays the reactive unit. Keyed by the snapshot array, so it is built
+ * at most once per layer change and never invalidated by hand.
+ */
+const layerIndexBySnapshot = new WeakMap>();
+
+/**
+ * Reactive, ordered view of the graph's layers. The list reference changes only
+ * when a layer is added, removed, reordered or has an attribute changed.
+ */
+interface LayersStore {
+ /** Layers bottom → top. Stable reference across unrelated graph commits. */
+ readonly getSnapshot: () => readonly LayerRecord[];
+ /** One layer by id, O(1). `undefined` when no such layer exists. */
+ readonly getLayer: (id: string) => LayerRecord | undefined;
+ /** Subscribe to any layer change. */
+ readonly subscribe: (listener: () => void) => () => void;
+}
/**
* A batch of cell changes reported after each graph update, delivered to the
@@ -59,6 +83,36 @@ export function graphProjection<
const cells = createContainer();
+ let layersSnapshot: readonly LayerRecord[] = readLayerRecords(graph, EMPTY_LAYERS);
+ const layerListeners = new Set<() => void>();
+
+ /** Re-read the layer list; notifies only when something actually changed. */
+ function syncLayersFromGraph(): void {
+ const next = readLayerRecords(graph, layersSnapshot);
+ if (next === layersSnapshot) return;
+ layersSnapshot = next;
+ for (const listener of layerListeners) listener();
+ }
+
+ const layers: LayersStore = {
+ getSnapshot: () => layersSnapshot,
+ getLayer(id) {
+ let index = layerIndexBySnapshot.get(layersSnapshot);
+ if (!index) {
+ index = new Map();
+ for (const record of layersSnapshot) index.set(record.id, record);
+ layerIndexBySnapshot.set(layersSnapshot, index);
+ }
+ return index.get(id);
+ },
+ subscribe(listener) {
+ layerListeners.add(listener);
+ return () => {
+ layerListeners.delete(listener);
+ };
+ },
+ };
+
// Pending container change set — accumulates across deferred commits (a
// transaction batch) and is flushed into the immutable snapshot by
// `cells.batchSet` whenever `deferCommit` is false. Buckets are kept disjoint
@@ -122,6 +176,7 @@ export function graphProjection<
const graphChangesController = graphChanges({
graph,
+ onLayersChange: syncLayersFromGraph,
onChanges: ({ changes, isInsideBatch, deferCommit, isReset }) => {
// Elements removed in this batch — swept once after the loop for link
// records they may have stranded.
@@ -274,9 +329,18 @@ export function graphProjection<
return {
cells: asReadonlyContainer(cells),
+ layers,
syncFromGraph,
+ syncLayersFromGraph,
updateGraph(update: UpdateGraphOptions) {
- const { cellIds } = graphChangesController.updateGraph(update);
+ let cellIds: readonly CellId[];
+ try {
+ ({ cellIds } = graphChangesController.updateGraph(update));
+ } finally {
+ // Tagged layer writes are not echoed by graphChanges. A user layer
+ // constructor may throw after earlier writes; publish the actual state.
+ if (update.flag === 'updateFromReact' && update.layers) syncLayersFromGraph();
+ }
if (update.flag !== 'updateFromReact') return;
if (!update.cells) return;
diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts
index e1837b4a76..7ebcb15fd1 100644
--- a/packages/joint-react/src/store/graph-store.ts
+++ b/packages/joint-react/src/store/graph-store.ts
@@ -3,9 +3,14 @@ import type { ElementJSONInit, LinkJSONInit, CellId } from '../types/cell.types'
import type { AddPaperOptions } from './paper-store';
import { PaperStore, getDefaultPaperState } from './paper-store';
+import type { LayerPatch, LayerRecord } from '../types/layer.types';
+import { assertLayerRecords, reconcileLayers, withLayerPatch } from './layers';
+import { isUpdater } from '../utils/is';
+import type { ArrayUpdate } from './state-container';
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 { LAYER_MODEL_TYPE, LayerModel } from '../mvc/layer-model';
import { isElementType, isLinkType } from '../utils/cell-type';
import { clearConnectedLinkViews } from './clear-view';
import { LAYOUT_UPDATE_EVENT } from './graph-changes';
@@ -27,6 +32,15 @@ export const DEFAULT_CELL_NAMESPACE: Record = {
[LINK_MODEL_TYPE]: LinkModel,
};
+/**
+ * Layer namespace of a graph the store creates: joint-core's plain layer
+ * `type` resolves to {@link LayerModel}, so the default `cells` layer and every
+ * layer declared without a `type` is one. Keyed by `type` like a cell namespace.
+ */
+export const DEFAULT_LAYER_NAMESPACE: Record = {
+ [LAYER_MODEL_TYPE]: LayerModel,
+};
+
/**
* Paper snapshot is a simple version counter.
* Incremented on every view mount/unmount change to trigger React re-renders.
@@ -60,6 +74,8 @@ export interface GraphStoreOptions<
readonly graph?: dia.Graph;
readonly cellNamespace?: unknown;
readonly cellModel?: typeof dia.Cell;
+ /** Layer classes by `type`, merged on top of {@link DEFAULT_LAYER_NAMESPACE}. */
+ readonly layerNamespace?: unknown;
/**
* Reference point that stays fixed when an auto-sized element's measured size
* changes. See {@link AutoSizeOrigin}.
@@ -67,6 +83,8 @@ export interface GraphStoreOptions<
*/
readonly autoSizeOrigin?: AutoSizeOrigin;
readonly initialCells?: ReadonlyArray>;
+ /** Layers to declare once at construction, in paint order. See {@link LayerRecord}. */
+ readonly initialLayers?: readonly LayerRecord[];
}
/**
@@ -90,9 +108,11 @@ export class GraphStore<
const {
cellModel,
cellNamespace = DEFAULT_CELL_NAMESPACE,
+ layerNamespace = DEFAULT_LAYER_NAMESPACE,
graph,
autoSizeOrigin = 'top-left',
initialCells,
+ initialLayers,
} = config;
this.graph =
@@ -104,6 +124,10 @@ export class GraphStore<
...DEFAULT_CELL_NAMESPACE,
...(cellNamespace as Record),
},
+ layerNamespace: {
+ ...DEFAULT_LAYER_NAMESPACE,
+ ...(layerNamespace as Record),
+ },
cellModel,
}
);
@@ -127,6 +151,15 @@ export class GraphStore<
paperStores: this.paperStores,
});
+ // Layers before cells: a seed cell may name a layer declared here, and
+ // joint-core throws on a cell whose layer does not exist yet.
+ if (initialLayers && initialLayers.length > 0) {
+ // Tagged so the layer listeners skip it; the projection is read once, here.
+ assertLayerRecords(this.graph, initialLayers);
+ reconcileLayers(this.graph, initialLayers, { isUpdateFromReact: true });
+ this.graphProjection.syncLayersFromGraph();
+ }
+
if (initialCells && initialCells.length > 0) {
// Replace existing graph state with the seed cells. The graph-changes
// listener handles `reset` synchronously and populates the cells
@@ -152,12 +185,35 @@ export class GraphStore<
* with the react-origin flag set.
* @param cells - new cells snapshot from the parent
* @param metadata - extra options forwarded to the underlying `graph.syncCells` opt
+ * @param layers - controlled layers, applied in the same commit: added before
+ * the cells (a cell may name a new layer) and pruned after them (a layer
+ * emptied by this sync is removed now, not next time)
*/
public applyControlled = (
cells: ReadonlyArray,
- metadata?: Record
+ metadata?: Record,
+ layers?: readonly LayerRecord[]
) => {
- this.graphProjection.updateGraph({ cells, flag: 'updateFromReact', metadata });
+ this.graphProjection.updateGraph({ cells, layers, flag: 'updateFromReact', metadata });
+ };
+
+ /**
+ * Apply a layers snapshot from React (the controlled `layers` prop or an
+ * imperative setter). Adds, reorders, updates, and removes now-empty layers.
+ * @param layers - declared layers in paint order, or an updater of the current ones
+ */
+ public applyLayers = (layers: ArrayUpdate) => {
+ const next = isUpdater(layers) ? layers(this.graphProjection.layers.getSnapshot()) : layers;
+ this.graphProjection.updateGraph({ layers: next, flag: 'updateFromReact' });
+ };
+
+ /**
+ * Merge attributes into one layer, adding it on top when it does not exist.
+ * @param id - the layer to patch
+ * @param patch - attributes to merge
+ */
+ public applyLayer = (id: string, patch: LayerPatch) => {
+ this.applyLayers(withLayerPatch(this.graphProjection.layers.getSnapshot(), id, patch));
};
/**
diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts
new file mode 100644
index 0000000000..04d92f34df
--- /dev/null
+++ b/packages/joint-react/src/store/layers.ts
@@ -0,0 +1,364 @@
+import { dia } from '@joint/core';
+import type { LayerPatch, LayerRecord } from '../types/layer.types';
+import { forgetLayerNotEmptyWarning, warnLayerNotEmpty } from '../utils/dev-warnings';
+import { isShallowEqual } from '../utils/selector-utils';
+import { isRecord } from '../utils/is';
+
+/** `type` joint-core gives a plain layer — read from core, not restated — and left out of records. */
+const DEFAULT_GRAPH_LAYER_TYPE: unknown = dia.GraphLayer.prototype.defaults().type;
+
+/** Keys that identify or describe a layer's role rather than its attributes — never written back. */
+const RESERVED_KEYS = new Set(['id', 'type', 'isDefault']);
+// Public add/move operations enable layer-aware JSON. Remember that activation
+// without reading or writing the core's legacy flag or repeating a no-op move.
+const graphsWithDeclaredLayers = new WeakSet();
+
+/**
+ * Whether a layer attribute belongs in its record: omit identity, derived
+ * default-layer status and joint-core's type marker for a plain layer.
+ * @param key - The attribute key.
+ * @param value - Its value.
+ */
+function isRecordAttribute(key: string, value: unknown): boolean {
+ return key !== 'id' && key !== 'isDefault' && !(key === 'type' && value === DEFAULT_GRAPH_LAYER_TYPE);
+}
+
+/**
+ * Projects one `dia.GraphLayer` into a record, reusing `previous` when nothing
+ * changed so `Object.is` bail-outs hold downstream.
+ * @param layer - The graph layer to project.
+ * @param isDefault - Whether it is the graph's default layer.
+ * @param previous - The record last projected for this layer id, if any.
+ * @returns The previous record when shallow-equal, otherwise a fresh one.
+ */
+function toLayerRecord(
+ layer: dia.GraphLayer,
+ isDefault: boolean,
+ previous: LayerRecord | undefined
+): LayerRecord {
+ const { attributes } = layer;
+ const custom: Record = {};
+ for (const key in attributes) {
+ if (isRecordAttribute(key, attributes[key])) custom[key] = attributes[key];
+ }
+ const next: LayerRecord = isDefault
+ ? { ...custom, id: layer.id, isDefault }
+ : { ...custom, id: layer.id };
+ return isShallowEqual(previous, next) && previous ? previous : next;
+}
+
+/**
+ * Reads the graph's layers, bottom to top, as records. Structurally shared:
+ * returns `previous` itself when nothing changed, and reuses every unchanged
+ * record inside a new array otherwise. O(L); the only allocation on the
+ * no-change path is joint-core's own `getLayers()` copy. Runs on layer events,
+ * on a graph reset, and after a React-origin layers write — never on a plain
+ * cell commit.
+ * @param graph - The graph to read.
+ * @param previous - The last projected list, in paint order.
+ * @returns The current list, sharing references with `previous` where possible.
+ * @internal
+ */
+export function readLayerRecords(
+ graph: dia.Graph,
+ previous: readonly LayerRecord[]
+): readonly LayerRecord[] {
+ const layers = graph.getLayers();
+ const defaultLayer = graph.getDefaultLayer();
+ // Allocated only once a difference is found; until then `previous` may be
+ // returned as is.
+ let next: LayerRecord[] | null = layers.length === previous.length ? null : [];
+ let index = 0;
+ for (const layer of layers) {
+ // Paint order is usually stable, so try the positional match first and fall
+ // back to a scan only when the order changed. Deliberate ceiling: O(L²) on
+ // a full reorder; an id index pays off past ~50 layers.
+ const positional = previous[index];
+ const before =
+ positional?.id === layer.id ? positional : previous.find((record) => record.id === layer.id);
+ const record = toLayerRecord(layer, layer === defaultLayer, before);
+ if (next === null && record !== positional) next = previous.slice(0, index);
+ next?.push(record);
+ index += 1;
+ }
+ return next ?? previous;
+}
+
+/**
+ * Whether the graph can construct a layer of `type` — the constructor is
+ * registered in its `layerNamespace` (a dotted path, like a cell type).
+ * @param graph - The graph whose namespace is consulted.
+ * @param type - The layer `type` attribute.
+ */
+function isRegisteredLayerType(graph: dia.Graph, type: string): boolean {
+ // Own keys only: `util.getByPath` also walks the prototype chain, so a type
+ // such as `constructor` or `toString` would pass here and then fail inside
+ // joint-core, halfway through the apply.
+ let current: unknown = graph.layerCollection.layerNamespace;
+ for (const key of type.split('.')) {
+ if (!isRecord(current) || !Object.hasOwn(current, key)) return false;
+ current = current[key];
+ }
+ return (
+ typeof current === 'function' &&
+ (current === dia.GraphLayer || current.prototype instanceof dia.GraphLayer)
+ );
+}
+
+/**
+ * Throws on a layer list joint-core would reject or silently collapse: an
+ * empty id, an id declared twice (layers are keyed by id, so the graph could
+ * never match the array), a provided `type` that is not a string identifying a
+ * GraphLayer constructor in the graph's `layerNamespace`, or a `type` that
+ * differs from the existing layer's (a layer's class is fixed at creation,
+ * so the change would be ignored). Runs before any write, so a bad array
+ * leaves the graph — and any open batch — untouched. An omitted or `undefined` type uses the
+ * graph's default layer constructor.
+ * @param graph - The target graph.
+ * @param layers - The declared layers.
+ * @internal
+ */
+export function assertLayerRecords(graph: dia.Graph, layers: readonly LayerRecord[]): void {
+ const seen = new Set();
+ for (const { id, type } of layers) {
+ if (!id) throw new Error('GraphProvider: a layer needs a non-empty id, got an empty layer id.');
+ if (seen.has(id)) throw new Error(`GraphProvider: layer id "${id}" is declared twice.`);
+ seen.add(id);
+ if (type !== undefined && typeof type !== 'string') {
+ throw new Error(
+ `GraphProvider: layer "${id}" has a non-string type. Omit it or provide a layer type string.`
+ );
+ }
+ if (typeof type === 'string' && !isRegisteredLayerType(graph, type)) {
+ throw new Error(
+ `GraphProvider: layer "${id}" has type "${type}", which is not in the graph's \`layerNamespace\`.`
+ );
+ }
+ if (typeof type === 'string' && graph.hasLayer(id) && graph.getLayer(id).get('type') !== type) {
+ throw new Error(
+ `GraphProvider: layer "${id}" already exists with another type than "${type}". ` +
+ 'A layer type is fixed at creation - declare the new type under a new id.'
+ );
+ }
+ }
+}
+
+/**
+ * Applies a declared layer list to the graph: adds missing layers, reorders to
+ * match the array (index 0 paints at the bottom), and writes changed attributes.
+ * Layers absent from the array are left in place — remove them with
+ * {@link removeEmptyLayers} once the cells are synced, because joint-core
+ * refuses to remove a non-empty layer.
+ *
+ * The default layer always exists and is never removed; omit it and it is
+ * reconciled as a bare `{ id }` at the bottom (so attributes it was given
+ * earlier are unset), name it to position it.
+ * @param graph - The graph to reconcile.
+ * @param next - Declared layers in paint order.
+ * @param options - Forwarded into every graph event this raises (tag React writes here).
+ * @internal
+ */
+export function reconcileLayers(
+ graph: dia.Graph,
+ next: readonly LayerRecord[],
+ options: Record = {}
+): void {
+ const defaultId = graph.getDefaultLayer().id;
+ const declaredDefault = next.find((record) => record.id === defaultId);
+ const target = declaredDefault ? next : [{ id: defaultId }, ...next];
+
+ for (const record of target) {
+ if (graph.hasLayer(record.id)) {
+ updateLayerAttributes(graph.getLayer(record.id), record, options);
+ } else {
+ // `isDefault` is derived from the graph, never an attribute to store.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { isDefault, ...layerInit } = record;
+ graph.addLayer(layerInit, options);
+ graphsWithDeclaredLayers.add(graph);
+ }
+ }
+
+ // Walk the target order; move each layer that is out of place to its index.
+ // The local mirror avoids re-reading `graph.getLayers()` (an array copy) per
+ // move. Deliberate ceiling: O(L) moves, one paper sort each; switch to
+ // LCS-minimal moves if diagrams grow past a handful of layers.
+ const order = graph.getLayers().map((layer) => layer.id);
+ for (const [index, record] of target.entries()) {
+ if (order[index] === record.id) continue;
+ graph.moveLayer(record.id, { ...options, index });
+ graphsWithDeclaredLayers.add(graph);
+ order.splice(order.indexOf(record.id), 1);
+ order.splice(index, 0, record.id);
+ }
+
+ // Configuring only the existing default layer calls neither add nor move,
+ // but its attributes must still survive graph.toJSON(). A same-position
+ // public move opts into layer serialization without resetting cells/views.
+ if (
+ declaredDefault &&
+ !graphsWithDeclaredLayers.has(graph) &&
+ Object.keys(declaredDefault).some((key) => !RESERVED_KEYS.has(key))
+ ) {
+ graph.moveLayer(defaultId, { ...options, index: order.indexOf(defaultId) });
+ graphsWithDeclaredLayers.add(graph);
+ }
+}
+
+/**
+ * Writes the record's attributes and unsets those it dropped. `mvc.Model.set`
+ * diffs against the current values itself and fires `change` only for real
+ * changes, so no pre-diff is needed here.
+ * @param layer - The live graph layer.
+ * @param record - The declared record for it.
+ * @param options - Forwarded as the `set`/`unset` opt.
+ */
+function updateLayerAttributes(
+ layer: dia.GraphLayer,
+ record: LayerRecord,
+ options: Record
+): void {
+ const attributes: Record = {};
+ for (const key in record) {
+ if (!RESERVED_KEYS.has(key)) attributes[key] = record[key];
+ }
+ layer.set(attributes, options);
+ // An attribute the record dropped goes back to the layer class's default when
+ // it provides one (a custom `dia.GraphLayer` subclass keeps its own), and is
+ // unset otherwise. `defaults()` allocates; fetch it only once an attribute is
+ // actually missing.
+ let classDefaults: Record | null = null;
+ for (const key in layer.attributes) {
+ if (RESERVED_KEYS.has(key) || key in record) continue;
+ classDefaults ??= layer.defaults();
+ if (key in classDefaults) {
+ layer.set(key, classDefaults[key], options);
+ } else {
+ layer.unset(key, options);
+ }
+ }
+}
+
+/**
+ * Removes every layer the array no longer declares, provided it is empty. A
+ * layer that still holds cells is kept — joint-core throws on removing it, and
+ * migrating its cells would rewrite user data — and a dev warning names the
+ * cells to move. Runs after the cells sync so a layer emptied in the same
+ * commit is removed in that commit.
+ * @param graph - The graph to prune.
+ * @param next - Declared layers; anything else is a removal candidate.
+ * @param options - Forwarded into the `layer:remove` event.
+ * @param isCurrent - Whether this declaration still owns layer removals.
+ * @returns Ids retained only because they still hold cells.
+ * @internal
+ */
+export function removeEmptyLayers(
+ graph: dia.Graph,
+ next: readonly LayerRecord[],
+ options: Record = {},
+ isCurrent: () => boolean = () => true
+): Set {
+ const keep = new Set();
+ for (const record of next) keep.add(record.id);
+ const pending = new Set();
+ for (const candidate of graph.getLayers()) {
+ if (!isCurrent()) break;
+ const { id } = candidate;
+ // Removing the previous layer fires public listeners synchronously. They
+ // may replace this declaration, the default, or the next layer instance.
+ if (id === graph.getDefaultLayer().id || keep.has(id) || !graph.hasLayer(id)) continue;
+ const layer = graph.getLayer(id);
+ if (!removeEmptyLayer(graph, layer, options)) {
+ warnLayerNotEmpty(id, () => layer.getCells().map((cell) => cell.id));
+ pending.add(id);
+ }
+ }
+ return pending;
+}
+
+/** Removes an empty layer, keeping nonempty ones for a later cells commit. */
+function removeEmptyLayer(
+ graph: dia.Graph,
+ layer: dia.GraphLayer,
+ options: Record
+): boolean {
+ if (layer.cellCollection.length > 0) return false;
+ graph.removeLayer(layer.id, options);
+ forgetLayerNotEmptyWarning(layer.id);
+ return true;
+}
+
+/** Resolves a pending id against the current default and current layer model. */
+function getEmptyPendingLayer(
+ graph: dia.Graph,
+ pending: Set,
+ id: string
+): dia.GraphLayer | undefined {
+ if (id === graph.getDefaultLayer().id || !graph.hasLayer(id)) {
+ pending.delete(id);
+ forgetLayerNotEmptyWarning(id);
+ return undefined;
+ }
+ const layer = graph.getLayer(id);
+ return layer.cellCollection.length === 0 ? layer : undefined;
+}
+
+/**
+ * Retries only layers a declaration already tried to remove, after cells move.
+ * No layer list is read or reconciled on this cells-only path.
+ * @param graph - The graph holding the retained layers.
+ * @param pending - Pending ids, removed from this set as they are resolved.
+ * @param options - Forwarded to removals.
+ * @param beforeRemove - Called once, before preparing the first removal.
+ * @param isCurrent - Whether the owner and this pending declaration are still current.
+ * @returns Whether a layer was removed and the snapshot must be refreshed.
+ * @internal
+ */
+export function retryEmptyLayerRemovals(
+ graph: dia.Graph,
+ pending: Set,
+ options: Record,
+ beforeRemove?: () => void,
+ isCurrent: () => boolean = () => true
+): boolean {
+ let removed = false;
+ let prepared = false;
+ for (const id of pending) {
+ if (!isCurrent()) break;
+ if (!getEmptyPendingLayer(graph, pending, id)) continue;
+ if (!prepared) {
+ prepared = true;
+ beforeRemove?.();
+ }
+ // Batch-start and prior removal listeners may replace the declaration,
+ // destroy its owner, change the default, or replace this layer model.
+ if (!isCurrent()) break;
+ const layer = getEmptyPendingLayer(graph, pending, id);
+ if (layer && removeEmptyLayer(graph, layer, options)) {
+ pending.delete(id);
+ removed = true;
+ }
+ }
+ return removed;
+}
+
+/**
+ * The layer list with `patch` merged into layer `id` — appended (on top) when
+ * no such layer exists. Pure; the caller applies the result.
+ * @param layers - Current layers in paint order.
+ * @param id - The layer to patch or add.
+ * @param patch - Attributes to merge.
+ * @returns A new list; `layers` is not mutated.
+ * @internal
+ */
+export function withLayerPatch(
+ layers: readonly LayerRecord[],
+ id: string,
+ patch: LayerPatch
+): readonly LayerRecord[] {
+ // Deliberate: O(L) rebuild on the write path — L is a handful of layers.
+ const hasLayer = layers.some((layer) => layer.id === id);
+ return hasLayer
+ ? layers.map((layer) => (layer.id === id ? { ...layer, ...patch, id } : layer))
+ : [...layers, { ...patch, id }];
+}
diff --git a/packages/joint-react/src/types/layer.types.ts b/packages/joint-react/src/types/layer.types.ts
new file mode 100644
index 0000000000..ec081ccc6d
--- /dev/null
+++ b/packages/joint-react/src/types/layer.types.ts
@@ -0,0 +1,73 @@
+/**
+ * A graph layer as seen from React: its id, the attributes joint-react
+ * interprets, and any custom attributes you store on it. Mirrors a
+ * `dia.GraphLayer`'s attribute hash, so custom attributes round-trip through
+ * `graph.toJSON()` unchanged. Custom attributes read back as `unknown` —
+ * narrow them (`typeof layer.name === 'string'`) or select a typed value.
+ * A provided `type` must be a string naming a registered `dia.GraphLayer`
+ * constructor in the graph's `layerNamespace`. Omit it or pass `undefined`
+ * to use the default constructor; other values, including `null`, are rejected.
+ * @template LayerId - Union of the layer ids your diagram uses, for
+ * autocomplete and narrowing. Defaults to `string`.
+ * @group Types
+ * @example
+ * ```tsx
+ * import type { LayerRecord } from '@joint/react';
+ *
+ * type DiagramLayerId = 'background' | 'cells' | 'foreground';
+ * const layers: ReadonlyArray> = [
+ * { id: 'background' },
+ * { id: 'cells' },
+ * { id: 'foreground', visible: false },
+ * ];
+ * ```
+ */
+export interface LayerRecord {
+ readonly id: LayerId;
+ /**
+ * Whether the layer is painted. A hidden layer keeps its cells mounted and
+ * sets `visibility: hidden` on the SVG layer group (see {@link LayerView}),
+ * with a single group update. HTML overlays mirror visibility on their
+ * wrappers. Cells keep their bounding boxes: they can still be measured,
+ * and links anchored to them still route.
+ *
+ * Hidden is not unmounted: cell views stay in the DOM and in the paper's
+ * view management, so hide/show never re-renders React content. For a very
+ * large layer you hide for long periods the paper's `cellVisibility` option
+ * (which does unmount) is the better tool.
+ * @default true
+ */
+ readonly visible?: boolean;
+ /**
+ * `true` on the graph's default layer — the one an untagged cell lands on.
+ * Read-only: it reflects `graph.getDefaultLayer()` and is ignored when
+ * written back; change the default through the graph if you must, and note
+ * the layers API assumes it stays the built-in `cells` layer.
+ */
+ readonly isDefault?: boolean;
+ readonly [attribute: string]: unknown;
+}
+
+/**
+ * Attributes to merge into a layer with `setLayer` — everything a
+ * {@link LayerRecord} carries except its `id`. Declared explicitly rather than
+ * as `Omit`: `Omit` over an index signature collapses
+ * `visible` to `unknown`.
+ * @group Types
+ * @example
+ * ```tsx
+ * import { useGraph } from '@joint/react';
+ *
+ * const { setLayer } = useGraph();
+ * setLayer('notes', { visible: false });
+ * ```
+ */
+export interface LayerPatch {
+ /** Not patchable: the id names the layer to patch. */
+ readonly id?: never;
+ /** Not patchable: derived from the graph, see {@link LayerRecord.isDefault}. */
+ readonly isDefault?: never;
+ /** See {@link LayerRecord.visible}. */
+ readonly visible?: boolean;
+ readonly [attribute: string]: unknown;
+}
diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts
index 85ca3b68fa..a415991da0 100644
--- a/packages/joint-react/src/utils/dev-warnings.ts
+++ b/packages/joint-react/src/utils/dev-warnings.ts
@@ -4,6 +4,28 @@ import type { CellId } from '../types/cell.types';
const WARNED = new Set();
+/**
+ * Emits a dev-only warning once per `key`; later calls with the same key are
+ * silent until {@link forgetWarning} clears it. The message is built lazily so
+ * a suppressed call does no string work.
+ * @param key - Deduplication key.
+ * @param buildMessage - Produces the warning text.
+ */
+function warnOnce(key: string, buildMessage: () => string): void {
+ if (process.env.NODE_ENV === 'production') return;
+ if (WARNED.has(key)) return;
+ WARNED.add(key);
+ console.warn(buildMessage());
+}
+
+/**
+ * Re-arms a {@link warnOnce} key so the next occurrence warns afresh.
+ * @param key - The key to clear.
+ */
+function forgetWarning(key: string): void {
+ WARNED.delete(key);
+}
+
/**
* Warns (dev-only, tree-shaken in production) when a cell setter is called for
* a target that cannot be resolved, a nullish id, or an id with no matching
@@ -125,3 +147,35 @@ export function warnResizeOnAutoSizedElement(cellId: dia.Cell.ID): void {
'explicit size (e.g. from FreeTransform / Halo).'
);
}
+
+/**
+ * Warns once per layer when a `layers` array drops a layer that still holds
+ * cells. The layer is kept rather than removed: joint-core refuses to remove a
+ * non-empty layer, and moving its cells would rewrite user data. Keyed by
+ * layer id so a controlled commit stream (every drag frame) warns once, not
+ * per frame; {@link forgetLayerNotEmptyWarning} re-arms it once the layer is
+ * gone. The cell ids are listed only when the warning actually prints. Dev-only.
+ * @param layerId - The layer the array no longer declares.
+ * @param getCellIds - Lazily lists the cells still assigned to it.
+ */
+export function warnLayerNotEmpty(layerId: string, getCellIds: () => readonly dia.Cell.ID[]): void {
+ warnOnce(`layer-not-empty:${layerId}`, () => {
+ const cellIds = getCellIds();
+ return (
+ `[GraphProvider] Layer "${layerId}" was dropped from \`layers\` but still holds ` +
+ `${cellIds.length} cell(s): ${cellIds.map(String).join(', ')}. ` +
+ 'It is kept until they are moved or removed.\n\n' +
+ 'Fix: reassign them first — setCell(id, (previous) => ({ ...previous, layer: \'other\' })) — ' +
+ 'or remove them.'
+ );
+ });
+}
+
+/**
+ * Re-arms {@link warnLayerNotEmpty} for a layer id, called once the layer is
+ * actually removed so a later re-declaration warns afresh.
+ * @param layerId - The removed layer.
+ */
+export function forgetLayerNotEmptyWarning(layerId: string): void {
+ forgetWarning(`layer-not-empty:${layerId}`);
+}
diff --git a/packages/joint-react/stories/examples/layers/code.tsx b/packages/joint-react/stories/examples/layers/code.tsx
index 83fae7b26e..3fc050faa3 100644
--- a/packages/joint-react/stories/examples/layers/code.tsx
+++ b/packages/joint-react/stories/examples/layers/code.tsx
@@ -1,17 +1,16 @@
-import { dia, shapes } from '@joint/core';
import {
type CellRecord,
- type CellVisibility,
+ type LayerRecord,
GraphProvider,
- useCell,
- Paper,
- ElementModel,
- LinkModel,
HTMLHost,
+ Paper,
+ useCell,
+ useGraph,
+ useLayer,
+ useLayers,
selectElementSize,
- usePaper,
} from '@joint/react';
-import { useCallback, useMemo, useState, type MouseEvent } from 'react';
+import { useCallback, useMemo } from 'react';
import './styles.css';
// Colors — unified dark diagram palette.
@@ -22,9 +21,15 @@ const BACKGROUND_LAYER_STROKE = '#2f4053';
const TEXT_COLOR = '#DDE6ED';
const MUTED_TEXT_COLOR = '#93A4B3';
-const PAPER_ID = 'layers-paper';
-/** Render order: first added draws behind, last draws in front. */
-const LAYERS = ['background', 'main', 'foreground'] as const;
+/** Autocomplete and narrowing for every layer id in this diagram. */
+type DiagramLayerId = 'background' | 'cells' | 'foreground';
+
+/** Paint order: index 0 draws at the bottom, the last entry on top. */
+const initialLayers: ReadonlyArray> = [
+ { id: 'background' },
+ { id: 'cells' },
+ { id: 'foreground' },
+];
interface LayeredElementData {
readonly [key: string]: unknown;
@@ -55,14 +60,12 @@ const initialCells: ReadonlyArray> = [
type: 'element',
data: { label: 'Main 1', color: PRIMARY },
position: { x: 50, y: 50 },
- layer: 'main',
},
{
id: 'main-2',
type: 'element',
data: { label: 'Main 2', color: PRIMARY },
position: { x: 280, y: 50 },
- layer: 'main',
},
{
id: 'fg-1',
@@ -77,7 +80,6 @@ const initialCells: ReadonlyArray> = [
source: { id: 'main-1' },
target: { id: 'main-2' },
style: { color: PRIMARY, className: 'fade-in' },
- layer: 'main',
},
{
id: 'link-2',
@@ -122,79 +124,62 @@ function RenderElement(data: Readonly) {
return data.isBackground ? : ;
}
+const selectVisible = (layer: LayerRecord) => layer.visible !== false;
+
+/** One row of the layers panel; subscribes to its own layer only. */
+function LayerRow({ id }: Readonly<{ id: DiagramLayerId }>) {
+ // `LayerId` infers from the typed `id`, `Selected` from the selector — no generics needed.
+ const isVisible = useLayer(id, selectVisible) ?? true;
+ const { setLayer } = useGraph();
+ const toggle = useCallback(() => setLayer(id, { visible: !isVisible }), [id, isVisible, setLayer]);
+ return (
+
+ );
+}
+
function Main() {
- const [hiddenLayers, setHiddenLayers] = useState>(() => new Set());
- const { wakeUp } = usePaper(PAPER_ID);
+ const layers = useLayers();
+ const { setLayers, setCell } = useGraph();
- const toggleLayer = useCallback(
- (event: MouseEvent) => {
- const layerId = event.currentTarget.dataset.layer;
- if (!layerId) return;
- setHiddenLayers((previous) => {
- const next = new Set(previous);
- if (next.has(layerId)) {
- next.delete(layerId);
- } else {
- next.add(layerId);
- }
- return next;
- });
- // Refresh the paper so hidden/shown cells are re-evaluated immediately.
- wakeUp();
- },
- [wakeUp]
+ // Reverse the paint order: the array IS the order, so reorder the array.
+ const flipOrder = useCallback(
+ () => setLayers((previous) => previous.toReversed()),
+ [setLayers]
);
- const cellVisibility = useCallback(
- ({ model }) => {
- const cellLayer = model.layer();
- return !cellLayer || !hiddenLayers.has(cellLayer);
- },
- [hiddenLayers]
+ // Membership lives on the cell: move it by writing its `layer`.
+ const liftMain = useCallback(
+ () => setCell('main-1', (previous) => ({ ...previous, layer: 'foreground' })),
+ [setCell]
);
return (
- {LAYERS.map((layerId) => {
- const isHidden = hiddenLayers.has(layerId);
- return (
-
- );
- })}
+ {layers.map((layer) => (
+
+ ))}
+
+
-
+
);
}
export default function App() {
- const graph = useMemo(() => {
- const nextGraph = new dia.Graph(
- {},
- { cellNamespace: { ...shapes, element: ElementModel, link: LinkModel } }
- );
- for (const id of LAYERS) {
- nextGraph.addLayer({ id });
- }
- return nextGraph;
- }, []);
-
return (
-
+
);
diff --git a/packages/joint-react/stories/examples/layers/story.tsx b/packages/joint-react/stories/examples/layers/story.tsx
index 464959c53a..5be9fc067a 100644
--- a/packages/joint-react/stories/examples/layers/story.tsx
+++ b/packages/joint-react/stories/examples/layers/story.tsx
@@ -11,8 +11,8 @@ const meta = {
showcase: {
canvasHeight: 440,
description:
- 'Organize elements and links into background, main, and foreground layers, then show or hide each layer with the paper cellVisibility predicate.',
- apiUrl: getAPILink('Paper'),
+ 'Declare paint-ordered layers on GraphProvider, assign cells with their `layer` field, then hide and reorder them with useLayer, useLayers, and setLayers — and move a cell between layers with setCell.',
+ apiUrl: getAPILink('useLayers'),
code: codeRaw,
},
},
|