diff --git a/.changeset/layers-graph-provider.md b/.changeset/layers-graph-provider.md new file mode 100644 index 0000000000..ae4bdb49bf --- /dev/null +++ b/.changeset/layers-graph-provider.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + + - add `initialLayers`, controlled `layers` and `onLayersChange` diff --git a/.changeset/layers-mvc.md b/.changeset/layers-mvc.md new file mode 100644 index 0000000000..e63ad16afc --- /dev/null +++ b/.changeset/layers-mvc.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +LayerModel - add the layer model preset registered for plain graph layers diff --git a/.changeset/layers-namespace.md b/.changeset/layers-namespace.md new file mode 100644 index 0000000000..bd3e3e6168 --- /dev/null +++ b/.changeset/layers-namespace.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + + - add `layerNamespace` prop to register custom layer types diff --git a/.changeset/layers-record.md b/.changeset/layers-record.md new file mode 100644 index 0000000000..b9fac051de --- /dev/null +++ b/.changeset/layers-record.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +LayerRecord - add the layer record type; `visible: false` hides a layer without unmounting its cells diff --git a/.changeset/layers-setters.md b/.changeset/layers-setters.md new file mode 100644 index 0000000000..9362e94d15 --- /dev/null +++ b/.changeset/layers-setters.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useGraph - add `setLayers()` and `setLayer()` to declare, reorder, update and hide layers diff --git a/.changeset/layers-use-layer.md b/.changeset/layers-use-layer.md new file mode 100644 index 0000000000..6449e9a998 --- /dev/null +++ b/.changeset/layers-use-layer.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useLayer - add a hook reading one layer by id with an optional selector diff --git a/.changeset/layers-use-layers.md b/.changeset/layers-use-layers.md new file mode 100644 index 0000000000..10ea8844a3 --- /dev/null +++ b/.changeset/layers-use-layers.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +useLayers - add a hook returning the graph's layers in paint order, generic over the layer id union diff --git a/.changeset/layers-view.md b/.changeset/layers-view.md new file mode 100644 index 0000000000..772757463c --- /dev/null +++ b/.changeset/layers-view.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +LayerView - add the layer view preset that applies a layer's `visible` attribute diff --git a/.changeset/react-layer-callback-commit.md b/.changeset/react-layer-callback-commit.md new file mode 100644 index 0000000000..efae1ebba7 --- /dev/null +++ b/.changeset/react-layer-callback-commit.md @@ -0,0 +1,5 @@ +--- +"@joint/react": patch +--- + + - refresh layer callbacks before descendant layout effects diff --git a/packages/joint-react/src/components/graph/__tests__/controlled-layer-pruning.test.tsx b/packages/joint-react/src/components/graph/__tests__/controlled-layer-pruning.test.tsx new file mode 100644 index 0000000000..3ebb36c2e5 --- /dev/null +++ b/packages/joint-react/src/components/graph/__tests__/controlled-layer-pruning.test.tsx @@ -0,0 +1,98 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { GraphProvider } from '../graph-provider'; +import { useGraphStore } from '../../../hooks/use-graph-store'; +import { useGraph } from '../../../hooks/use-graph'; +import type { GraphStore } from '../../../store'; +import type { CellRecord, ElementRecord } from '../../../types/cell.types'; +import type { LayerRecord } from '../../../types/layer.types'; +import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model'; + +const element = (layer: string) => ({ + id: 'node', + type: ELEMENT_MODEL_TYPE, + layer, + position: { x: 0, y: 0 }, + size: { width: 10, height: 10 }, +} satisfies ElementRecord); +const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'notes' }]; +const REMAINING_LAYERS: readonly LayerRecord[] = []; +const INITIAL_CELLS: readonly CellRecord[] = [element('notes')]; +const MOVED_CELLS: readonly CellRecord[] = [element('cells')]; +const NO_CELLS: readonly CellRecord[] = []; +interface Captured { store?: GraphStore; api?: ReturnType } +function Capture({ captured }: Readonly<{ captured: Captured }>) { + captured.store = useGraphStore(); + captured.api = useGraph(); + return null; +} + +it.each(['move', 'delete'] as const)('prunes a retained layer after a controlled cells-only %s with the same layers array', async (mode) => { + const captured: Captured = {}; + const onLayersChange = jest.fn(); + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + const tree = (cells: readonly CellRecord[], layers: readonly LayerRecord[]) => ( + + + + ); + const view = render(tree(INITIAL_CELLS, INITIAL_LAYERS)); + try { + await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true)); + onLayersChange.mockClear(); + await act(async () => { + view.rerender(tree(INITIAL_CELLS, REMAINING_LAYERS)); + }); + expect(captured.store?.graph.hasLayer('notes')).toBe(true); + + await act(async () => { + view.rerender(tree(mode === 'move' ? MOVED_CELLS : NO_CELLS, REMAINING_LAYERS)); + }); + + if (mode === 'move') expect(captured.store?.graph.getCell('node').get('layer')).toBe('cells'); + else expect(captured.store?.graph.getCell('node')).toBeUndefined(); + expect(captured.store?.graph.hasLayer('notes')).toBe(false); + expect(captured.store?.graphProjection.layers.getLayer('notes')).toBeUndefined(); + expect(onLayersChange).not.toHaveBeenCalled(); + } finally { + view.unmount(); + warn.mockRestore(); + } +}); + +it.each(['api move', 'native move', 'native delete'] as const)( + 'prunes a retained controlled layer after an uncontrolled %s without a parent update', + async (mode) => { + const captured: Captured = {}; + const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + const tree = (layers: readonly LayerRecord[]) => ( + + + + ); + const view = render(tree(INITIAL_LAYERS)); + try { + await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true)); + await act(async () => { + view.rerender(tree(REMAINING_LAYERS)); + }); + const { store, api } = captured; + if (!store || !api) throw new Error('graph not ready'); + expect(store.graph.hasLayer('notes')).toBe(true); + + await act(async () => { + if (mode === 'api move') api.setCell(element('cells')); + else if (mode === 'native move') store.graph.getCell('node').layer('cells'); + else store.graph.removeCells([store.graph.getCell('node')]); + await new Promise((resolve) => queueMicrotask(resolve)); + }); + + expect(store.graph.hasLayer('notes')).toBe(false); + expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined(); + if (mode === 'native delete') expect(store.graph.getCell('node')).toBeUndefined(); + else expect(store.graph.getCell('node').layer()).toBe('cells'); + } finally { + view.unmount(); + warn.mockRestore(); + } + } +); diff --git a/packages/joint-react/src/components/graph/__tests__/graph-provider-layer-lifecycle.test.tsx b/packages/joint-react/src/components/graph/__tests__/graph-provider-layer-lifecycle.test.tsx new file mode 100644 index 0000000000..46929d80b3 --- /dev/null +++ b/packages/joint-react/src/components/graph/__tests__/graph-provider-layer-lifecycle.test.tsx @@ -0,0 +1,120 @@ +import { dia } from '@joint/core'; +import { act, render } from '@testing-library/react'; +import { useLayoutEffect } from 'react'; +import { GraphProvider } from '../graph-provider'; +import { useGraph } from '../../../hooks/use-graph'; +import type { GraphApi } from '../../../hooks/use-graph'; +import type { LayerRecord } from '../../../types/layer.types'; + +const UPDATED_LAYERS = [{ id: 'cells' }, { id: 'notes', name: 'Updated' }]; +const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'cells' }, { id: 'notes' }]; + +function MountMutation({ mutate }: { readonly mutate: (api: GraphApi) => void }) { + const api = useGraph(); + useLayoutEffect(() => { + mutate(api); + }, [api, mutate]); + return null; +} + +const hideNotes = (api: GraphApi) => api.setLayer('notes', { visible: false }); +const addLayer = (api: GraphApi) => { + if (!api.graph.hasLayer('child')) api.graph.addLayer({ id: 'child' }); +}; + +describe('GraphProvider layer subscription ownership', () => { + it.each([ + ['setLayer', hideNotes, { id: 'notes', visible: false }], + ['graph.addLayer', addLayer, { id: 'child' }], + ] as const)('reports %s from a child mount layout effect', (_name, mutate, expected) => { + const onLayersChange = jest.fn(); + render( + + + + ); + + expect(onLayersChange).toHaveBeenCalledTimes(1); + expect(onLayersChange).toHaveBeenLastCalledWith(expect.arrayContaining([expected])); + }); + + it('restores controlled layers changed by a child mount layout effect without a handler', async () => { + const graph = new dia.Graph(); + render( + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(graph.getLayer('notes').get('visible')).not.toBe(false); + }); + + it('does not revert external graph layers after the provider unmounts', async () => { + const graph = new dia.Graph(); + const { unmount } = render(); + + act(() => { + graph.getLayer('notes').set('visible', false); + }); + expect(graph.getLayer('notes').get('visible')).toBe(false); + unmount(); + + await act(async () => { + await Promise.resolve(); + }); + expect(graph.getLayer('notes').get('visible')).toBe(false); + }); + + it('uses the latest callback and does not echo controlled prop updates', () => { + const graph = new dia.Graph(); + const initialHandler = jest.fn(); + const nextHandler = jest.fn(); + const { rerender } = render( + + ); + + rerender(); + expect(initialHandler).not.toHaveBeenCalled(); + expect(nextHandler).not.toHaveBeenCalled(); + + act(() => { + graph.getLayer('notes').set('visible', false); + }); + expect(initialHandler).not.toHaveBeenCalled(); + expect(nextHandler).toHaveBeenCalledTimes(1); + expect(nextHandler).toHaveBeenLastCalledWith( + expect.arrayContaining([{ id: 'notes', name: 'Updated', visible: false }]) + ); + }); +}); + + +function CommitLayerChange({ visible }: { readonly visible: boolean }) { + const { setLayer } = useGraph(); + useLayoutEffect(() => { setLayer('notes', { visible }); }, [setLayer, visible]); + return null; +} + +it('uses the replacement handler for a child layout-effect write in the same commit', () => { + const oldHandler = jest.fn(); + const nextHandler = jest.fn(); + const { rerender } = render( + + + + ); + oldHandler.mockClear(); + rerender( + + + + ); + expect(oldHandler).not.toHaveBeenCalled(); + expect(nextHandler).toHaveBeenCalledTimes(1); + expect(nextHandler).toHaveBeenLastCalledWith( + expect.arrayContaining([{ id: 'notes', visible: false }]) + ); +}); diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index a08a752b74..55dc5a09ad 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -1,11 +1,14 @@ import type { dia } from '@joint/core'; import React, { memo, useLayoutEffect, useRef } from 'react'; +import { useLatestRef } from '../../hooks/use-latest-ref'; +import { simpleScheduler } from '../../utils/scheduler'; import { useImperativeApi } from '../../hooks/use-imperative-api'; import { GraphStoreContext } from '../../context'; import { GraphStore } from '../../store'; import type { AutoSizeOrigin } from '../../store/measurement'; import type { OnIncrementalCellsChange } from '../../store/graph-projection'; import type { ElementJSONInit, LinkJSONInit, CellInput } from '../../types/cell.types'; +import type { LayerRecord } from '../../types/layer.types'; /** Cells array accepted by GraphProvider. */ type ProviderCells = ReadonlyArray< @@ -17,12 +20,15 @@ type ProviderCells = * instance, initial cells, or a controlled cells array) and subscribe to changes. * @template Element - Shape of the element cells stored in the graph. * @template Link - Shape of the link cells stored in the graph. + * @template LayerId - Union of the layer ids the diagram uses. Include the + * default `'cells'` layer: it always exists and `onLayersChange` reports it. * @expand * @group Types */ export interface GraphProviderProps< Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, + LayerId extends string = string, > { /** * Pre-existing JointJS graph instance to use. If omitted, GraphProvider @@ -39,6 +45,16 @@ export interface GraphProviderProps< * @default JointJS `shapes` plus the `@joint/react` cell models */ readonly cellNamespace?: unknown; + /** + * Layer namespace passed to `new dia.Graph`, keyed by layer `type`. Your + * entries are merged on top of the built-in, which maps the plain layer type + * to the `@joint/react` {@link LayerModel}. Declare a layer with a `type` you + * registered here to use your own `dia.GraphLayer` subclass, and register + * its view as `View` in the `` `layerViewNamespace` (JointJS + * resolves layer views by that name; {@link LayerView} is a good base). + * @default the `@joint/react` layer model + */ + readonly layerNamespace?: unknown; /** * Base model class used for every cell the graph constructs from JSON. Maps to * the (deprecated) `cellModel` option of `dia.Graph`; prefer `cellNamespace`, @@ -89,6 +105,36 @@ export interface GraphProviderProps< * @see {@link IncrementalCellsChange} */ readonly onIncrementalCellsChange?: OnIncrementalCellsChange; + + /** + * Layers to declare once, at mount, in paint order (index 0 is painted at the + * bottom). Cells join a layer through their `layer` field. The default + * `cells` layer always exists: omit it and it sits at the bottom, name it to + * position it. Later changes to this array are not applied; use `layers` + * for that, or `setLayers` / `setLayer` from {@link useGraph}. + * + * A cell must name a layer that exists — declared here, in `layers`, or + * added through the graph — or joint-core throws when the cell is synced. + * The membership field is always `layer`; a custom `config.layerAttribute` + * is not supported by the React records. + * @see {@link LayerRecord} + */ + readonly initialLayers?: ReadonlyArray>; + /** + * Controlled layers array, in paint order. Whenever this array changes the + * graph's layers are reconciled to match it — added, reordered, updated, and + * removed once empty; a layer that still holds cells is kept and a dev warning + * names them. A new array with the same content is diffed and leaves the graph + * untouched; passing the same reference does not re-run the diff. Pair it + * with `onLayersChange`. + */ + readonly layers?: ReadonlyArray>; + /** + * Fires after any layer change with the full, ordered layers array — + * including the default `cells` layer. Notification only; it writes nothing + * back into the graph. + */ + readonly onLayersChange?: (layers: ReadonlyArray>) => void; } /** @@ -118,13 +164,30 @@ function GraphBase(props: Readonly): React.React onCellsChange, graph, cellNamespace, + layerNamespace, cellModel, autoSizeOrigin, initialCells, cells, + initialLayers, + layers, + onLayersChange, } = props; const isControlled = !!cells; + const hasControlledLayers = !!layers; + const onLayersChangeRef = useLatestRef(onLayersChange); + const layersRef = useLatestRef(layers); + // Set for the duration of a React-origin layers apply so the subscription + // below can tell an echo from a graph-origin change. + const isApplyingLayersRef = useRef(false); + const lastAppliedCellsRef = useRef(undefined); + // Layers are re-reconciled only when their reference changes, so a drag frame + // in controlled mode does zero layer work. Cleared on a graph-origin change so + // a parent that ignores it still gets its array re-applied on the next commit. + const lastAppliedLayersRef = useRef(undefined); + // The store the two refs above describe; a new store invalidates them. + const appliedStoreRef = useRef | null>(null); // `initialCells` seed the graph once. A dev-server Fast Refresh re-runs the // mount effect below (destroying and re-creating the store) while this ref @@ -143,12 +206,46 @@ function GraphBase(props: Readonly): React.React new GraphStore({ graph, cellNamespace, + layerNamespace, cellModel, initialCells: shouldSeed ? (cells ?? initialCells ?? []) : undefined, + initialLayers: shouldSeed ? (layers ?? initialLayers) : undefined, autoSizeOrigin, }); + // Subscribe before the ready render exposes children: their layout effects + // run before this provider's effects and may already change the layers. + const { applyLayers, graphProjection } = graphStore; + let isActive = true; + // One closure per ownership lifetime lets the scheduler dedupe a burst. + const revert = () => { + const controlledLayers = layersRef.current; + if (!isActive || !controlledLayers) return; + isApplyingLayersRef.current = true; + try { + applyLayers(controlledLayers); + lastAppliedLayersRef.current = controlledLayers; + } finally { + isApplyingLayersRef.current = false; + } + }; + const unsubscribeLayers = graphProjection.layers.subscribe(() => { + // Parent-origin applies are echoes, not changes to report or revert. + if (isApplyingLayersRef.current) return; + lastAppliedLayersRef.current = undefined; + const handler = onLayersChangeRef.current; + if (handler) { + handler(graphProjection.layers.getSnapshot()); + return; + } + // Defer controlled reverts rather than re-entering the notification loop. + simpleScheduler(revert); + }); return { cleanup() { + // The provider owns the subscription even when it adopts a store. + // A queued revert must never mutate an external graph after teardown. + isActive = false; + unsubscribeLayers(); if (store) return; graphStore.destroy(!!graph); }, @@ -168,7 +265,16 @@ function GraphBase(props: Readonly): React.React // destroyed one (its state lands next render) while `ref` already holds the // new one: skip, the re-render wires the live store. if (!instance || instance !== ref.current) return; - const { setOnIncrementalCellsChange, applyControlled, graphProjection } = instance; + const { setOnIncrementalCellsChange, applyControlled, applyLayers, graphProjection } = instance; + // A re-created store has seen none of the controlled arrays: re-seed it in full. + if (appliedStoreRef.current !== instance) { + appliedStoreRef.current = instance; + lastAppliedCellsRef.current = undefined; + lastAppliedLayersRef.current = undefined; + } + /** The controlled layers, or `undefined` when the same reference was already applied. */ + const layersToApply = () => + hasControlledLayers && layers !== lastAppliedLayersRef.current ? layers : undefined; setOnIncrementalCellsChange((changeSet) => { onIncrementalCellsChange?.(changeSet); if (onCellsChange) { @@ -176,13 +282,51 @@ function GraphBase(props: Readonly): React.React return; } if (isControlled) { - applyControlled(cells); + // Same React-origin apply as the effect below: guarded so the parent's + // own layers do not echo, and recorded so the next commit skips them. + const nextLayers = layersToApply(); + isApplyingLayersRef.current = true; + applyControlled(cells, undefined, nextLayers); + isApplyingLayersRef.current = false; + if (nextLayers) lastAppliedLayersRef.current = nextLayers; } }); + // Controlled layers ride along with controlled cells in ONE commit: joint-core + // throws on a cell naming a layer that does not exist yet, and refuses to + // remove a layer until its cells are gone — so the two cannot be applied by + // separate effects. When the cells reference is unchanged nothing can have + // left a layer, so a layers-only change skips the O(n) cells diff. if (isControlled) { - applyControlled(cells ?? []); + const nextLayers = layersToApply(); + isApplyingLayersRef.current = true; + if (cells === lastAppliedCellsRef.current) { + if (nextLayers) applyLayers(nextLayers); + } else { + applyControlled(cells ?? [], undefined, nextLayers); + } + isApplyingLayersRef.current = false; + lastAppliedCellsRef.current = cells; + lastAppliedLayersRef.current = layers; } - }, [instance, ref, onIncrementalCellsChange, onCellsChange, isControlled, cells]); + }, [ + instance, + onIncrementalCellsChange, + onCellsChange, + ref, + isControlled, + hasControlledLayers, + cells, + layers, + ]); + + // With uncontrolled cells nothing else applies controlled layers, so do it here. + useLayoutEffect(() => { + if (!instance || instance !== ref.current || isControlled || !hasControlledLayers) return; + isApplyingLayersRef.current = true; + instance.applyLayers(layers); + isApplyingLayersRef.current = false; + lastAppliedLayersRef.current = layers; + }, [instance, ref, isControlled, hasControlledLayers, layers]); if (!instance) { return null; @@ -237,12 +381,29 @@ function GraphBase(props: Readonly): React.React * * * ``` + * @example Layers — declare paint order once, or control it from state + * ```tsx + * import { useState } from 'react'; + * import { GraphProvider, Paper, type LayerRecord } from '@joint/react'; + * + * type LayerId = 'background' | 'cells' | 'notes'; + * const [layers, setLayers] = useState>>([ + * { id: 'background' }, + * { id: 'cells' }, + * { id: 'notes', visible: false }, + * ]); + * // Cells join a layer through their `layer` field: { id: 'n1', type: 'element', layer: 'notes' } + * + * + * + * ``` * @see {@link GraphProviderProps} for the full list of props. * @group Components */ export const GraphProvider = memo(GraphBase) as < Element extends ElementJSONInit = ElementJSONInit, Link extends LinkJSONInit = LinkJSONInit, + LayerId extends string = string, >( - props: GraphProviderProps + props: GraphProviderProps ) => ReturnType; diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 1dbddb76b9..16c8502e83 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -264,7 +264,9 @@ interface PaperSupportedOptions { readonly cellViewNamespace?: dia.Paper.Options['cellViewNamespace']; /** * Namespace of layer-view constructors used to resolve custom paper layers. - * @default JointJS built-in layer views + * Merged on top of the built-ins, which render plain graph layers through + * the `@joint/react` {@link LayerView}. + * @default JointJS built-in layer views plus the `@joint/react` layer view */ readonly layerViewNamespace?: dia.Paper.Options['layerViewNamespace']; /** diff --git a/packages/joint-react/src/components/paper/render-element/__tests__/paper-html-overlay.test.tsx b/packages/joint-react/src/components/paper/render-element/__tests__/paper-html-overlay.test.tsx index d2949d5d17..6b1d8f4614 100644 --- a/packages/joint-react/src/components/paper/render-element/__tests__/paper-html-overlay.test.tsx +++ b/packages/joint-react/src/components/paper/render-element/__tests__/paper-html-overlay.test.tsx @@ -1,12 +1,14 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; +import { useGraph } from '../../../../hooks/use-graph'; import { GraphProvider } from '../../../graph/graph-provider'; import { Paper } from '../../paper'; import { ELEMENT_MODEL_TYPE } from '../../../../mvc/element-model'; import type { CellRecord } from '../../../../types/cell.types'; +const OVERLAY_LAYERS = [{ id: 'notes', visible: false }]; const HTML_CELLS: readonly CellRecord[] = [ { id: 'a', @@ -76,3 +78,64 @@ describe('Paper with useHTMLOverlay', () => { }); }); }); + + +describe('HTML overlay layer visibility', () => { + it('hides and restores HTML content without unmounting or rendering it again', async () => { + let api: ReturnType | undefined; + const renderContent = jest.fn(() => ); + function CaptureGraph() { + api = useGraph(); + return null; + } + const { container } = render( + + + + + ); + await waitFor(() => expect(container.querySelector('div[model-id="a"]')).toBeTruthy()); + const wrapper = container.querySelector('div[model-id="a"]') as HTMLDivElement; + const button = wrapper.querySelector('button'); + const renders = renderContent.mock.calls.length; + act(() => api?.setLayer('cells', { visible: false })); + expect(wrapper.style.visibility).toBe('hidden'); + expect(wrapper.querySelector('button')).toBe(button); + expect(wrapper.style.width).toBe('60px'); + expect(renderContent).toHaveBeenCalledTimes(renders); + act(() => api?.setLayer('cells', { visible: true })); + expect(wrapper.style.visibility).not.toBe('hidden'); + expect(wrapper.querySelector('button')).toBe(button); + expect(renderContent).toHaveBeenCalledTimes(renders); + }); + + it('tracks layer moves and a changed default layer, and ignores unrelated layer edits', async () => { + let api: ReturnType | undefined; + const renderContent = jest.fn(() => Content); + function CaptureGraph() { + api = useGraph(); + return null; + } + const { container } = render( + + + + + ); + await waitFor(() => expect(container.querySelector('div[model-id="a"]')).toBeTruthy()); + const wrapper = container.querySelector('div[model-id="a"]') as HTMLDivElement; + const initialRenders = renderContent.mock.calls.length; + act(() => api?.setLayer('notes', { name: 'Annotations' })); + expect(wrapper.style.visibility).not.toBe('hidden'); + expect(renderContent).toHaveBeenCalledTimes(initialRenders); + + await act(async () => { api?.graph.getCell('a').layer('notes'); }); + expect(wrapper.style.visibility).toBe('hidden'); + await act(async () => { api?.graph.getCell('a').layer(null); }); + expect(wrapper.style.visibility).not.toBe('hidden'); + await act(async () => { api?.graph.setDefaultLayer('notes'); }); + expect(wrapper.style.visibility).toBe('hidden'); + expect(container.querySelector('div[model-id="a"]')).toBe(wrapper); + }); + +}); diff --git a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 934e8a2f7f..fb8531fe5c 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useMemo, + useRef, type ComponentType, type CSSProperties, } from 'react'; @@ -91,6 +92,25 @@ function HTMLElementItemComponent(props: ElementItemProps) { // Subscribe to the whole element record (missing-tolerant — the portal can // mount before the record lands in the store, and briefly after removal). const element = useCells(id, (cell) => cell); + const graphStore = useGraphStore(); + const wrapperRef = useRef(null); + const layerId = element?.layer; + + useLayoutEffect(() => { + const { layers } = graphStore.graphProjection; + // HTML portals live outside the SVG layer group. Mirror its visibility + // without rendering user content again or detaching its measurable DOM. + const syncVisibility = () => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const currentLayerId = layerId ?? graphStore.graph.getDefaultLayer().id; + const visibility = layers.getLayer(currentLayerId)?.visible === false ? 'hidden' : ''; + if (wrapper.style.visibility !== visibility) wrapper.style.visibility = visibility; + }; + const unsubscribe = layers.subscribe(syncVisibility); + syncVisibility(); + return unsubscribe; + }, [graphStore, layerId, portalElement]); const x = element?.position?.x ?? 0; const y = element?.position?.y ?? 0; @@ -119,7 +139,7 @@ function HTMLElementItemComponent(props: ElementItemProps) { const dataProps = element?.data ?? {}; return createPortal( -
+
, 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, }, },