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
| options.onChanges({ | ||
| changes, | ||
| isInsideBatch: isInsideBatch(), | ||
| deferCommit: isDeferring(), | ||
| isReset: true, |
| 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 }; |
| const defaultId = graph.getDefaultLayer().id; | ||
| const target = next.some((record) => record.id === defaultId) | ||
| ? next | ||
| : [{ id: defaultId }, ...next]; |
| 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.' |
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/paper.tsvisible: falsesetsdisplay: noneon the layer's<g>— O(1), cellviews stay mounted — applied in an
insertLayerViewoverride (initial render,late add, reorder) and on
layer:change:visible.onGraphLayerAddoverride: core defers a layer view's removal butearly-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 viewfrom the async update loop. Requesting an insert cancelsthe pending removal (core clears
FLAG_REMOVEwhenFLAG_INSERTarrives);the sort pass restores paint order.
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__/paper-layer-visibility.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.