Repository navigation
Conversation
- 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.
There was a problem hiding this comment.
🟡 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, andsetLayer. - 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.
…hProvider and Paper
There was a problem hiding this comment.
🟡 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: 1remains at0.5after changing{ opacity: 0.5 }to an omittedopacity, 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
There was a problem hiding this comment.
🟡 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
Description
https://github.com/orgs/clientIO/projects/6/views/13?pane=issue&itemId=223856141&issue=clientIO%7Cjoint-plus%7C778
Using layers from
@joint/reactmeant leaving the managed graph: build adia.Graphby hand with acellNamespaceassembled from two internal modelexports, loop
graph.addLayer()in auseMemo, and fake per-layer visibilitywith a
cellVisibilitypredicate plus a manualwakeUp()— the previousExamples/Layersstory did exactly that. Cell membership already worked(
layeris a declareddia.Cell.Attributesfield, soCellRecord.layerround-trips through
syncCellsandGraphLayersControllermoves 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:
addLayerthrows on aduplicate id,
removeLayerthrows on a non-empty layer and on the defaultlayer, a cell naming a missing layer throws, and there is no
syncLayerscounterpart to
syncCells. So the binding carries its own id-keyed reconcilerand 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.
packages/joint-react/src/types/layer.types.tsLayerRecord<LayerId extends string = string>:id,visible?, read-onlyisDefault?, plus an index signature so customdia.GraphLayerattributesround-trip through
graph.toJSON(). Layer ids arestringin core(
GraphLayer.ID), hence the constraint.LayerPatchis declared explicitlyrather than as
Omit<LayerRecord, 'id'>, which collapsesvisibletounknownthrough the index signature.packages/joint-react/src/store/layers.tsreconcileLayers()diffs by id:addLayeronly for missing ids (withbeforefor position),moveLayeronly for out-of-place layers walkingfrom 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 holdscells is kept and a once-per-layer dev warning names the cells; the default
layer is never removed. Omit the default
cellslayer from the array and itstays at the bottom; name it to position it.
readLayerRecords()projectsgraph.getLayers()with structural sharing:returns the previous array when nothing changed and reuses every unchanged
record otherwise (
isShallowEqual). Runs on layer events, onreset, andafter a React-origin write — never on a plain cell commit.
packages/joint-react/src/store/graph-changes.ts,graph-projection.ts,graph-store.tslayer:add|remove|change|defaultandlayers:sortnotifysynchronously and skip React-origin events via the same
isUpdateFromReacttag
syncCellsuses (core forwards the calleroptas the last argument ofevery layer event). Synchronous on purpose: a coalesced notification was
consumed under the React-origin guard when a
flushSynccommit landed first.fromJSONresets layers without a forwardedlayers:reset; the cellresetlistener re-reads them.WeakMapidindex, so
useLayerreads are O(1) and the list stays the reactive unit.updateGraphacceptslayersand applies them aroundsyncCellsinsideone batch tagged with the sync options, so
batch:stopschedules noredundant change pass. The dead
isSyncedWithReactlatch in that path isremoved.
packages/joint-react/src/components/graph/graph-provider.tsxinitialLayers, controlledlayers+onLayersChange;GraphProviderPropsgains a
LayerIdgeneric so a typed union works in controlled mode.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 ahandler reverts imperative changes, deferred and deduped so a burst reverts
once.
packages/joint-react/src/hooks/use-layers.ts,use-layer.ts,use-graph.tsuseLayers()subscribes to the layer list;useLayer(id, selector?, isEqual?)selects with the same array-aware default equality
useCelluses and returnsundefinedfor a missing layer (a layer may legitimately not exist yet incontrolled 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 thestore;
useGraphdoes not subscribe to layers, since a value only read in acallback must not re-render every consumer.
packages/joint-react/src/mvc/layer-model.ts,layer-view.ts,paper.tsLayerModel(extendsdia.GraphLayer) andLayerView(extendsdia.GraphLayerView) are the presets registered for plain graph layers:LayerModelunder the plainGraphLayertype through the graph'slayerNamespace(DEFAULT_LAYER_NAMESPACE), so the defaultcellslayer isone and the graph JSON is unchanged;
LayerViewasGraphLayerViewin thepaper's
layerViewNamespace(merged inPaperView, a paper's own entries win).The attributes the layers API interprets (
visible, later e.g.locked) livethere, not on the paper.
visible: falsesetsvisibility: hiddenon the layer's<g>inLayerView— O(1), cell views stay mounted and keep their bbox, so they canstill be measured — applied when the view gets its paper and on the layer's
layer:change:visible.<GraphProvider layerNamespace>registers custom layer types; their views goin the
<Paper>layerViewNamespaceas<type>View, which is how joint-coreresolves them.
packages/joint-core/src/dia/Paper.mjsonGraphLayerAdd: core deferred a layer view's removal but early-returned ona re-add while it was pending, so dropping and re-declaring a layer within one
frame orphaned it and the next cell placed on it threw
Unknown layer view.The early return now holds only when the existing view is bound to the same
layer; otherwise the pending removal is dropped with the stale view and a
fresh view is created. QUnit test in
test/jointjs/layers.js, changesetpaper-layer-readd.md(@joint/corepatch).Tests
src/store/__tests__/layers.test.ts— projection and reconciler against areal
dia.Graph: paint order, default-layer placement, reorder viamoveLayeronly, equal-content no-op, attribute update/unset, customsubclass 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 viasetCell,setLayers/setLayer, layer and cell declared in the samecommit, the
flushSyncordering regression, no echo of the parent's ownwrite, zero layer reads on a cells-only commit, one revert per burst, and
render-count contracts (
useLayersdoes not re-render on a drag, a siblinguseLayerdoes not re-render).src/mvc/__tests__/layer-view.test.tsx— hidden group keepsits 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 genericprovider.
test/jointjs/layers.jscases React-side; not coveredby design: a custom
config.layerAttribute(records hardcodelayer,documented) and the layer-view lifecycle internals.
yarn testpasses on this branch: typecheck, lint, knip, Jest on React 19(1098) and React 18 (1094). The
Examples/Layersstory was rewritten on thenew API and verified in headless Chrome: initial order, hide keeps cells
mounted, flip reverses paint order,
setCellmoves a cell between layers, noconsole output.
Changesets:
@joint/reactminor —<GraphProvider />props,useLayers,useLayer,useGraphsetters,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
devas a next-minor feature; PRs todevget no CI here, so thefull suite was run locally on this branch.
defaultLayerprop (setDefaultLayersilentlymigrates every untagged cell;
isDefaultis exposed read-only instead),onIncrementalLayersChange,<Layer>children sugar (it would have to bebuilt 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).positional-mismatch fallback are O(L²) on a full reorder, which only matters
past ~100 layers — where core's own
moveLayerplus the paper's per-sort DOMreparenting is already O(L²).
element in a hidden layer cannot measure that port; for a large layer hidden
for long periods the paper's
cellVisibilityremains the right tool. Bothare in the
visibleJSDoc.Screenshots (if appropriate):
Not attached — the rewritten
Examples/Layersstory demonstrates eachoperation interactively.