Skip to content

feat(joint-react): add declarative layers with initialLayers, controlled layers, useLayers and useLayer - #3499

Open
samuelgja wants to merge 5 commits into
clientIO:devfrom
samuelgja:feat/layers-support-for-react
Open

samuelgja wants to merge 5 commits into
clientIO:devfrom
samuelgja:feat/layers-support-for-react

Conversation

@samuelgja

@samuelgja samuelgja commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

https://github.com/orgs/clientIO/projects/6/views/13?pane=issue&itemId=223856141&issue=clientIO%7Cjoint-plus%7C778
Using layers from @joint/react meant leaving the managed graph: build a
dia.Graph by hand with a cellNamespace assembled from two internal model
exports, loop graph.addLayer() in a useMemo, and fake per-layer visibility
with a cellVisibility predicate plus a manual wakeUp() — the previous
Examples/Layers story did exactly that. Cell membership already worked
(layer is a declared dia.Cell.Attributes field, so CellRecord.layer
round-trips through syncCells and GraphLayersController moves the cell);
what was missing was any way to declare, order, observe, or update the layers
themselves from React.

Doing that declaratively is constrained by joint-core: addLayer throws on a
duplicate id, removeLayer throws on a non-empty layer and on the default
layer, a cell naming a missing layer throws, and there is no syncLayers
counterpart to syncCells. So the binding carries its own id-keyed reconciler
and applies layers and cells in one tagged batch — add layers, reorder, sync
cells, then remove the layers that are now empty — because no ordering of
separate effects can satisfy those rules.

type LayerId = 'background' | 'cells' | 'notes';

<GraphProvider initialLayers={[{ id: 'background' }, { id: 'cells' }, { id: 'notes', visible: false }]} />
<GraphProvider layers={layers} onLayersChange={setLayers} />   // controlled, generic over LayerId

const layers = useLayers<LayerId>();               // paint order, bottom → top
const isVisible = useLayer(id, selectVisible);     // generics infer from a typed id + selector
const { setLayers, setLayer } = useGraph();
setLayer('notes', { visible: false });
setLayers((previous) => previous.toReversed());

packages/joint-react/src/types/layer.types.ts

  • LayerRecord<LayerId extends string = string>: id, visible?, read-only
    isDefault?, plus an index signature so custom dia.GraphLayer attributes
    round-trip through graph.toJSON(). Layer ids are string in core
    (GraphLayer.ID), hence the constraint. LayerPatch is declared explicitly
    rather than as Omit<LayerRecord, 'id'>, which collapses visible to
    unknown through the index signature.

packages/joint-react/src/store/layers.ts

  • reconcileLayers() diffs by id: addLayer only for missing ids (with
    before for position), moveLayer only for out-of-place layers walking
    from index 0, attribute writes through mvc.Model.set (which diffs itself),
    and unsets only attributes the record dropped that the layer class does not
    provide as defaults(). It never remove+adds an existing layer.
  • removeEmptyLayers() runs after the cells sync. A layer that still holds
    cells is kept and a once-per-layer dev warning names the cells; the default
    layer is never removed. Omit the default cells layer from the array and it
    stays at the bottom; name it to position it.
  • readLayerRecords() projects graph.getLayers() with structural sharing:
    returns the previous array when nothing changed and reuses every unchanged
    record otherwise (isShallowEqual). Runs on layer events, on reset, and
    after a React-origin write — never on a plain cell commit.

packages/joint-react/src/store/graph-changes.ts, graph-projection.ts, graph-store.ts

  • Listeners for layer:add|remove|change|default and layers:sort notify
    synchronously and skip React-origin events via the same isUpdateFromReact
    tag syncCells uses (core forwards the caller opt as the last argument of
    every layer event). Synchronous on purpose: a coalesced notification was
    consumed under the React-origin guard when a flushSync commit landed first.
  • fromJSON resets layers without a forwarded layers:reset; the cell
    reset listener re-reads them.
  • The layers store is an ordered snapshot plus a per-snapshot WeakMap id
    index, so useLayer reads are O(1) and the list stays the reactive unit.
  • updateGraph accepts layers and applies them around syncCells inside
    one batch tagged with the sync options, so batch:stop schedules no
    redundant change pass. The dead isSyncedWithReact latch in that path is
    removed.

packages/joint-react/src/components/graph/graph-provider.tsx

  • initialLayers, controlled layers + onLayersChange; GraphProviderProps
    gains a LayerId generic so a typed union works in controlled mode.
  • Layers are reconciled only when their reference changes; a drag frame in
    controlled cells+layers mode does zero layer work. A layers-only change skips
    the O(n) cells diff. The subscription is registered once, the handler and
    array read through refs, and a React-origin apply is guarded so the parent's
    own write does not echo through onLayersChange. Controlled without a
    handler reverts imperative changes, deferred and deduped so a burst reverts
    once.

packages/joint-react/src/hooks/use-layers.ts, use-layer.ts, use-graph.ts

  • useLayers() subscribes to the layer list; useLayer(id, selector?, isEqual?)
    selects with the same array-aware default equality useCell uses and returns
    undefined for a missing layer (a layer may legitimately not exist yet in
    controlled mode). Both narrow ids to the caller's union through overloads —
    the same unchecked narrowing useCells<Cell> performs, no assertion.
  • GraphApi.setLayers(arrayOrUpdater) / setLayer(id, patch) delegate to the
    store; useGraph does not subscribe to layers, since a value only read in a
    callback must not re-render every consumer.

packages/joint-react/src/mvc/paper.ts

  • visible: false sets display: none on the layer's <g> — O(1), cell
    views stay mounted — applied in an insertLayerView override (initial render,
    late add, reorder) and on layer:change:visible.
  • onGraphLayerAdd override: core defers a layer view's removal but
    early-returns on a re-add while it is pending, so dropping and re-declaring a
    layer within one frame orphaned it and the next cell placed on it threw
    Unknown layer view from the async update loop. Requesting an insert cancels
    the pending removal (core clears FLAG_REMOVE when FLAG_INSERT arrives);
    the sort pass restores paint order.

Tests

  • src/store/__tests__/layers.test.ts — projection and reconciler against a
    real dia.Graph: paint order, default-layer placement, reorder via
    moveLayer only, equal-content no-op, attribute update/unset, custom
    subclass defaults preserved, option propagation into events, legacy mode,
    removal rules (empty / default / non-empty kept + warning).
  • src/hooks/__tests__/use-layers.test.tsx — uncontrolled, controlled,
    imperative addLayer / fromJSON / setDefaultLayer, cell↔layer moves via
    setCell, setLayers / setLayer, layer and cell declared in the same
    commit, the flushSync ordering regression, no echo of the parent's own
    write, zero layer reads on a cells-only commit, one revert per burst, and
    render-count contracts (useLayers does not re-render on a drag, a sibling
    useLayer does not re-render).
  • src/mvc/__tests__/paper-layer-visibility.test.tsx — hidden group keeps
    its cell views, toggling, late-added layers, and the remove-then-re-add
    regression.
  • src/hooks/__tests__/use-layer.type.test.ts — the typing contract,
    including inference from a typed id, LayerPatch.visible, and the generic
    provider.
  • Mirrors joint-core's test/jointjs/layers.js cases React-side; not covered
    by design: a custom config.layerAttribute (records hardcode layer,
    documented) and the layer-view lifecycle internals.

yarn test passes on this branch: typecheck, lint, knip, Jest on React 19
(1098) and React 18 (1094). The Examples/Layers story was rewritten on the
new API and verified in headless Chrome: initial order, hide keeps cells
mounted, flip reverses paint order, setCell moves a cell between layers, no
console output.

Changesets: @joint/react minor — <GraphProvider /> props, useLayers,
useLayer, useGraph setters, LayerRecord.

Motivation and Context

Requested on the JointJS project board (item 223856141): make layers a
first-class, declarative part of the React binding with the same
controlled/uncontrolled modes cells have, instead of the hand-built-graph
workaround.

Notes

  • Targets dev as a next-minor feature; PRs to dev get no CI here, so the
    full suite was run locally on this branch.
  • Left out on purpose: a defaultLayer prop (setDefaultLayer silently
    migrates every untagged cell; isDefault is exposed read-only instead),
    onIncrementalLayersChange, <Layer> children sugar (it would have to be
    built on this reconciler anyway — React's effect ordering cannot satisfy
    core's add-before/remove-after rules), and a layer-attributes generic
    (custom attributes read as unknown, documented).
  • Known ceilings, commented in code: the reconcile move loop and the
    positional-mismatch fallback are O(L²) on a full reorder, which only matters
    past ~100 layers — where core's own moveLayer plus the paper's per-sort DOM
    reparenting is already O(L²).
  • Hidden is not unmounted: a link in a visible layer anchored to a port on an
    element in a hidden layer cannot measure that port; for a large layer hidden
    for long periods the paper's cellVisibility remains the right tool. Both
    are in the visible JSDoc.

Screenshots (if appropriate):

Not attached — the rewritten Examples/Layers story demonstrates each
operation interactively.

- 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Layer re-addition retains a stale model binding, and reconciliation mishandles empty declarations and writable isDefault metadata.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds declarative layer management to @joint/react, including controlled state, hooks, setters, rendering behavior, documentation, and tests.

Changes:

  • Adds layer records, reconciliation, subscriptions, and controlled provider support.
  • Adds useLayers, useLayer, setLayers, and setLayer.
  • Implements layer visibility and updates the example and test coverage.
File summaries
File Description
packages/joint-react/stories/examples/layers/story.tsx Updates story metadata.
packages/joint-react/stories/examples/layers/code.tsx Demonstrates declarative layers.
packages/joint-react/src/utils/dev-warnings.ts Adds non-empty-layer warnings.
packages/joint-react/src/types/layer.types.ts Defines layer record and patch types.
packages/joint-react/src/store/layers.ts Implements layer projection and reconciliation.
packages/joint-react/src/store/graph-store.ts Exposes layer mutation operations.
packages/joint-react/src/store/graph-projection.ts Adds reactive layer snapshots.
packages/joint-react/src/store/graph-changes.ts Integrates layer events and synchronized updates.
packages/joint-react/src/store/__tests__/layers.test.ts Tests layer reconciliation.
packages/joint-react/src/mvc/paper.ts Implements visibility and re-add handling.
packages/joint-react/src/mvc/__tests__/paper-layer-visibility.test.tsx Tests layer rendering behavior.
packages/joint-react/src/index.ts Exports the public layer API.
packages/joint-react/src/hooks/use-layers.ts Adds the ordered-layers hook.
packages/joint-react/src/hooks/use-layer.ts Adds the selected-layer hook.
packages/joint-react/src/hooks/use-graph.ts Adds layer setters.
packages/joint-react/src/hooks/index.ts Exports layer hooks internally.
packages/joint-react/src/hooks/__tests__/use-layers.test.tsx Tests hooks and controlled behavior.
packages/joint-react/src/hooks/__tests__/use-layer.type.test.ts Tests layer API typings.
packages/joint-react/src/components/graph/graph-provider.tsx Adds declarative provider props.
.changeset/layers-use-layers.md Records the useLayers API.
.changeset/layers-use-layer.md Records the useLayer API.
.changeset/layers-setters.md Records layer setters.
.changeset/layers-record.md Records layer types and visibility.
.changeset/layers-graph-provider.md Records provider layer props.
Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/joint-react/src/mvc/paper.ts Outdated
Comment thread packages/joint-react/src/store/layers.ts Outdated
Comment thread packages/joint-react/src/store/layers.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Layer defaults are not restored when overrides are removed, and failed synchronization can leave graph batches open.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

packages/joint-react/src/store/layers.ts:158

  • When a controlled record drops an overridden attribute that is also present in the layer subclass's defaults, this branch leaves the old override in place. For example, a layer with default opacity: 1 remains at 0.5 after changing { opacity: 0.5 } to an omitted opacity, so the graph no longer matches the declared record. Reset such keys to their class-default value; only non-default keys should be unset.
  • Files reviewed: 27/27 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread packages/joint-react/src/store/graph-changes.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reset ordering, exception-safe batching, and public layer input validation have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 28/28 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment on lines 259 to 263
options.onChanges({
changes,
isInsideBatch: isInsideBatch(),
deferCommit: isDeferring(),
isReset: true,
Comment on lines +335 to 344
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 };
Comment on lines +103 to +106
const defaultId = graph.getDefaultLayer().id;
const target = next.some((record) => record.id === defaultId)
? next
: [{ id: defaultId }, ...next];
Comment on lines +61 to +65
export interface LayerPatch {
/** See {@link LayerRecord.visible}. */
readonly visible?: boolean;
readonly [attribute: string]: unknown;
}
`[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.'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants