Skip to content

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

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

feat(joint-react): add declarative layers with initialLayers, controlled layers, useLayers and useLayer#3499
samuelgja wants to merge 2 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.
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.

1 participant