Skip to content

fix(react): stop writing the useSchemaPersistence adapter ref during render - #6796

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6745-ref-during-render
Aug 30, 2026
Merged

fix(react): stop writing the useSchemaPersistence adapter ref during render#6796
os-sam merged 1 commit into
mainfrom
claude/issue-6745-ref-during-render

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6745

useSchemaPersistence kept the live adapter in a ref that was written in the
render body, drawing three react-hooks/refs warnings.

Measured on my own base, not the card's

The card's line numbers and counts were taken on faac0d935. Re-measured on my
base d06059f24 — the file has grown since, so the lines differ:

pnpm exec eslint packages/react/src/hooks/useSchemaPersistence.ts --format json
BEFORE 3 warnings 237:40 react-hooks/refs Cannot access ref value during render
239:3 react-hooks/refs Cannot update ref during render
239:35 react-hooks/refs Cannot access ref value during render
AFTER 0 warnings

Package-wide, pnpm exec eslint packages/react --format json:

fileserrorswarningsreact-hooks/refs
before12603466
after12703433

The 3 that remain are on other files and out of scope here. The extra file is
the new pin.

The shape, and why it is not an effect

The four callbacks are created once ([] deps) and read the adapter at call
time, so the newest adapter has to reach them without changing their identity —
that is the ref's whole job. The write now happens in useInsertionEffect:

  • useEffect lands after paint, so a call made earlier in the same commit
    reaches the previous adapter.
  • useLayoutEffect is not enough either: a child's layout effects run
    before its parent's, so a child calling save() from its own layout effect
    still sees the previous adapter.
  • useInsertionEffect runs in the mutation phase — ahead of every layout
    effect in the tree, ahead of paint, ahead of any event handler.

useEffectEvent would be the idiomatic answer but is React 19.2+, and this
package declares react: ^18.0.0 || ^19.0.0 as a peer. useInsertionEffect
has been available since React 18.0.

What changed observably: nothing any legal call site can reach. The single
deferred window is a read during the render phase itself, and
save/load/list/remove are side effects that are never callable during
render. What the old code additionally did — and this is the defect the rule
names — was perform the write on renders React discards or replays (StrictMode,
a Suspense retry, a concurrent interruption), so a save could be routed through
an adapter belonging to a render that never committed.

Also fixed, named rather than slipped in

useRef(createLocalStorageAdapter()) invoked the factory on every render and
discarded all but the first result. It is now useMemo. This is not a widening:
the 237:40 warning is a read of defaultAdapter.current during render, so
clearing it requires changing how the default adapter is held, and the lazy-init
ref idiom would read and write a ref during render just the same. The adapter is
a stateless facade over localStorage whose identity is never exposed, so the
change is unobservable beyond the saved work.

Pins

packages/react/src/hooks/__tests__/useSchemaPersistence.adapterTracking.test.tsx,
4 pins: a changed adapter prop is what the next save reaches; the swap is in
place before a child layout effect of the same commit (the discriminating
one); the default localStorage adapter still persists across re-renders; an
explicit adapter takes over from the default.

Ablation, both directions

Implementation committed first; every leg confirmed on disk by comparing
git hash-object against the HEAD blob, restored with git checkout HEAD --
under a trap. The pins import the hook by relative source path and this package
has no dist, so the mutation bites the file Vitest actually loads — no rebuild
leg applies.

legmutationresult
Aimplementation reverted to origin/main, new pins kept15/15 pass — lint warnings return (3, at 237/239/239)
BuseInsertionEffect to useEffectpin 2 fails
CuseInsertionEffect to useLayoutEffectpin 2 fails

Leg B's failure is the stale read itself:

- "second:from-layout-second"
+ "first:from-layout-second"

Leg A is the honest headline: no test fails when the fix is reverted. The
pins pass against the old code and the new code alike — precisely because the
timing was preserved. This is a lint-cleanliness fix with no behavioural
regression behind it, not a bug fix; the new pins guard the next edit (legs B
and C) rather than a break that exists today. No user-visible break was measured
here, and none is claimed.

Verification

Union re-run after the final commit, at a7be07cc5, tree clean:

  • pnpm exec vitest run on both pin files (canonical invocation, repo root, no
    --): Test Files 2 passed (2), Tests 15 passed (15) — including the 11
    from the objectui#6658 callable guard, untouched.
  • pnpm --filter '@object-ui/react' run type-check: pass. It runs
    tsc --noEmit && tsc -p tsconfig.test.json; --listFiles confirms the new
    pin file is in that program, so the green covers it.
  • pnpm exec eslint packages/react: 0 errors, 343 warnings (table above).
  • check:control-bytes: OK (scanned 5646 tracked text file(s)).
  • check:vi-mock-specifiers: OK.

Repo-wide pnpm lint is left to CI, which runs the farm exactly once.


Generated by Claude Code

…render
The hook wrote `adapterRef.current` in the render body and read
`defaultAdapter.current` there too, drawing three react-hooks/refs warnings
(objectui#6745). A render React discards or replays still performed the write,
so a save could be routed through an adapter from a render that never
committed.
The write moves to `useInsertionEffect` — the mutation phase, ahead of every
layout effect in the tree, paint, and any event handler — so every call site
that may legally invoke save/load/list/remove sees exactly what the old
render-body write gave it. `useEffect` (after paint) and `useLayoutEffect`
(a child's run before its parent's) would each have deferred the swap past a
legal reader; a new pin holds that distinction.
The default adapter moves from `useRef(createLocalStorageAdapter())` to
`useMemo`, which also stops the factory running on every render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.1 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-DL7e8Anq.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.75KB116.33KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.40KB1.20KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 29, 2026 22:15
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit d47de51Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6745-ref-during-render branch August 30, 2026 02:55
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(react): useSchemaPersistence writes adapterRef.current during render — three react-hooks/refs warnings on a published hook

2 participants

@os-sam@os-sales