fix(joint-react): register paper features synchronously during render#3306
Merged
kumilingus merged 3 commits intoMay 12, 2026
Merged
Conversation
… when paperStore exists
When `<X />` mounts inside `<Paper>` (so PaperStoreContext is populated)
and calls `useCreateFeature('paper', ...)`, the previous code only
registered the feature in the commit phase via `useLayoutEffect`. A
sibling `<Y />` that reads `usePaperFeatures(paperId)` in the same render
would not see the feature, because:
1. `<Paper>` renders, isReady flips to true, children render.
2. The child component (e.g. `<Snaplines />`) invokes `useCreateFeature`.
Registration is deferred to its useLayoutEffect.
3. Sibling consumer (e.g. `<Stencil />`) renders, `usePaperFeatures` reads
`paperStore.features` — empty, no feature.
4. Commit phase: child effect registers the feature, version bumps.
5. Re-render — but consumers that captured features in their `onLoad` /
imperative-api closures from step 3 still have the stale value.
Fix: when `target === 'paper'` AND `paperStore` exists at render time
AND the feature isn't already cached, register synchronously during
render (mirrors the existing render-phase deferred-registration path
for the no-paperStore branch). The existing `featureRef.current` guard
in the useLayoutEffect re-register branch ensures `onAddFeature` is
only called once.
Effect: feature registration is observable to sibling consumers in the
same render pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
samuelgja
reviewed
May 11, 2026
samuelgja
approved these changes
May 12, 2026
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.
Summary
Fixes a class of bugs where features registered by hooks called inside a
<Paper>subtree, or through a wrapping component (e.g.<PaperScroller>wrapping<Paper>), were not visible to sibling consumers in the same render pass. Adds adjacent fixes that surfaced while validating the main change.Changes
1. Synchronous render-phase feature registration (
use-create-features.ts)Previously
useCreateFeaturedeferred registration touseLayoutEffect. When a feature-registering component (e.g.<Snaplines />) mounted inside<Paper>, sibling consumers readingusePaperFeatures(paperId)in the same render captured an empty snapshot in theironLoad/ imperative-API closures and never picked up the feature.Fix: when
target === 'paper'AND apaperStoreis reachable AND the feature isn't already cached, register synchronously during render viacreateAndRegisterFeature+registerFeature. Mirrors the existing render-phasefeatureContext.features.set(...)path used when nopaperStoreexists. ThefeatureRef.currentguard in the existing useLayoutEffect re-register branch keepsonAddFeaturefiring exactly once.2. Fallback paperStore lookup (
use-create-features.ts)When the hook is called outside a Paper subtree (e.g.
<PaperScroller>wraps<Paper>, so the scroller sits abovePaperStoreContext),usePaperStore(OPTIONAL)returnsnull. The deferred-queue path may have already registered the feature inside somepaperStore, butuseCreateFeaturecan't see it via context.Fix: scan
graphStore.papersfor a paperStore whosefeaturesbag contains ourid, fall back to it. Update / load paths now have apaperStoreto operate on regardless of where the hook is mounted.3. Existing-feature adoption (
use-create-features.ts)When the fallback paperStore resolves and a feature was already created via the deferred-queue path, the create-effect would have called
onAddFeatureagain, producing duplicates. Adoption path:resolveExistingFeature(...)— if a feature withidalready exists, store it infeatureRef.currentand skip creation.4.
<Paper>width/height routed throughsetDimensions(use-create-portal-paper.tsx)widthandheightwere spread intoassignOptions(paper.options, ...), which mutatedpaper.options.width/heightdirectly and bypassed the DOM update. Worse, it trippedsetDimensions's early-exit guard the next time it ran (e.g.<PaperScroller>callingpaper.setDimensions(...)after the prop change).Fix: strip
width/heightout of the assignOptions spread and add a dedicateduseEffectthat callspaper.setDimensions(width, height). Keeps DOM CSS andpaper.optionsin lockstep.5.
minPathMarginoption inLinkRoutingOrthogonalpreset (presets/link-routing.ts)Surfaces the new
minPathMarginoption from therightAnglerouter (joint-core #3266) on the orthogonal preset typings.6. New
@joint/react/storiessubpath exportAdds a stories utility entry point so
@joint/react-plusstory files can reusemakeRootDocumentation,makeStory, andgetAPILinkwithout reaching intosrc/. Not part of the runtime public API.package.json—./storiesexports map entryrollup.config.ts—src/stories.tsadded to entriessrc/stories.ts(new) — re-exportssrc/internal.ts— exposesOPTIONALconstant for downstream consumersTest plan
yarn jest --testPathPatterns="features|use-create"(43/43 pass)yarn typecheck(no new errors)<Snaplines />mounted as child of<Paper>is detected by sibling<Stencil />(validated downstream in joint-plus)<PaperScroller>wrapping<Paper>— feature registration / update / unmount paths all hit the samepaperStorewidth/heightprops on<Paper>resizes the DOM, and<PaperScroller>setDimensionscalls afterwards still work