feat(joint-react): add declarative layers with initialLayers, controlled layers, useLayers and useLayer - #3499
Open
samuelgja wants to merge 2 commits into
Open
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.