From 37896063eab51f559b62a0b46b9722c11ca764f2 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 10 Sep 2026 18:44:01 +0800 Subject: [PATCH 01/10] feat(layers): introduce layer management in JointJS integration - Added support for layer visibility and management in the PaperView class. - Implemented layer reconciliation and removal of empty layers in the graph store. - Created utility functions for reading and updating layer records. - Enhanced the GraphProvider to accept initial layers and manage layer updates. - Introduced hooks for layer visibility control and layer management in the React components. - Updated example stories to demonstrate the new layer functionality. --- .changeset/layers-graph-provider.md | 5 + .changeset/layers-record.md | 5 + .changeset/layers-setters.md | 5 + .changeset/layers-use-layer.md | 5 + .changeset/layers-use-layers.md | 5 + .../src/components/graph/graph-provider.tsx | 147 +++++- .../hooks/__tests__/use-layer.type.test.ts | 92 ++++ .../src/hooks/__tests__/use-layers.test.tsx | 425 ++++++++++++++++++ packages/joint-react/src/hooks/index.ts | 2 + packages/joint-react/src/hooks/use-graph.ts | 32 ++ packages/joint-react/src/hooks/use-layer.ts | 89 ++++ packages/joint-react/src/hooks/use-layers.ts | 40 ++ packages/joint-react/src/index.ts | 13 + .../__tests__/paper-layer-visibility.test.tsx | 102 +++++ packages/joint-react/src/mvc/paper.ts | 61 ++- .../src/store/__tests__/layers.test.ts | 204 +++++++++ .../joint-react/src/store/graph-changes.ts | 109 +++-- .../joint-react/src/store/graph-projection.ts | 59 +++ packages/joint-react/src/store/graph-store.ts | 42 +- packages/joint-react/src/store/layers.ts | 208 +++++++++ packages/joint-react/src/types/layer.types.ts | 65 +++ .../joint-react/src/utils/dev-warnings.ts | 22 + .../stories/examples/layers/code.tsx | 127 +++--- .../stories/examples/layers/story.tsx | 4 +- 24 files changed, 1758 insertions(+), 110 deletions(-) create mode 100644 .changeset/layers-graph-provider.md create mode 100644 .changeset/layers-record.md create mode 100644 .changeset/layers-setters.md create mode 100644 .changeset/layers-use-layer.md create mode 100644 .changeset/layers-use-layers.md create mode 100644 packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-layers.test.tsx create mode 100644 packages/joint-react/src/hooks/use-layer.ts create mode 100644 packages/joint-react/src/hooks/use-layers.ts create mode 100644 packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx create mode 100644 packages/joint-react/src/store/__tests__/layers.test.ts create mode 100644 packages/joint-react/src/store/layers.ts create mode 100644 packages/joint-react/src/types/layer.types.ts diff --git a/.changeset/layers-graph-provider.md b/.changeset/layers-graph-provider.md new file mode 100644 index 0000000000..8afafd2b83 --- /dev/null +++ b/.changeset/layers-graph-provider.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + + - add `initialLayers`, and controlled `layers` + `onLayersChange`, to declare paint-ordered layers diff --git a/.changeset/layers-record.md b/.changeset/layers-record.md new file mode 100644 index 0000000000..ad8be1d727 --- /dev/null +++ b/.changeset/layers-record.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +LayerRecord - add the layer record type with a `visible` attribute that hides a layer's group without unmounting its cells diff --git a/.changeset/layers-setters.md b/.changeset/layers-setters.md new file mode 100644 index 0000000000..dd495dcc6d --- /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 imperatively diff --git a/.changeset/layers-use-layer.md b/.changeset/layers-use-layer.md new file mode 100644 index 0000000000..c955f97e47 --- /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, generic over the layer id union 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/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 10a305d31c..d2521d3045 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 } from 'react'; +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/graph-store'; 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 @@ -89,6 +95,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; } /** @@ -122,9 +158,23 @@ function GraphBase(props: Readonly): React.React 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); const { isReady, ref } = useImperativeApi>( { @@ -136,6 +186,7 @@ function GraphBase(props: Readonly): React.React cellNamespace, cellModel, initialCells: cells ?? initialCells ?? [], + initialLayers: layers ?? initialLayers, autoSizeOrigin, }); return { @@ -153,6 +204,9 @@ function GraphBase(props: Readonly): React.React useLayoutEffect(() => { if (!isReady) return; const { setOnIncrementalCellsChange, applyControlled, graphProjection } = ref.current; + /** 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) { @@ -160,13 +214,79 @@ function GraphBase(props: Readonly): React.React return; } if (isControlled) { - applyControlled(cells); + applyControlled(cells, undefined, layersToApply()); } }); + // 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) ref.current.applyLayers(nextLayers); + } else { + applyControlled(cells ?? [], undefined, nextLayers); + } + isApplyingLayersRef.current = false; + lastAppliedCellsRef.current = cells; + lastAppliedLayersRef.current = layers; } - }, [isReady, onIncrementalCellsChange, onCellsChange, ref, isControlled, cells]); + }, [ + isReady, + onIncrementalCellsChange, + onCellsChange, + ref, + isControlled, + hasControlledLayers, + cells, + layers, + ]); + + // With uncontrolled cells nothing else applies controlled layers, so do it here. + useLayoutEffect(() => { + if (!isReady || isControlled || !hasControlledLayers) return; + isApplyingLayersRef.current = true; + ref.current.applyLayers(layers); + isApplyingLayersRef.current = false; + lastAppliedLayersRef.current = layers; + }, [isReady, ref, isControlled, hasControlledLayers, layers]); + + // Subscribe once; the handler and the controlled array are read through refs + // so an inline `onLayersChange` never re-subscribes. + useLayoutEffect(() => { + if (!isReady) return; + const { applyLayers, graphProjection } = ref.current; + // One closure for the life of the subscription: `simpleScheduler` dedupes + // by identity, so a burst of graph-origin events reverts once, not per event. + const revert = () => { + const controlledLayers = layersRef.current; + if (!controlledLayers) return; + // A React-origin apply like the effects': its store re-read is an echo. + isApplyingLayersRef.current = true; + applyLayers(controlledLayers); + isApplyingLayersRef.current = false; + }; + return graphProjection.layers.subscribe(() => { + // A React-origin apply re-reads the store synchronously; that is the + // parent's own array coming back, not a change to report or revert. + if (isApplyingLayersRef.current) return; + // A graph-origin change: whatever the parent decides, its array must be + // reconciled again on the next commit even if the reference is unchanged. + lastAppliedLayersRef.current = undefined; + const handler = onLayersChangeRef.current; + if (handler) { + handler(graphProjection.layers.getSnapshot()); + return; + } + // Controlled without a change handler: the parent's array is the truth, + // so an imperative change is reverted — same contract as `cells`. Deferred + // so the revert does not re-enter the notification loop that is running. + simpleScheduler(revert); + }); + }, [isReady, ref, layersRef, onLayersChangeRef]); if (!isReady) { return null; @@ -221,12 +341,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/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..c93a3863fc --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts @@ -0,0 +1,92 @@ +/* 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' }; + + // 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, 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..e0161566e1 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx @@ -0,0 +1,425 @@ +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.length).toBe(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.length).toBe(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.length).toBe(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']); + }); +}); 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-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 9ae4148f34..eb4c02e8ad 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 diff --git a/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx b/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx new file mode 100644 index 0000000000..425e56c563 --- /dev/null +++ b/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +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 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(); + }); + +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 = () => ; + +function makeWrapper(id: string) { + return paperRenderElementWrapper({ + graphProviderProps: { initialLayers: LAYERS, initialCells: CELLS }, + paperProps: { id, renderElement: renderRect }, + }); +} + +/** Mounts a paper with the layers fixture and resolves once the paper exists. */ +async function mountPaper(id: string) { + const { result } = renderHook( + () => ({ paperApi: usePaper(id), api: useGraph(), store: useGraphStore() }), + { wrapper: makeWrapper(id) } + ); + await waitFor(() => expect(result.current.paperApi.paper).not.toBeNull()); + return { result, paper: result.current.paperApi.paper! }; +} + +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.display).toBe('none'); + expect(paper.getLayerView('overlay').el.style.display).toBe(''); + // Hidden means not painted, not unmounted: the cell view is still there. + expect(paper.findViewByModel('n')).toBeDefined(); + }); + + it('toggles display 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.display).toBe(''); + expect(paper.findViewByModel('n')).toBe(viewBefore); + + await commit(() => { + result.current.api.setLayer('notes', { visible: false }); + }); + expect(paper.getLayerView('notes').el.style.display).toBe('none'); + }); + + 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.display).toBe('none'); + }); +}); + +// Regression: core's Paper defers a layer view removal but early-returns on a +// re-add while that removal is still pending, so dropping and re-declaring a +// layer within one frame orphaned it — 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('keeps a live layer view 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(); + expect(result.current.store.graph.hasLayer('overlay')).toBe(true); + expect(paper.hasLayerView('overlay')).toBe(true); + + 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/paper.ts b/packages/joint-react/src/mvc/paper.ts index 21a61b2651..e5605eff9d 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -1,4 +1,4 @@ -import type { dia, mvc } from '@joint/core'; +import { 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'; @@ -34,6 +34,65 @@ export class PaperView extends Paper { this.onViewMountChange = onViewMountChange ?? noopViewMountChange; this.shouldPreserveHostElementOnRemove = !!paperOptions.el; this.portalSelector = portalSelector; + // A layer's `visible` attribute is one `display` toggle on its group, so + // hiding a layer never unmounts or re-evaluates its cell views. + this.listenTo(this.model, 'layer:change:visible', this.applyLayerVisibility); + } + + /** + * Mirrors a layer's `visible` attribute onto its group element. + * + * A prototype method, not a class field: `dia.Paper`'s constructor renders + * the layer views — and so calls `insertLayerView` below — before `super()` + * returns, when class fields do not exist yet. + * @param layer - The graph layer whose visibility changed. + */ + private applyLayerVisibility(layer: dia.GraphLayer): void { + if (!this.hasLayerView(layer.id)) return; + this.getLayerView(layer.id).el.style.display = layer.get('visible') === false ? 'none' : ''; + } + + /** + * Keeps a layer view alive when its layer is dropped and re-declared within + * one frame. Core defers the view's removal to the next update pass but + * early-returns on a re-add while that removal is still pending, which + * orphans the layer — the next cell placed on it would throw `Unknown layer + * view` from the async update loop. Requesting an insert cancels the pending + * removal (core clears `FLAG_REMOVE` when `FLAG_INSERT` arrives), and the + * sort pass puts the kept view back in paint order. + * @param layer - The re-added graph layer. + * @param collection - The graph's layer collection. + * @param opt - Options of the originating `addLayer` call. + */ + protected onGraphLayerAdd( + layer: dia.GraphLayer, + collection: mvc.Collection, + opt: dia.Graph.Options + ): void { + if (!this.hasLayerView(layer.id)) { + super.onGraphLayerAdd(layer, collection, opt); + return; + } + const layerView = this.getLayerView(layer.id); + // `FLAG_INSERT` is a paper constant core's typings omit; widen and narrow. + const { FLAG_INSERT }: { readonly FLAG_INSERT?: unknown } = this; + if (typeof FLAG_INSERT !== 'number') return; + this.requestViewUpdate(layerView, FLAG_INSERT, layerView.UPDATE_PRIORITY, opt); + this.onGraphLayerCollectionSort(collection); + } + + /** + * Every layer group passes through here — initial render, a late + * `addLayer`, and each reorder — so the visibility is applied once, here. + * @param layerView - The layer view being inserted. + * @param beforeLayerView - The sibling to insert before, if any. + */ + protected insertLayerView(layerView: dia.LayerView, beforeLayerView?: dia.LayerView): void { + super.insertLayerView(layerView, beforeLayerView); + // The base `LayerView` types `model` as `undefined`; only a graph layer's + // view carries a `GraphLayer`, so widen and narrow at runtime, not by cast. + const { model }: { readonly model?: unknown } = layerView; + if (model instanceof dia.GraphLayer) this.applyLayerVisibility(model); } /** 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..d666c6d05e --- /dev/null +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -0,0 +1,204 @@ +import { dia } from '@joint/core'; +import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; +import { 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, + }); + }); + + 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(); + }); +}); diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index 22a6d2d1e1..da17628a29 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 { reconcileLayers, removeEmptyLayers } 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 (coalesced) after any graph-origin layer add / remove / change / reorder. */ + readonly onLayersChange?: () => void; readonly onElementsSizeChange?: (id: CellId, size: { width: number; height: number }) => void; } @@ -71,6 +82,16 @@ 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; +} + /** * 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. @@ -78,12 +99,11 @@ interface JointJSEventOptions { * @returns Controller exposing updateGraph and destroy. */ export function graphChanges(options: Options) { - const { graph, onElementsSizeChange } = options; + const { graph, onElementsSizeChange, onLayersChange } = options; const changes = new Map>(); let batchDepth = 0; let deferDepth = 0; - let isSyncedWithReact = true; /** True while a {@link DEFER_COMMIT_BATCH_OPTION} batch is open — commits are deferred. */ function isDeferring() { @@ -127,6 +147,30 @@ export function graphChanges(options: Options) { onChanges({ changes, isInsideBatch: isInsideBatch(), deferCommit: isDeferring() }); } + /** + * 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; + } + const controller = new mvc.Listener(); controller.listenTo( @@ -174,7 +218,6 @@ export function graphChanges(options: Options) { 'reset', (collection: mvc.Collection, eventOptions: JointJSEventOptions = {}) => { if (eventOptions.isUpdateFromReact) return; - isSyncedWithReact = true; changes.clear(); for (const cell of collection.models) { changes.set(cell.id, { type: 'add', data: cell }); @@ -196,6 +239,9 @@ export function graphChanges(options: Options) { deferCommit: isDeferring(), isReset: true, }); + // `fromJSON` resets layers too, and joint-core does not forward + // `layers:reset` to the graph — the cell `reset` is the only signal. + onLayersChange?.(); } ); @@ -203,6 +249,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 + ); + } + controller.listenTo(graph, 'change:size', (cell: dia.Cell, newSize: dia.Size) => { if (!onElementsSizeChange) return; onElementsSizeChange(cell.id, newSize); @@ -234,33 +298,20 @@ 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[] = []; - - 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.startBatch('updateFromReact'); + const { cells, layers, flag, metadata } = update; // Spread metadata first so the required sync flags always win. - graph.syncCells(cellsToSync, { - ...metadata, - remove: true, - isUpdateFromReact: flag === 'updateFromReact', - }); - graph.stopBatch('updateFromReact'); + const syncOptions = { ...metadata, isUpdateFromReact: flag === 'updateFromReact' }; + + // Tagged batch: every event inside it carries the React-origin flag, so + // `batch:stop` schedules no redundant (empty) change pass for it. + graph.startBatch('updateFromReact', syncOptions); + // 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. + if (layers) removeEmptyLayers(graph, layers, syncOptions); + graph.stopBatch('updateFromReact', syncOptions); return { cellIds }; }, diff --git a/packages/joint-react/src/store/graph-projection.ts b/packages/joint-react/src/store/graph-projection.ts index 0a52bf5c4a..fc051c3c33 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 @@ -60,6 +84,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 @@ -124,6 +178,7 @@ export function graphProjection< const graphChangesController = graphChanges({ graph, onElementsSizeChange, + onLayersChange: syncLayersFromGraph, onChanges: ({ changes, isInsideBatch, deferCommit, isReset }) => { for (const [id, change] of changes) { const { data, type } = change; @@ -228,10 +283,14 @@ export function graphProjection< return { cells: asReadonlyContainer(cells), + layers, syncFromGraph, + syncLayersFromGraph, updateGraph(update: UpdateGraphOptions) { const { cellIds } = graphChangesController.updateGraph(update); if (update.flag !== 'updateFromReact') return; + // React-origin layer events are ignored by graphChanges, so re-read now. + if (update.layers) syncLayersFromGraph(); if (!update.cells) return; // React-origin graph events are ignored by graphChanges (isUpdateFromReact), diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 176a27a8a0..a5f7b9e933 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -3,6 +3,10 @@ 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 { reconcileLayers, withLayerPatch } from './layers'; +import { isUpdater } from '../utils/is'; +import type { ArrayUpdate } from './state-container'; import { createElementsSizeObserver, type GraphStoreObserver, @@ -97,6 +101,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[]; } /** @@ -126,6 +132,7 @@ export class GraphStore< graph, autoSizeOrigin = 'top-left', initialCells, + initialLayers, } = config; this.autoSizeOrigin = autoSizeOrigin; @@ -226,6 +233,14 @@ export class GraphStore< }, }); + // 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. + 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 @@ -264,12 +279,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..a0dd92ea24 --- /dev/null +++ b/packages/joint-react/src/store/layers.ts @@ -0,0 +1,208 @@ +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'; + +/** `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']); + +/** + * Whether a layer attribute belongs in its record: everything except the + * default `type`, which is joint-core's own 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 === '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; +} + +/** + * 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 stays + * at the bottom, 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 { + if (next.length === 0) return; + const defaultId = graph.getDefaultLayer().id; + const target = next.some((record) => record.id === defaultId) + ? next + : [{ id: defaultId }, ...next]; + + for (const record of target) { + if (graph.hasLayer(record.id)) { + updateLayerAttributes(graph.getLayer(record.id), record, options); + } else { + graph.addLayer({ ...record }, options); + } + } + + // 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 }); + order.splice(order.indexOf(record.id), 1); + order.splice(index, 0, record.id); + } +} + +/** + * 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); + // Unset only attributes the record dropped that the layer class does not + // provide as a default — a custom `dia.GraphLayer` subclass keeps its own. + // `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) continue; + 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. + * @internal + */ +export function removeEmptyLayers( + graph: dia.Graph, + next: readonly LayerRecord[], + options: Record = {} +): void { + const keep = new Set(); + for (const record of next) keep.add(record.id); + const defaultId = graph.getDefaultLayer().id; + for (const layer of graph.getLayers()) { + if (layer.id === defaultId || keep.has(layer.id)) continue; + if (layer.cellCollection.length > 0) { + warnLayerNotEmpty(layer.id, () => layer.getCells().map((cell) => cell.id)); + continue; + } + graph.removeLayer(layer.id, options); + forgetLayerNotEmptyWarning(layer.id); + } +} + +/** + * 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..4c7c86b578 --- /dev/null +++ b/packages/joint-react/src/types/layer.types.ts @@ -0,0 +1,65 @@ +/** + * 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. + * @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 + * only sets `display: none` on the layer group, so toggling is O(1). + * + * 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. Two + * consequences to plan for — a link in a visible layer anchored to a port of + * an element in a hidden layer cannot measure that port and may misroute, + * and 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 { + /** 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..8ba43ab45f 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 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, }, }, From 16a48eb2dd400d59dbf2c83f153e06e9d7c5137a Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 10 Sep 2026 18:45:09 +0800 Subject: [PATCH 02/10] feat(layers): add warnings for non-empty layers during removal --- .../joint-react/src/utils/dev-warnings.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/joint-react/src/utils/dev-warnings.ts b/packages/joint-react/src/utils/dev-warnings.ts index 8ba43ab45f..1ac1ba019a 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -147,3 +147,34 @@ 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, 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}`); +} From 3ab30ba0e63d2f7a74a27df9fbfe799203f0c528 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 17 Sep 2026 16:28:06 +0700 Subject: [PATCH 03/10] feat(layers): refine layer management and visibility handling in GraphProvider and Paper --- .changeset/layers-graph-provider.md | 2 +- .changeset/layers-record.md | 2 +- .changeset/layers-setters.md | 2 +- .changeset/layers-use-layer.md | 2 +- .changeset/paper-layer-readd.md | 5 +++ packages/joint-core/src/dia/Paper.mjs | 26 ++++++++++++- packages/joint-core/test/jointjs/layers.js | 38 +++++++++++++++++++ .../__tests__/paper-layer-visibility.test.tsx | 13 ++++--- packages/joint-react/src/mvc/paper.ts | 29 -------------- .../src/store/__tests__/layers.test.ts | 22 +++++++++++ packages/joint-react/src/store/layers.ts | 11 ++++-- 11 files changed, 109 insertions(+), 43 deletions(-) create mode 100644 .changeset/paper-layer-readd.md diff --git a/.changeset/layers-graph-provider.md b/.changeset/layers-graph-provider.md index 8afafd2b83..ae4bdb49bf 100644 --- a/.changeset/layers-graph-provider.md +++ b/.changeset/layers-graph-provider.md @@ -2,4 +2,4 @@ "@joint/react": minor --- - - add `initialLayers`, and controlled `layers` + `onLayersChange`, to declare paint-ordered layers + - add `initialLayers`, controlled `layers` and `onLayersChange` diff --git a/.changeset/layers-record.md b/.changeset/layers-record.md index ad8be1d727..b9fac051de 100644 --- a/.changeset/layers-record.md +++ b/.changeset/layers-record.md @@ -2,4 +2,4 @@ "@joint/react": minor --- -LayerRecord - add the layer record type with a `visible` attribute that hides a layer's group without unmounting its cells +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 index dd495dcc6d..9362e94d15 100644 --- a/.changeset/layers-setters.md +++ b/.changeset/layers-setters.md @@ -2,4 +2,4 @@ "@joint/react": minor --- -useGraph - add `setLayers()` and `setLayer()` to declare, reorder, update, and hide layers imperatively +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 index c955f97e47..6449e9a998 100644 --- a/.changeset/layers-use-layer.md +++ b/.changeset/layers-use-layer.md @@ -2,4 +2,4 @@ "@joint/react": minor --- -useLayer - add a hook reading one layer by id with an optional selector, generic over the layer id union +useLayer - add a hook reading one layer by id with an optional selector diff --git a/.changeset/paper-layer-readd.md b/.changeset/paper-layer-readd.md new file mode 100644 index 0000000000..2fab661325 --- /dev/null +++ b/.changeset/paper-layer-readd.md @@ -0,0 +1,5 @@ +--- +"@joint/core": patch +--- + +dia.Paper - fix a layer re-added while its view removal is pending being left without a view diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index a96ce73595..a9d0247428 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -792,7 +792,17 @@ export const Paper = View.extend({ * @description When a new layer is added to the graph, we create a new layer view **/ onGraphLayerAdd: function(layer, _, opt) { - if (this.hasLayerView(layer.id)) return; + if (this.hasLayerView(layer.id)) { + const existingLayerView = this.getLayerView(layer.id); + if (existingLayerView.model === layer) return; + // A layer with this id was removed within the current update cycle and + // the removal of its view is still pending (`onGraphLayerRemove` defers + // it so that the cell views leave first). The new layer would be left + // without a view once that removal runs, so drop the pending removal + // together with the stale view now and render a view for the new layer. + this._dumpLayerViewUpdate(existingLayerView); + this._removeLayerView(existingLayerView); + } const layerView = this.createLayerView({ id: layer.id, @@ -1020,6 +1030,20 @@ export const Paper = View.extend({ return this.getLayerView(layerId).el; }, + /** + * @protected + * @description Discards the update scheduled for the given layer view, if any. + * @param {dia.LayerView} layerView - The layer view whose pending update is dropped. + */ + _dumpLayerViewUpdate(layerView) { + const { _updates: updates } = this; + const { cid, UPDATE_PRIORITY } = layerView; + const priorityUpdates = updates.priorities[UPDATE_PRIORITY]; + if (!priorityUpdates || !(cid in priorityUpdates)) return; + delete priorityUpdates[cid]; + updates.count--; + }, + /** * @protected * @description Removes the given layer view from the paper. diff --git a/packages/joint-core/test/jointjs/layers.js b/packages/joint-core/test/jointjs/layers.js index 207a910c7c..8e16cb4f7d 100644 --- a/packages/joint-core/test/jointjs/layers.js +++ b/packages/joint-core/test/jointjs/layers.js @@ -496,6 +496,44 @@ QUnit.module('Layers', function(hooks) { assert.ok(afterPaperRefSetSpy.calledOnce, 'afterPaperReferenceSet should still be called only once'); }); + QUnit.test('re-adding a layer while its view removal is pending (async paper)', (assert) => { + const paperEl = document.createElement('div'); + fixtures.getElement().appendChild(paperEl); + const graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); + const paper = new joint.dia.Paper({ + el: paperEl, + model: graph, + cellViewNamespace: joint.shapes, + async: true, + autoFreeze: false + }); + + graph.addLayer({ id: 'layer1' }); + paper.updateViews(); + // The view removal is deferred to the next update; the re-add lands before it. + graph.removeLayer('layer1'); + graph.addLayer({ id: 'layer1' }); + paper.updateViews(); + + assert.ok(paper.hasLayerView('layer1'), 'The re-added layer has a view after the update'); + assert.equal(paper.getLayerView('layer1').model, graph.getLayer('layer1'), 'The view is bound to the re-added layer'); + + const rect1 = new joint.shapes.standard.Rectangle({ id: 'rect1', layer: 'layer1', z: 2 }); + const rect2 = new joint.shapes.standard.Rectangle({ id: 'rect2', layer: 'layer1', z: 1 }); + graph.addCells([rect1, rect2]); + paper.updateViews(); + const layerViewEl = paper.getLayerView('layer1').el; + assert.ok(layerViewEl.contains(paper.findViewByModel(rect1).el), 'A cell added to the re-added layer renders in its view'); + + // The view listens to the re-added layer: a z change re-sorts its cell views. + rect2.set('z', 3); + paper.updateViews(); + const order = Array.from(layerViewEl.children).map((node) => node.getAttribute('model-id')); + assert.deepEqual(order, ['rect1', 'rect2'], 'A later z change re-sorts the cell views'); + + paper.remove(); + }); + QUnit.test('removing layer with cells', (assert) => { const layer1 = new joint.dia.GraphLayer({ id: 'layer1' }); this.graph.addLayer(layer1); diff --git a/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx b/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx index 425e56c563..2fd80b0dc1 100644 --- a/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx +++ b/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx @@ -75,12 +75,13 @@ describe('layer visibility on ', () => { }); }); -// Regression: core's Paper defers a layer view removal but early-returns on a -// re-add while that removal is still pending, so dropping and re-declaring a -// layer within one frame orphaned it — the next cell placed on it threw +// 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('keeps a live layer view and renders a cell added to it afterwards', async () => { + 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(() => { @@ -88,8 +89,10 @@ describe('re-declaring a layer within one frame', () => { result.current.api.setLayers([{ id: 'cells' }, { id: 'notes' }, { id: 'overlay' }]); }); paper.updateViews(); - expect(result.current.store.graph.hasLayer('overlay')).toBe(true); + 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' }); diff --git a/packages/joint-react/src/mvc/paper.ts b/packages/joint-react/src/mvc/paper.ts index e5605eff9d..181aa57172 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -52,35 +52,6 @@ export class PaperView extends Paper { this.getLayerView(layer.id).el.style.display = layer.get('visible') === false ? 'none' : ''; } - /** - * Keeps a layer view alive when its layer is dropped and re-declared within - * one frame. Core defers the view's removal to the next update pass but - * early-returns on a re-add while that removal is still pending, which - * orphans the layer — the next cell placed on it would throw `Unknown layer - * view` from the async update loop. Requesting an insert cancels the pending - * removal (core clears `FLAG_REMOVE` when `FLAG_INSERT` arrives), and the - * sort pass puts the kept view back in paint order. - * @param layer - The re-added graph layer. - * @param collection - The graph's layer collection. - * @param opt - Options of the originating `addLayer` call. - */ - protected onGraphLayerAdd( - layer: dia.GraphLayer, - collection: mvc.Collection, - opt: dia.Graph.Options - ): void { - if (!this.hasLayerView(layer.id)) { - super.onGraphLayerAdd(layer, collection, opt); - return; - } - const layerView = this.getLayerView(layer.id); - // `FLAG_INSERT` is a paper constant core's typings omit; widen and narrow. - const { FLAG_INSERT }: { readonly FLAG_INSERT?: unknown } = this; - if (typeof FLAG_INSERT !== 'number') return; - this.requestViewUpdate(layerView, FLAG_INSERT, layerView.UPDATE_PRIORITY, opt); - this.onGraphLayerCollectionSort(collection); - } - /** * Every layer group passes through here — initial render, a late * `addLayer`, and each reorder — so the visibility is applied once, here. diff --git a/packages/joint-react/src/store/__tests__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts index d666c6d05e..c2e773923d 100644 --- a/packages/joint-react/src/store/__tests__/layers.test.ts +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -144,6 +144,28 @@ describe('reconcileLayers', () => { }); }); + // 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); diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index a0dd92ea24..0532afcb5f 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -87,8 +87,9 @@ export function readLayerRecords( * {@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 stays - * at the bottom, name it to position it. + * 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). @@ -99,7 +100,6 @@ export function reconcileLayers( next: readonly LayerRecord[], options: Record = {} ): void { - if (next.length === 0) return; const defaultId = graph.getDefaultLayer().id; const target = next.some((record) => record.id === defaultId) ? next @@ -109,7 +109,10 @@ export function reconcileLayers( if (graph.hasLayer(record.id)) { updateLayerAttributes(graph.getLayer(record.id), record, options); } else { - graph.addLayer({ ...record }, options); + // `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); } } From 7ac6c62c52fba24d48379e7e9f4038621ecbc964 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 17 Sep 2026 17:02:22 +0700 Subject: [PATCH 04/10] feat(layers): add layer existence checks and improve attribute handling in graph updates --- .../src/store/__tests__/graph-changes.test.ts | 40 +++++++++++++++++++ .../src/store/__tests__/layers.test.ts | 20 ++++++++++ .../joint-react/src/store/graph-changes.ts | 28 +++++++++++++ packages/joint-react/src/store/layers.ts | 14 ++++--- 4 files changed, 97 insertions(+), 5 deletions(-) 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 051ba6fe03..400809e2d9 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -52,6 +52,46 @@ 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 }); + }); + + 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__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts index c2e773923d..468503a445 100644 --- a/packages/joint-react/src/store/__tests__/layers.test.ts +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -144,6 +144,26 @@ describe('reconcileLayers', () => { }); }); + // 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', () => { diff --git a/packages/joint-react/src/store/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index da17628a29..893b43ca29 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -92,6 +92,29 @@ 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. @@ -304,6 +327,11 @@ export function graphChanges(options: Options) { // 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 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 (cells) assertLayersExist(graph, cells, layers); + graph.startBatch('updateFromReact', syncOptions); // Layers first: a cell may name a layer declared in this same commit. if (layers) reconcileLayers(graph, layers, syncOptions); diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index 0532afcb5f..e44af644b0 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -147,15 +147,19 @@ function updateLayerAttributes( if (!RESERVED_KEYS.has(key)) attributes[key] = record[key]; } layer.set(attributes, options); - // Unset only attributes the record dropped that the layer class does not - // provide as a default — a custom `dia.GraphLayer` subclass keeps its own. - // `defaults()` allocates; fetch it only once an attribute is actually missing. + // 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) continue; - layer.unset(key, options); + if (key in classDefaults) { + layer.set(key, classDefaults[key], options); + } else { + layer.unset(key, options); + } } } From b7e61ae7b630ed23927c43b25f6909d244f53ea7 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 22 Sep 2026 19:15:19 +0700 Subject: [PATCH 05/10] feat(layers): add validation for layer records and improve patch handling --- .../hooks/__tests__/use-layer.type.test.ts | 7 +++- .../src/hooks/__tests__/use-layers.test.tsx | 23 +++++++++++ .../src/store/__tests__/graph-changes.test.ts | 22 +++++++++++ .../src/store/__tests__/layers.test.ts | 34 ++++++++++++++++- .../joint-react/src/store/graph-changes.ts | 19 ++++++---- packages/joint-react/src/store/graph-store.ts | 3 +- packages/joint-react/src/store/layers.ts | 38 ++++++++++++++++++- packages/joint-react/src/types/layer.types.ts | 4 ++ .../joint-react/src/utils/dev-warnings.ts | 3 +- 9 files changed, 141 insertions(+), 12 deletions(-) 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 index c93a3863fc..d664947790 100644 --- a/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts @@ -74,6 +74,11 @@ function typeChecks() { 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 = { @@ -82,7 +87,7 @@ function typeChecks() { }; // @ts-expect-error -- 'nope' is not a DiagramLayerId const badControlled: GraphProviderProps = { layers: [{ id: 'nope' }] }; - return [bad, badPatch, controlled, badControlled]; + return [bad, badPatch, renamePatch, defaultPatch, controlled, badControlled]; } describe('useLayer types', () => { diff --git a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx index e0161566e1..30d6d0a973 100644 --- a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx @@ -423,3 +423,26 @@ describe('apply order (React way of core\'s throw cases)', () => { 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'); + }); +}); 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 400809e2d9..74739d1974 100644 --- a/packages/joint-react/src/store/__tests__/graph-changes.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-changes.test.ts @@ -81,6 +81,28 @@ describe('updateGraph — a cell naming an undeclared layer', () => { 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({ diff --git a/packages/joint-react/src/store/__tests__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts index 468503a445..aeaf2fc25b 100644 --- a/packages/joint-react/src/store/__tests__/layers.test.ts +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -1,6 +1,6 @@ import { dia } from '@joint/core'; import { DEFAULT_CELL_NAMESPACE } from '../graph-store'; -import { readLayerRecords, reconcileLayers, removeEmptyLayers } from '../layers'; +import { assertLayerRecords, readLayerRecords, reconcileLayers, removeEmptyLayers } from '../layers'; import type { LayerRecord } from '../../types/layer.types'; const EMPTY_LAYERS: readonly LayerRecord[] = []; @@ -244,3 +244,35 @@ describe('removeEmptyLayers', () => { 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('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 893b43ca29..e101c97c15 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -4,7 +4,7 @@ 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 { reconcileLayers, removeEmptyLayers } from './layers'; +import { assertLayerRecords, reconcileLayers, removeEmptyLayers } from './layers'; import { isRecord } from '../utils/is'; /** Custom graph event signalling a layout-only update (position/size/angle change). */ @@ -252,6 +252,12 @@ export function graphChanges(options: Options) { onElementsSizeChange(cell.id, (cell as dia.Element).size()); } } + // `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 @@ -262,9 +268,6 @@ export function graphChanges(options: Options) { deferCommit: isDeferring(), isReset: true, }); - // `fromJSON` resets layers too, and joint-core does not forward - // `layers:reset` to the graph — the cell `reset` is the only signal. - onLayersChange?.(); } ); @@ -327,9 +330,11 @@ export function graphChanges(options: Options) { // 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 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. + // 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', syncOptions); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index a5f7b9e933..efad306292 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -4,7 +4,7 @@ import type { AddPaperOptions } from './paper-store'; import { PaperStore, getDefaultPaperState } from './paper-store'; import type { LayerPatch, LayerRecord } from '../types/layer.types'; -import { reconcileLayers, withLayerPatch } from './layers'; +import { assertLayerRecords, reconcileLayers, withLayerPatch } from './layers'; import { isUpdater } from '../utils/is'; import type { ArrayUpdate } from './state-container'; import { @@ -237,6 +237,7 @@ export class GraphStore< // 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(); } diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index e44af644b0..2f0a6a156d 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -1,7 +1,8 @@ -import { dia } from '@joint/core'; +import { dia, util } 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; @@ -80,6 +81,41 @@ export function readLayerRecords( 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 { + const namespace: unknown = graph.layerCollection.layerNamespace; + return isRecord(namespace) && typeof util.getByPath(namespace, type, '.') === 'function'; +} + +/** + * 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), or a `type` with no constructor in the graph's + * `layerNamespace`. Runs before any write, so a bad array leaves the graph — + * and any open batch — untouched. + * @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 (typeof type === 'string' && !isRegisteredLayerType(graph, type)) { + throw new Error( + `GraphProvider: layer "${id}" has type "${type}", which is not in the graph's \`layerNamespace\`.` + ); + } + } +} + /** * 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. diff --git a/packages/joint-react/src/types/layer.types.ts b/packages/joint-react/src/types/layer.types.ts index 4c7c86b578..a7b1f374cf 100644 --- a/packages/joint-react/src/types/layer.types.ts +++ b/packages/joint-react/src/types/layer.types.ts @@ -59,6 +59,10 @@ export interface LayerRecord { * ``` */ 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 1ac1ba019a..a415991da0 100644 --- a/packages/joint-react/src/utils/dev-warnings.ts +++ b/packages/joint-react/src/utils/dev-warnings.ts @@ -165,7 +165,8 @@ export function warnLayerNotEmpty(layerId: string, getCellIds: () => readonly di `[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, layer: \'other\' }) — or remove them.' + 'Fix: reassign them first — setCell(id, (previous) => ({ ...previous, layer: \'other\' })) — ' + + 'or remove them.' ); }); } From 047e3950a61909d23b9d237053f4b588c47c1542 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 29 Sep 2026 19:19:43 +0700 Subject: [PATCH 06/10] feat(layers): implement layer model and view with namespace support --- .changeset/layers-mvc.md | 5 ++ .../src/components/graph/graph-provider.tsx | 12 +++ .../src/components/paper/paper.types.ts | 4 +- packages/joint-react/src/index.ts | 8 ++ packages/joint-react/src/internal.ts | 7 +- ...isibility.test.tsx => layer-view.test.tsx} | 74 ++++++++++++++++--- packages/joint-react/src/mvc/layer-model.ts | 36 +++++++++ packages/joint-react/src/mvc/layer-view.ts | 30 ++++++++ packages/joint-react/src/mvc/paper.ts | 46 ++++-------- packages/joint-react/src/store/graph-store.ts | 17 +++++ packages/joint-react/src/types/layer.types.ts | 12 +-- 11 files changed, 199 insertions(+), 52 deletions(-) create mode 100644 .changeset/layers-mvc.md rename packages/joint-react/src/mvc/__tests__/{paper-layer-visibility.test.tsx => layer-view.test.tsx} (54%) create mode 100644 packages/joint-react/src/mvc/layer-model.ts create mode 100644 packages/joint-react/src/mvc/layer-view.ts diff --git a/.changeset/layers-mvc.md b/.changeset/layers-mvc.md new file mode 100644 index 0000000000..ee5c058bb0 --- /dev/null +++ b/.changeset/layers-mvc.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + +LayerModel / LayerView - add the layer presets registered for plain layers, and a `layerNamespace` prop diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 985049498b..237f8cccaf 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -45,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`, @@ -154,6 +164,7 @@ function GraphBase(props: Readonly): React.React onCellsChange, graph, cellNamespace, + layerNamespace, cellModel, autoSizeOrigin, initialCells, @@ -195,6 +206,7 @@ function GraphBase(props: Readonly): React.React new GraphStore({ graph, cellNamespace, + layerNamespace, cellModel, initialCells: shouldSeed ? (cells ?? initialCells ?? []) : undefined, initialLayers: shouldSeed ? (layers ?? initialLayers) : undefined, 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/index.ts b/packages/joint-react/src/index.ts index eb4c02e8ad..0735162a7b 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -300,6 +300,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 99fb9fb8d5..69efd8041d 100644 --- a/packages/joint-react/src/internal.ts +++ b/packages/joint-react/src/internal.ts @@ -27,7 +27,12 @@ export type { FeaturesContext as FeaturesContextType } from './context'; export { FeaturesProvider } from './components'; // Store Classes -export { GraphStore, DEFAULT_CELL_NAMESPACE, AUTO_SIZE_OPTION } from './store/graph-store'; +export { + GraphStore, + DEFAULT_CELL_NAMESPACE, + DEFAULT_LAYER_NAMESPACE, + AUTO_SIZE_OPTION, +} from './store/graph-store'; export type { GraphStoreInternalSnapshot, PaperStoreState } from './store/graph-store'; export type { GraphStoreOptions } from './store/graph-store'; export { PaperStore } from './store/paper-store'; diff --git a/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx b/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx similarity index 54% rename from packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx rename to packages/joint-react/src/mvc/__tests__/layer-view.test.tsx index 2fd80b0dc1..13a8c5af71 100644 --- a/packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx +++ b/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx @@ -1,10 +1,15 @@ 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. */ @@ -24,46 +29,91 @@ const CELLS: readonly CellRecord[] = [ ]; const renderRect = () => ; -function makeWrapper(id: string) { +interface MountOptions { + readonly graphProviderProps?: Partial; + readonly paperProps?: Partial; +} + +function makeWrapper(id: string, { graphProviderProps, paperProps }: MountOptions) { return paperRenderElementWrapper({ - graphProviderProps: { initialLayers: LAYERS, initialCells: CELLS }, - paperProps: { id, renderElement: renderRect }, + 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) { +async function mountPaper(id: string, options: MountOptions = {}) { const { result } = renderHook( () => ({ paperApi: usePaper(id), api: useGraph(), store: useGraphStore() }), - { wrapper: makeWrapper(id) } + { 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.display).toBe('none'); - expect(paper.getLayerView('overlay').el.style.display).toBe(''); - // Hidden means not painted, not unmounted: the cell view is still there. + 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 display when visible changes, without touching cell views', async () => { + 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.display).toBe(''); + 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.display).toBe('none'); + expect(paper.getLayerView('notes').el.style.visibility).toBe('hidden'); }); it('applies visibility to a layer added after the paper mounted', async () => { @@ -71,7 +121,7 @@ describe('layer visibility on ', () => { await commit(() => { result.current.api.setLayer('late', { visible: false }); }); - expect(paper.getLayerView('late').el.style.display).toBe('none'); + expect(paper.getLayerView('late').el.style.visibility).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 6f59fc4ca9..6c25fe7d37 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -1,15 +1,24 @@ -import { dia, type mvc } from '@joint/core'; +import type { dia, 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,41 +71,14 @@ 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; - // A layer's `visible` attribute is one `display` toggle on its group, so - // hiding a layer never unmounts or re-evaluates its cell views. - this.listenTo(this.model, 'layer:change:visible', this.applyLayerVisibility); - } - - /** - * Mirrors a layer's `visible` attribute onto its group element. - * - * A prototype method, not a class field: `dia.Paper`'s constructor renders - * the layer views — and so calls `insertLayerView` below — before `super()` - * returns, when class fields do not exist yet. - * @param layer - The graph layer whose visibility changed. - */ - private applyLayerVisibility(layer: dia.GraphLayer): void { - if (!this.hasLayerView(layer.id)) return; - this.getLayerView(layer.id).el.style.display = layer.get('visible') === false ? 'none' : ''; - } - - /** - * Every layer group passes through here — initial render, a late - * `addLayer`, and each reorder — so the visibility is applied once, here. - * @param layerView - The layer view being inserted. - * @param beforeLayerView - The sibling to insert before, if any. - */ - protected insertLayerView(layerView: dia.LayerView, beforeLayerView?: dia.LayerView): void { - super.insertLayerView(layerView, beforeLayerView); - // The base `LayerView` types `model` as `undefined`; only a graph layer's - // view carries a `GraphLayer`, so widen and narrow at runtime, not by cast. - const { model }: { readonly model?: unknown } = layerView; - if (model instanceof dia.GraphLayer) this.applyLayerVisibility(model); } /** diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index efad306292..a158292f4a 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -14,6 +14,7 @@ import { } from './create-elements-size-observer'; 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'; @@ -36,6 +37,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, +}; + /** * `dia.Cell.set()` option key used to mark writes that originate from the * auto-size / measurement pipeline. `change:size` listeners can read it to @@ -94,6 +104,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}. @@ -129,6 +141,7 @@ export class GraphStore< const { cellModel, cellNamespace = DEFAULT_CELL_NAMESPACE, + layerNamespace = DEFAULT_LAYER_NAMESPACE, graph, autoSizeOrigin = 'top-left', initialCells, @@ -145,6 +158,10 @@ export class GraphStore< ...DEFAULT_CELL_NAMESPACE, ...(cellNamespace as Record), }, + layerNamespace: { + ...DEFAULT_LAYER_NAMESPACE, + ...(layerNamespace as Record), + }, cellModel, } ); diff --git a/packages/joint-react/src/types/layer.types.ts b/packages/joint-react/src/types/layer.types.ts index a7b1f374cf..e21c923053 100644 --- a/packages/joint-react/src/types/layer.types.ts +++ b/packages/joint-react/src/types/layer.types.ts @@ -23,14 +23,14 @@ export interface LayerRecord { readonly id: LayerId; /** * Whether the layer is painted. A hidden layer keeps its cells mounted and - * only sets `display: none` on the layer group, so toggling is O(1). + * only sets `visibility: hidden` on the layer group (see {@link LayerView}), + * so toggling is O(1) and the 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. Two - * consequences to plan for — a link in a visible layer anchored to a port of - * an element in a hidden layer cannot measure that port and may misroute, - * and for a very large layer you hide for long periods the paper's - * `cellVisibility` option (which does unmount) is the better tool. + * 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; From 5b0ab31dc9d34f6a4718ad750f5e2018df779c72 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 7 Oct 2026 14:16:33 +0700 Subject: [PATCH 07/10] feat(layers): enhance layer model and view with namespace support and visibility attributes --- .changeset/layers-mvc.md | 2 +- .changeset/layers-namespace.md | 5 +++ .changeset/layers-view.md | 5 +++ .../src/components/graph/graph-provider.tsx | 9 +++- .../src/hooks/__tests__/use-layers.test.tsx | 44 +++++++++++++++++++ .../src/store/__tests__/layers.test.ts | 17 +++++++ packages/joint-react/src/store/layers.ts | 24 +++++++--- 7 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 .changeset/layers-namespace.md create mode 100644 .changeset/layers-view.md diff --git a/.changeset/layers-mvc.md b/.changeset/layers-mvc.md index ee5c058bb0..e63ad16afc 100644 --- a/.changeset/layers-mvc.md +++ b/.changeset/layers-mvc.md @@ -2,4 +2,4 @@ "@joint/react": minor --- -LayerModel / LayerView - add the layer presets registered for plain layers, and a `layerNamespace` prop +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-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/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 237f8cccaf..bb1e4286aa 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -250,7 +250,13 @@ function GraphBase(props: Readonly): React.React return; } if (isControlled) { - applyControlled(cells, undefined, layersToApply()); + // 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 @@ -304,6 +310,7 @@ function GraphBase(props: Readonly): React.React isApplyingLayersRef.current = true; applyLayers(controlledLayers); isApplyingLayersRef.current = false; + lastAppliedLayersRef.current = controlledLayers; }; return graphProjection.layers.subscribe(() => { // A React-origin apply re-reads the store synchronously; that is the diff --git a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx index 30d6d0a973..98059c6443 100644 --- a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx @@ -446,3 +446,47 @@ describe('controlled cells + layers on an imperative fromJSON()', () => { 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/store/__tests__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts index aeaf2fc25b..1ca6893018 100644 --- a/packages/joint-react/src/store/__tests__/layers.test.ts +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -265,6 +265,23 @@ describe('assertLayerRecords', () => { ); }); + 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( diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index 2f0a6a156d..9424a2eb1d 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -1,4 +1,4 @@ -import { dia, util } from '@joint/core'; +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'; @@ -88,15 +88,23 @@ export function readLayerRecords( * @param type - The layer `type` attribute. */ function isRegisteredLayerType(graph: dia.Graph, type: string): boolean { - const namespace: unknown = graph.layerCollection.layerNamespace; - return isRecord(namespace) && typeof util.getByPath(namespace, type, '.') === 'function'; + // 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'; } /** * 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), or a `type` with no constructor in the graph's - * `layerNamespace`. Runs before any write, so a bad array leaves the graph — + * never match the array), a `type` with no 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. * @param graph - The target graph. * @param layers - The declared layers. @@ -113,6 +121,12 @@ export function assertLayerRecords(graph: dia.Graph, layers: readonly LayerRecor `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.' + ); + } } } From c4ca9989c17d7cd123a8c2627bc70ba46f26d26b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 29 Oct 2026 15:57:03 +0700 Subject: [PATCH 08/10] fix(react): preserve layer state and visibility --- .changeset/react-layer-callback-commit.md | 5 + .../controlled-layer-pruning.test.tsx | 98 +++++++ .../graph-provider-layer-lifecycle.test.tsx | 28 ++ .../hooks/__tests__/use-latest-ref.test.tsx | 92 ++++++ .../joint-react/src/hooks/use-latest-ref.ts | 7 +- .../layer-visibility-regressions.test.ts | 152 ++++++++++ packages/joint-react/src/mvc/paper.ts | 60 +++- .../__tests__/layer-apply-errors.test.ts | 271 ++++++++++++++++++ .../joint-react/src/store/graph-changes.ts | 76 ++++- .../joint-react/src/store/graph-projection.ts | 11 +- packages/joint-react/src/store/layers.ts | 115 +++++++- 11 files changed, 891 insertions(+), 24 deletions(-) create mode 100644 .changeset/react-layer-callback-commit.md create mode 100644 packages/joint-react/src/components/graph/__tests__/controlled-layer-pruning.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-latest-ref.test.tsx create mode 100644 packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts create mode 100644 packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts 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 index ce5fdfd588..46929d80b3 100644 --- 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 @@ -90,3 +90,31 @@ describe('GraphProvider layer subscription ownership', () => { ); }); }); + + +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/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/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/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/paper.ts b/packages/joint-react/src/mvc/paper.ts index 6c25fe7d37..89c06a44ad 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -1,4 +1,4 @@ -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'; @@ -79,6 +79,61 @@ export class PaperView extends Paper { 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); + } } /** @@ -406,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__/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/graph-changes.ts b/packages/joint-react/src/store/graph-changes.ts index fde52f20d5..a3c8a21dca 100644 --- a/packages/joint-react/src/store/graph-changes.ts +++ b/packages/joint-react/src/store/graph-changes.ts @@ -4,7 +4,7 @@ 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 } from './layers'; +import { assertLayerRecords, reconcileLayers, removeEmptyLayers, retryEmptyLayerRemovals } from './layers'; import { isRecord } from '../utils/is'; /** Custom graph event signalling a layout-only update (position/size/angle change). */ @@ -126,12 +126,41 @@ export function graphChanges(options: Options) { let batchDepth = 0; let deferDepth = 0; + 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. @@ -164,9 +193,10 @@ 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); } /** @@ -193,6 +223,24 @@ export function graphChanges(options: Options) { 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(); controller.listenTo( @@ -258,6 +306,7 @@ export function graphChanges(options: Options) { deferCommit: isDeferring(), isReset: true, }); + if (pendingLayerRemovals.size > 0) simpleScheduler(retryPendingLayers); } ); @@ -298,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(); }, @@ -322,17 +374,33 @@ export function graphChanges(options: Options) { if (layers) assertLayerRecords(graph, layers); if (cells) assertLayersExist(graph, cells, layers); + // 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; + 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. - if (layers) removeEmptyLayers(graph, layers, syncOptions); + 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 f8abd54d6f..e2e89c8ed7 100644 --- a/packages/joint-react/src/store/graph-projection.ts +++ b/packages/joint-react/src/store/graph-projection.ts @@ -333,10 +333,15 @@ export function graphProjection< 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; - // React-origin layer events are ignored by graphChanges, so re-read now. - if (update.layers) syncLayersFromGraph(); if (!update.cells) return; // React-origin graph events are ignored by graphChanges (isUpdateFromReact), diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index 0fd866ed3b..04d92f34df 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -9,6 +9,9 @@ const DEFAULT_GRAPH_LAYER_TYPE: unknown = dia.GraphLayer.prototype.defaults().ty /** 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 @@ -161,9 +164,8 @@ export function reconcileLayers( options: Record = {} ): void { const defaultId = graph.getDefaultLayer().id; - const target = next.some((record) => record.id === defaultId) - ? next - : [{ id: defaultId }, ...next]; + 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)) { @@ -173,6 +175,7 @@ export function reconcileLayers( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { isDefault, ...layerInit } = record; graph.addLayer(layerInit, options); + graphsWithDeclaredLayers.add(graph); } } @@ -184,9 +187,22 @@ export function reconcileLayers( 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); + } } /** @@ -232,25 +248,98 @@ function updateLayerAttributes( * @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 = {} -): void { + options: Record = {}, + isCurrent: () => boolean = () => true +): Set { const keep = new Set(); for (const record of next) keep.add(record.id); - const defaultId = graph.getDefaultLayer().id; - for (const layer of graph.getLayers()) { - if (layer.id === defaultId || keep.has(layer.id)) continue; - if (layer.cellCollection.length > 0) { - warnLayerNotEmpty(layer.id, () => layer.getCells().map((cell) => cell.id)); - continue; + 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; } - graph.removeLayer(layer.id, options); - forgetLayerNotEmptyWarning(layer.id); } + return removed; } /** From e2f2c7f6dae0b1255f9af4125c7590e894da3807 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 3 Nov 2026 14:21:49 +0700 Subject: [PATCH 09/10] refactor(joint-react): remove layer visibility, consolidate layer changesets Layer visibility needs to go through the paper's view management, so it moves to a follow-up in core. Removes the `visible` layer attribute, LayerModel, LayerView, the createLayerView override and the detached-label and HTML overlay visibility mirroring. The layers story hides layers with the paper cellVisibility predicate again. --- .changeset/layers-graph-provider.md | 5 - .changeset/layers-mvc.md | 5 - .changeset/layers-namespace.md | 5 - .changeset/layers-record.md | 5 - .changeset/layers-setters.md | 5 - .changeset/layers-use-layer.md | 5 - .changeset/layers-use-layers.md | 5 - .changeset/layers-view.md | 5 - .changeset/react-layer-callback-commit.md | 5 - .changeset/react-layers.md | 5 + .../graph-provider-layer-lifecycle.test.tsx | 28 ++-- .../src/components/graph/graph-provider.tsx | 13 +- .../src/components/paper/paper.types.ts | 4 +- .../__tests__/paper-html-overlay.test.tsx | 65 +------- .../render-element/paper-element-item.tsx | 22 +-- .../hooks/__tests__/use-layer.type.test.ts | 26 ++- .../src/hooks/__tests__/use-layers.test.tsx | 22 +-- packages/joint-react/src/hooks/use-graph.ts | 2 +- packages/joint-react/src/hooks/use-layer.ts | 12 +- packages/joint-react/src/index.ts | 8 - packages/joint-react/src/internal.ts | 2 +- .../src/mvc/__tests__/layer-view.test.tsx | 155 ------------------ .../layer-visibility-regressions.test.ts | 152 ----------------- packages/joint-react/src/mvc/layer-model.ts | 36 ---- packages/joint-react/src/mvc/layer-view.ts | 30 ---- packages/joint-react/src/mvc/paper.ts | 74 +-------- .../__tests__/layer-apply-errors.test.ts | 6 +- .../src/store/__tests__/layers.test.ts | 10 +- packages/joint-react/src/store/graph-store.ts | 19 +-- packages/joint-react/src/types/layer.types.ts | 24 +-- .../stories/examples/layers/code.tsx | 65 ++++++-- .../stories/examples/layers/story.tsx | 2 +- 32 files changed, 123 insertions(+), 704 deletions(-) delete mode 100644 .changeset/layers-graph-provider.md delete mode 100644 .changeset/layers-mvc.md delete mode 100644 .changeset/layers-namespace.md delete mode 100644 .changeset/layers-record.md delete mode 100644 .changeset/layers-setters.md delete mode 100644 .changeset/layers-use-layer.md delete mode 100644 .changeset/layers-use-layers.md delete mode 100644 .changeset/layers-view.md delete mode 100644 .changeset/react-layer-callback-commit.md create mode 100644 .changeset/react-layers.md delete mode 100644 packages/joint-react/src/mvc/__tests__/layer-view.test.tsx delete mode 100644 packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts delete mode 100644 packages/joint-react/src/mvc/layer-model.ts delete mode 100644 packages/joint-react/src/mvc/layer-view.ts diff --git a/.changeset/layers-graph-provider.md b/.changeset/layers-graph-provider.md deleted file mode 100644 index ae4bdb49bf..0000000000 --- a/.changeset/layers-graph-provider.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": minor ---- - - - add `initialLayers`, controlled `layers` and `onLayersChange` diff --git a/.changeset/layers-mvc.md b/.changeset/layers-mvc.md deleted file mode 100644 index e63ad16afc..0000000000 --- a/.changeset/layers-mvc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index bd3e3e6168..0000000000 --- a/.changeset/layers-namespace.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": minor ---- - - - add `layerNamespace` prop to register custom layer types diff --git a/.changeset/layers-record.md b/.changeset/layers-record.md deleted file mode 100644 index b9fac051de..0000000000 --- a/.changeset/layers-record.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index 9362e94d15..0000000000 --- a/.changeset/layers-setters.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index 6449e9a998..0000000000 --- a/.changeset/layers-use-layer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index 10ea8844a3..0000000000 --- a/.changeset/layers-use-layers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index 772757463c..0000000000 --- a/.changeset/layers-view.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 deleted file mode 100644 index efae1ebba7..0000000000 --- a/.changeset/react-layer-callback-commit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@joint/react": patch ---- - - - refresh layer callbacks before descendant layout effects diff --git a/.changeset/react-layers.md b/.changeset/react-layers.md new file mode 100644 index 0000000000..1861911579 --- /dev/null +++ b/.changeset/react-layers.md @@ -0,0 +1,5 @@ +--- +"@joint/react": minor +--- + + - add layers support (`initialLayers`, controlled `layers` with `onLayersChange`, `layerNamespace`, `useLayers()`, `useLayer()`, `useGraph().setLayers()` / `setLayer()`) 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 index 46929d80b3..732c4373b8 100644 --- 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 @@ -17,14 +17,14 @@ function MountMutation({ mutate }: { readonly mutate: (api: GraphApi) => void }) return null; } -const hideNotes = (api: GraphApi) => api.setLayer('notes', { visible: false }); +const disableNotes = (api: GraphApi) => api.setLayer('notes', { enabled: false }); const addLayer = (api: GraphApi) => { if (!api.graph.hasLayer('child')) api.graph.addLayer({ id: 'child' }); }; describe('GraphProvider layer subscription ownership', () => { it.each([ - ['setLayer', hideNotes, { id: 'notes', visible: false }], + ['setLayer', disableNotes, { id: 'notes', enabled: false }], ['graph.addLayer', addLayer, { id: 'child' }], ] as const)('reports %s from a child mount layout effect', (_name, mutate, expected) => { const onLayersChange = jest.fn(); @@ -42,14 +42,14 @@ describe('GraphProvider layer subscription ownership', () => { const graph = new dia.Graph(); render( - + ); await act(async () => { await Promise.resolve(); }); - expect(graph.getLayer('notes').get('visible')).not.toBe(false); + expect(graph.getLayer('notes').get('enabled')).not.toBe(false); }); it('does not revert external graph layers after the provider unmounts', async () => { @@ -57,15 +57,15 @@ describe('GraphProvider layer subscription ownership', () => { const { unmount } = render(); act(() => { - graph.getLayer('notes').set('visible', false); + graph.getLayer('notes').set('enabled', false); }); - expect(graph.getLayer('notes').get('visible')).toBe(false); + expect(graph.getLayer('notes').get('enabled')).toBe(false); unmount(); await act(async () => { await Promise.resolve(); }); - expect(graph.getLayer('notes').get('visible')).toBe(false); + expect(graph.getLayer('notes').get('enabled')).toBe(false); }); it('uses the latest callback and does not echo controlled prop updates', () => { @@ -81,20 +81,20 @@ describe('GraphProvider layer subscription ownership', () => { expect(nextHandler).not.toHaveBeenCalled(); act(() => { - graph.getLayer('notes').set('visible', false); + graph.getLayer('notes').set('enabled', false); }); expect(initialHandler).not.toHaveBeenCalled(); expect(nextHandler).toHaveBeenCalledTimes(1); expect(nextHandler).toHaveBeenLastCalledWith( - expect.arrayContaining([{ id: 'notes', name: 'Updated', visible: false }]) + expect.arrayContaining([{ id: 'notes', name: 'Updated', enabled: false }]) ); }); }); -function CommitLayerChange({ visible }: { readonly visible: boolean }) { +function CommitLayerChange({ enabled }: { readonly enabled: boolean }) { const { setLayer } = useGraph(); - useLayoutEffect(() => { setLayer('notes', { visible }); }, [setLayer, visible]); + useLayoutEffect(() => { setLayer('notes', { enabled }); }, [setLayer, enabled]); return null; } @@ -103,18 +103,18 @@ it('uses the replacement handler for a child layout-effect write in the same com 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 }]) + expect.arrayContaining([{ id: 'notes', enabled: false }]) ); }); diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 55dc5a09ad..ad46138ff8 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -47,12 +47,11 @@ export interface GraphProviderProps< 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 + * entries are merged on top of the JointJS built-ins. 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). + * @default JointJS built-in layer models */ readonly layerNamespace?: unknown; /** @@ -390,7 +389,7 @@ function GraphBase(props: Readonly): React.React * const [layers, setLayers] = useState>>([ * { id: 'background' }, * { id: 'cells' }, - * { id: 'notes', visible: false }, + * { id: 'notes' }, * ]); * // Cells join a layer through their `layer` field: { id: 'n1', type: 'element', layer: 'notes' } * diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 16c8502e83..1dbddb76b9 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -264,9 +264,7 @@ interface PaperSupportedOptions { readonly cellViewNamespace?: dia.Paper.Options['cellViewNamespace']; /** * Namespace of layer-view constructors used to resolve custom paper layers. - * 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 + * @default JointJS built-in layer views */ 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 6b1d8f4614..d2949d5d17 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,14 +1,12 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { act, render, waitFor } from '@testing-library/react'; -import { useGraph } from '../../../../hooks/use-graph'; +import { render, waitFor } from '@testing-library/react'; 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', @@ -78,64 +76,3 @@ 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 fb8531fe5c..934e8a2f7f 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,7 +1,6 @@ import { useLayoutEffect, useMemo, - useRef, type ComponentType, type CSSProperties, } from 'react'; @@ -92,25 +91,6 @@ 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; @@ -139,7 +119,7 @@ function HTMLElementItemComponent(props: ElementItemProps) { const dataProps = element?.data ?? {}; return createPortal( -
+
, portalElement 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 index d664947790..17dec24112 100644 --- a/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts @@ -8,10 +8,10 @@ * - `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'>('a', (layer) => layer.locked === true)` → `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` + * - `LayerRecord` keeps custom attributes typed `unknown` */ import { useLayers } from '../use-layers'; import { useLayer } from '../use-layer'; @@ -42,19 +42,18 @@ function typeChecks() { const narrow = useLayer('foreground'); expectType | undefined>(narrow); - expectType(narrow?.visible); expectType(narrow?.name); const selected = useLayer( 'foreground', - (layer) => layer.visible !== false + (layer) => layer.locked === true ); 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); + const selectIsLocked = (layer: LayerRecord) => layer.locked === true; + const inferred = useLayer(typedId, selectIsLocked); expectType(inferred); // @ts-expect-error -- inferred as boolean, not a record expectType(inferred); @@ -62,18 +61,13 @@ function typeChecks() { // @ts-expect-error -- 'nope' is not a DiagramLayerId useLayer('nope'); - const record: LayerRecord = { id: 'background', visible: true, name: 'BG' }; + const record: LayerRecord = { id: 'background', locked: 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' }; + // LayerPatch carries custom attributes, typed `unknown`. + const patch: LayerPatch = { locked: false, name: 'Notes' }; + expectType(patch.locked); // 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' }; @@ -87,7 +81,7 @@ function typeChecks() { }; // @ts-expect-error -- 'nope' is not a DiagramLayerId const badControlled: GraphProviderProps = { layers: [{ id: 'nope' }] }; - return [bad, badPatch, renamePatch, defaultPatch, controlled, badControlled]; + return [patch, renamePatch, defaultPatch, controlled, badControlled]; } describe('useLayer types', () => { diff --git a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx index 06d7423394..82dcf921a4 100644 --- a/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-layers.test.tsx @@ -29,7 +29,7 @@ function StoreProbe() { const THREE_LAYERS: readonly LayerRecord[] = [ { id: 'background' }, { id: 'cells' }, - { id: 'foreground', visible: false }, + { id: 'foreground', enabled: false }, ]; const CELLS: readonly CellRecord[] = [ { id: 'a', type: 'element', layer: 'background' } as CellRecord, @@ -56,7 +56,7 @@ function renderCounted(hook: () => Result, wrapper: typeof withLayers) { ); return { ...view, renderSpy }; } -const selectVisible = (layer: LayerRecord) => layer.visible !== false; +const selectEnabled = (layer: LayerRecord) => layer.enabled !== false; /** Mounts a provider with BOTH `layers` and `cells` controlled from one state object. */ async function mountBoth(initial: { @@ -112,7 +112,7 @@ describe('useLayers — uncontrolled', () => { wrapper: withLayersAndCells, }); expect(selectIds(result.current)).toEqual(['background', 'cells', 'foreground']); - expect(result.current[2]).toEqual({ id: 'foreground', visible: false }); + expect(result.current[2]).toEqual({ id: 'foreground', enabled: false }); }); it('assigns cells to their declared layer and leaves untagged cells on the default layer', () => { @@ -159,16 +159,16 @@ describe('useLayer', () => { const { result } = renderHook(() => [useLayer('foreground'), useLayer('nope')] as const, { wrapper: withLayers, }); - expect(result.current[0]).toEqual({ id: 'foreground', visible: false }); + expect(result.current[0]).toEqual({ id: 'foreground', enabled: 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() }), + () => ({ isEnabled: useLayer('foreground', selectEnabled), api: useGraph() }), withLayers ); - expect(result.current.isVisible).toBe(false); + expect(result.current.isEnabled).toBe(false); const baseline = renderSpy.mock.calls.length; // Unrelated attribute on the same layer: selected value unchanged → no render. @@ -178,9 +178,9 @@ describe('useLayer', () => { expect(renderSpy.mock.calls).toHaveLength(baseline); await commit(() => { - result.current.api.setLayer('foreground', { visible: true }); + result.current.api.setLayer('foreground', { enabled: true }); }); - expect(result.current.isVisible).toBe(true); + expect(result.current.isEnabled).toBe(true); expect(renderSpy.mock.calls.length).toBeGreaterThan(baseline); }); @@ -191,7 +191,7 @@ describe('useLayer', () => { ); const baseline = renderSpy.mock.calls.length; await commit(() => { - result.current.api.setLayer('foreground', { visible: true }); + result.current.api.setLayer('foreground', { enabled: true }); }); expect(renderSpy.mock.calls).toHaveLength(baseline); }); @@ -308,13 +308,13 @@ describe('controlled layers', () => { await mountControlled(ONE_LAYER); onLayersChange.mockClear(); await commit(() => { - storeRef!.graph.getLayer('background').set('visible', false); + storeRef!.graph.getLayer('background').set('enabled', false); }); expect(onLayersChange).toHaveBeenCalled(); const [emitted] = onLayersChange.mock.calls.at(-1)!; expect(emitted).toEqual([ { id: 'cells', isDefault: true }, - { id: 'background', visible: false }, + { id: 'background', enabled: false }, ]); }); diff --git a/packages/joint-react/src/hooks/use-graph.ts b/packages/joint-react/src/hooks/use-graph.ts index 048ebd761b..6182b4d441 100644 --- a/packages/joint-react/src/hooks/use-graph.ts +++ b/packages/joint-react/src/hooks/use-graph.ts @@ -173,7 +173,7 @@ export interface GraphApi< 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. + * exist yet: `setLayer('notes', { name: 'Notes' })`. */ readonly setLayer: (id: string, patch: LayerPatch) => void; } diff --git a/packages/joint-react/src/hooks/use-layer.ts b/packages/joint-react/src/hooks/use-layer.ts index b8fbedb8d0..11e2b92de2 100644 --- a/packages/joint-react/src/hooks/use-layer.ts +++ b/packages/joint-react/src/hooks/use-layer.ts @@ -22,7 +22,7 @@ type LayerSelection = LayerRecord | Selected | undefined; * * function LayerBadge() { * const layer = useLayer('foreground'); - * return {layer?.visible === false ? 'hidden' : 'shown'}; + * return {typeof layer?.name === 'string' ? layer.name : 'Foreground'}; * } * ``` */ @@ -33,7 +33,7 @@ export function useLayer( * 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 + * `useLayer('foreground', (layer) => layer.locked === true)` 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`. @@ -48,11 +48,11 @@ export function useLayer( * ```tsx * import { useLayer, type LayerRecord } from '@joint/react'; * - * const selectVisible = (layer: LayerRecord) => layer.visible !== false; + * const selectIsLocked = (layer: LayerRecord) => layer.locked === true; * - * function VisibilityToggle() { - * const isVisible = useLayer('foreground', selectVisible); - * return ; + * function LockIndicator() { + * const isLocked = useLayer('foreground', selectIsLocked); + * return ; * } * ``` */ diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 8376d8344e..4a772059fe 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -297,14 +297,6 @@ 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 0ee58d7c4e..c6cbbe17a3 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, DEFAULT_LAYER_NAMESPACE } from './store/graph-store'; +export { GraphStore, DEFAULT_CELL_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 deleted file mode 100644 index 13a8c5af71..0000000000 --- a/packages/joint-react/src/mvc/__tests__/layer-view.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index ee425ec451..0000000000 --- a/packages/joint-react/src/mvc/__tests__/layer-visibility-regressions.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 41cbc9ac99..0000000000 --- a/packages/joint-react/src/mvc/layer-model.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 1ea031f0a3..0000000000 --- a/packages/joint-react/src/mvc/layer-view.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 89c06a44ad..9536dbf854 100644 --- a/packages/joint-react/src/mvc/paper.ts +++ b/packages/joint-react/src/mvc/paper.ts @@ -1,24 +1,15 @@ -import { config, dia, type mvc } from '@joint/core'; +import type { dia, 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'; @@ -71,69 +62,11 @@ export class PaperView extends Paper { constructor(options: PaperViewOptions) { const { onViewMountChange, portalSelector, id, ...paperOptions } = options; - super({ - ...paperOptions, - layerViewNamespace: { ...DEFAULT_LAYER_VIEW_NAMESPACE, ...paperOptions.layerViewNamespace }, - }); + super(paperOptions); 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); - } } /** @@ -461,9 +394,6 @@ 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__/layer-apply-errors.test.ts b/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts index 3ea6d79ce1..3a7e49f2ed 100644 --- a/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts +++ b/packages/joint-react/src/store/__tests__/layer-apply-errors.test.ts @@ -12,7 +12,7 @@ const element = (layer: string, x = 0) => ({ size: { width: 10, height: 10 }, } satisfies ElementRecord); const DEFAULT_ATTRIBUTES: readonly LayerRecord[] = [ - { id: 'cells', visible: false, label: 'Main diagram' }, + { id: 'cells', enabled: false, label: 'Main diagram' }, ]; it('publishes successful partial layer writes when a later valid constructor throws', () => { @@ -55,12 +55,12 @@ it.each(['initial', 'update'] as const)( expect(moveLayer).not.toHaveBeenCalled(); const json = store.graph.toJSON(); expect(json.layers).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: 'cells', visible: false, label: 'Main diagram' }), + expect.objectContaining({ id: 'cells', enabled: false, label: 'Main diagram' }), ])); restored.graph.fromJSON(json); - expect(restored.graph.getDefaultLayer().get('visible')).toBe(false); + expect(restored.graph.getDefaultLayer().get('enabled')).toBe(false); expect(restored.graph.getDefaultLayer().get('label')).toBe('Main diagram'); } finally { restored.destroy(false); diff --git a/packages/joint-react/src/store/__tests__/layers.test.ts b/packages/joint-react/src/store/__tests__/layers.test.ts index 1ca6893018..9563e68141 100644 --- a/packages/joint-react/src/store/__tests__/layers.test.ts +++ b/packages/joint-react/src/store/__tests__/layers.test.ts @@ -99,11 +99,11 @@ describe('reconcileLayers', () => { 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: 'Layer 1', enabled: 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(layer.has('enabled')).toBe(false); expect(graph.toJSON().layers).toEqual([ { type: 'GraphLayer', id: 'cells' }, { type: 'GraphLayer', id: 'layer1', name: 'Renamed' }, @@ -165,15 +165,15 @@ describe('reconcileLayers', () => { }); // Regression: an empty declaration returned early, so attributes given to the - // default layer earlier (e.g. `visible: false`) and its position were kept. + // default layer earlier (e.g. `enabled: 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 }]); + reconcileLayers(graph, [{ id: 'layer1' }, { id: 'cells', enabled: false }]); expect(layerIds(graph)).toEqual(['layer1', 'cells']); reconcileLayers(graph, EMPTY_LAYERS); - expect(graph.getDefaultLayer().has('visible')).toBe(false); + expect(graph.getDefaultLayer().has('enabled')).toBe(false); expect(layerIds(graph)).toEqual(['cells', 'layer1']); }); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 7ebcb15fd1..329700aa20 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -10,7 +10,6 @@ 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'; @@ -32,15 +31,6 @@ 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. @@ -74,7 +64,7 @@ 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}. */ + /** Layer classes by `type`, merged on top of the JointJS built-ins. */ readonly layerNamespace?: unknown; /** * Reference point that stays fixed when an auto-sized element's measured size @@ -108,7 +98,7 @@ export class GraphStore< const { cellModel, cellNamespace = DEFAULT_CELL_NAMESPACE, - layerNamespace = DEFAULT_LAYER_NAMESPACE, + layerNamespace, graph, autoSizeOrigin = 'top-left', initialCells, @@ -124,10 +114,7 @@ export class GraphStore< ...DEFAULT_CELL_NAMESPACE, ...(cellNamespace as Record), }, - layerNamespace: { - ...DEFAULT_LAYER_NAMESPACE, - ...(layerNamespace as Record), - }, + layerNamespace, cellModel, } ); diff --git a/packages/joint-react/src/types/layer.types.ts b/packages/joint-react/src/types/layer.types.ts index ec081ccc6d..f93be9e6a1 100644 --- a/packages/joint-react/src/types/layer.types.ts +++ b/packages/joint-react/src/types/layer.types.ts @@ -18,26 +18,12 @@ * const layers: ReadonlyArray> = [ * { id: 'background' }, * { id: 'cells' }, - * { id: 'foreground', visible: false }, + * { id: 'foreground', name: 'Annotations' }, * ]; * ``` */ 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 @@ -51,15 +37,15 @@ export interface LayerRecord { /** * 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`. + * as `Omit`: `Omit` over an index signature drops the + * `never` guards below. * @group Types * @example * ```tsx * import { useGraph } from '@joint/react'; * * const { setLayer } = useGraph(); - * setLayer('notes', { visible: false }); + * setLayer('notes', { name: 'Notes' }); * ``` */ export interface LayerPatch { @@ -67,7 +53,5 @@ export interface LayerPatch { 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/stories/examples/layers/code.tsx b/packages/joint-react/stories/examples/layers/code.tsx index 3fc050faa3..d1226aa841 100644 --- a/packages/joint-react/stories/examples/layers/code.tsx +++ b/packages/joint-react/stories/examples/layers/code.tsx @@ -1,16 +1,17 @@ import { type CellRecord, + type CellVisibility, type LayerRecord, GraphProvider, HTMLHost, Paper, useCell, useGraph, - useLayer, useLayers, + usePaper, selectElementSize, } from '@joint/react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import './styles.css'; // Colors — unified dark diagram palette. @@ -21,6 +22,8 @@ const BACKGROUND_LAYER_STROKE = '#2f4053'; const TEXT_COLOR = '#DDE6ED'; const MUTED_TEXT_COLOR = '#93A4B3'; +const PAPER_ID = 'layers-paper'; + /** Autocomplete and narrowing for every layer id in this diagram. */ type DiagramLayerId = 'background' | 'cells' | 'foreground'; @@ -124,21 +127,18 @@ function RenderElement(data: Readonly) { return data.isBackground ? : ; } -const selectVisible = (layer: LayerRecord) => layer.visible !== false; +interface LayerToggleProps { + readonly id: DiagramLayerId; + readonly isHidden: boolean; + readonly onToggle: (id: DiagramLayerId) => void; +} -/** 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]); +/** One row of the layers panel. */ +function LayerToggle({ id, isHidden, onToggle }: Readonly) { + const toggle = useCallback(() => onToggle(id), [id, onToggle]); return ( - ); } @@ -146,6 +146,27 @@ function LayerRow({ id }: Readonly<{ id: DiagramLayerId }>) { function Main() { const layers = useLayers(); const { setLayers, setCell } = useGraph(); + const { wakeUp } = usePaper(PAPER_ID); + const [hiddenLayers, setHiddenLayers] = useState>(() => new Set()); + + // Hiding a layer unmounts its cells through the paper's `cellVisibility`. + const toggleLayer = useCallback( + (id: DiagramLayerId) => { + setHiddenLayers((previous) => { + const next = new Set(previous); + if (!next.delete(id)) next.add(id); + return next; + }); + // Refresh the paper so hidden/shown cells are re-evaluated immediately. + wakeUp(); + }, + [wakeUp] + ); + + const cellVisibility = useCallback( + ({ model, graph }) => !hiddenLayers.has(graph.getCellLayerId(model)), + [hiddenLayers] + ); // Reverse the paint order: the array IS the order, so reorder the array. const flipOrder = useCallback( @@ -163,7 +184,12 @@ function Main() {
{layers.map((layer) => ( - + ))}
- +
); } diff --git a/packages/joint-react/stories/examples/layers/story.tsx b/packages/joint-react/stories/examples/layers/story.tsx index 5be9fc067a..ddaaa2a7c6 100644 --- a/packages/joint-react/stories/examples/layers/story.tsx +++ b/packages/joint-react/stories/examples/layers/story.tsx @@ -11,7 +11,7 @@ const meta = { showcase: { canvasHeight: 440, description: - '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.', + 'Declare paint-ordered layers on GraphProvider, assign cells with their `layer` field, reorder them with useLayers and setLayers, move a cell between layers with setCell, and hide a layer with the paper cellVisibility predicate.', apiUrl: getAPILink('useLayers'), code: codeRaw, }, From d3c1588b1eafc6d128f330f92822f5b416cc8333 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 3 Nov 2026 14:28:48 +0700 Subject: [PATCH 10/10] docs(joint-react): correct layer docs, use the suggested layers changeset --- .changeset/react-layers.md | 2 +- packages/joint-react/src/store/layers.ts | 4 ++-- packages/joint-react/src/types/layer.types.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/react-layers.md b/.changeset/react-layers.md index 1861911579..07f02947a7 100644 --- a/.changeset/react-layers.md +++ b/.changeset/react-layers.md @@ -2,4 +2,4 @@ "@joint/react": minor --- - - add layers support (`initialLayers`, controlled `layers` with `onLayersChange`, `layerNamespace`, `useLayers()`, `useLayer()`, `useGraph().setLayers()` / `setLayer()`) + - add layers support (`initialLayers`, controlled `layers` with `onLayersChange`, `useLayers()`, `useLayer()`, `useGraph().setLayers()` / `setLayer()`) diff --git a/packages/joint-react/src/store/layers.ts b/packages/joint-react/src/store/layers.ts index 04d92f34df..4f14eb90a1 100644 --- a/packages/joint-react/src/store/layers.ts +++ b/packages/joint-react/src/store/layers.ts @@ -50,8 +50,8 @@ function toLayerRecord( /** * 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, + * record inside a new array otherwise. O(L): each layer's attributes are copied + * into a candidate record to compare against the previous one. 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. diff --git a/packages/joint-react/src/types/layer.types.ts b/packages/joint-react/src/types/layer.types.ts index f93be9e6a1..f681eefa2a 100644 --- a/packages/joint-react/src/types/layer.types.ts +++ b/packages/joint-react/src/types/layer.types.ts @@ -37,8 +37,8 @@ export interface LayerRecord { /** * 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 drops the - * `never` guards below. + * as `Omit`: `Omit` over an index signature collapses every + * named member into it, so it would neither reject `id` nor guard `isDefault`. * @group Types * @example * ```tsx