Skip to content

fix(react): stop writing config refs during render in useETagCache, useGlobalUndo and useOffline - #6815

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-6797-more-refs-during-render
Aug 30, 2026
Merged

fix(react): stop writing config refs during render in useETagCache, useGlobalUndo and useOffline#6815
os-sam merged 4 commits into
mainfrom
claude/issue-6797-more-refs-during-render

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6797

Three react-hooks/refs warnings, one apiece on three published hooks, each
Cannot update ref during render. Each hook got its own reader-and-timing
analysis, its own shape decision and its own commit; the shapes converged, but
they were derived separately and the report below says where the three hooks
genuinely differ.

Measured on my own base, and the card's arithmetic corrected

Base 26896c689 (not the card's d06059f24). The three files have not moved:
the card's line numbers are still exact.

pnpm exec eslint packages/react --format json
fileserrorswarningsreact-hooks/refs
before (26896c689)12603466
after (a77a00c2c)12903433

⚠️The dispatch's "3 to 0" does not hold on this base — it is 6 to 3.
objectui#6796 has not merged, so useSchemaPersistence's three warnings are
still present here and are still the three that remain after this branch
(237:40, 239:3, 239:35 — untouched, that file belongs to the other PR).
Per hook, the count this branch owns:

hookbeforeafter
useETagCache.ts:204:310
useGlobalUndo.ts:57:310
useOffline.ts:262:310

The three new pin files add 3 to the file count and 0 warnings.

What is different from objectui#6796, measured rather than assumed

All three warnings here are the ref write only. useSchemaPersistence also
had two ref reads during render (237:40, 239:35), which is why its repair
had to change how the default adapter was held. Nothing of that kind applies
here: no reader of any of these three refs is reachable during render, so the
repair is strictly "move the write".

Each ref's readers, from a repo-wide grep of the ref identifier:

hookref holdsreadersreachable during render?
useETagCache5 resolved config scalarsisExpired, setEntry, removeEntry, clearCache, fetchWithETag — all useCallback([])no; all are side effects the consumer calls
useGlobalUndothe whole options bagexecuteOp, undo, redono; async data mutations
useOfflineconfig.syncsync, one siteno

Why useInsertionEffect for each, decided per hook

The shared reason is the commit-phase window: every one of these readers is
handed to the consumer, and a consumer may legally call it from a layout effect,
an insertion effect or a ref callback — all of which run before passive effects,
and child layout effects run before the parent's. So useEffect and
useLayoutEffect both move a window that exists today, and useInsertionEffect
(mutation phase, ahead of every layout effect, ref attachment and paint) moves
only the render phase, where none of these functions is callable. useEffectEvent
would be idiomatic but is React 19.2+, and this package's peer range is
react: ^18.0.0 || ^19.0.0.

What differs per hook is what the ref is protecting, which is what ruled out
the ref-free alternative in each case:

  • useETagCache — five useCallback([]) readers whose identity is part of
    the published result. Re-keying them on the config values is the tidiest React,
    but it changes fetchWithETag's identity whenever a caller's ttl moves,
    re-firing any consumer effect keyed on it. Observable, so rejected.
  • useGlobalUndo — all three in-repo callers (AppContent.tsx:429,
    RecordDetailView.tsx:587, useConsoleActionRuntime.tsx:161) pass a fresh
    inline literal with inline onUndo / onRedo closures on every render, and
    the keydown effect is keyed on undo / redo. Depending on options directly
    would re-register that listener every render. The ref is load-bearing.
  • useOffline — the odd one. Its single reader sync is already unstable
    (deps [enabled, queue]), so the ref is not protecting an identity at all: it
    protects retained closures. The auto-sync effect deliberately captures a
    sync and fires it 100ms later, and that closure must still read the newest
    batchSize. A syncConfig?.batchSize dep would be a scalar and would not
    thrash, but it changes what that retained closure reads. Observable, so
    rejected.

Pins

Three files, 4 pins each, one file per hook. Each file's discriminating pin
drives the hook's reader from a child's layout effect in the same commit as
the config change, which is the only window that separates the three candidate
shapes:

  • useETagCache.configTiming.test.tsxclearCache is fully synchronous, so
    the prefix it clears is read straight out of the ref.
  • useGlobalUndo.optionsTiming.test.tsxexecuteOp reads
    optionsRef.current.dataSource synchronously, before undo's first await.
  • useOffline.syncConfigTiming.test.tsx — the batchSize read is synchronous,
    before sync's first await.

The remaining pins hold the properties the repair had to preserve: latest-value
delivery through the retained callbacks, and the callback identities themselves.

⚠️One pin was wrong on the first pass and the ablation caught it. The
useOffline commit-phase caller was initially unguarded, so sync draining the
queue re-rendered, re-keyed the effect and re-fired it until the queue emptied —
reaching 0 whatever batchSize the first call had read. It passed with the
write moved to useEffect, i.e. it was pinning nothing. It now fires exactly
once, and fails on both legs.

Ablation, per hook

Implementation committed before every mutation. Each leg confirmed on disk by
comparing git hash-object against the HEAD blob (a mutation that did not change
the hash aborts the run), restored with git checkout HEAD -- on absolute paths
under a trap ... EXIT INT TERM, and each restore re-verified against the HEAD
blob. packages/react has no dist and the pins import the hook by relative
source path, so no rebuild leg applies; legs B and C flipping results is itself
the evidence that the mutation reached the module Vitest loads.

legmutationuseETagCacheuseGlobalUndouseOffline
Aimplementation reverted to 26896c689, new pins kept811/811 pass811/811 pass811/811 pass
BuseInsertionEffect to useEffectcommit-phase pin failscommit-phase pin failscommit-phase pin fails
CuseInsertionEffect to useLayoutEffectcommit-phase pin failscommit-phase pin failscommit-phase pin fails

Leg A ran the wholepackages/react suite (64 files, 811 tests), once per
hook, with that hook's implementation reverted and its pins kept.

Leg A is the honest headline, and it is the same answer for all three hooks:
no test fails when any of the three changes is reverted.
The pins pass against
the old code and the new code alike, precisely because the timing was preserved.
For every one of these three hooks, warnings are the only thing that moves.
This is a lint-cleanliness change, not a bug fix; no user-visible break was
measured and none is claimed. What the old code additionally did — and this is
the defect the rule names — was perform the write on renders React discards or
replays, so a tree that never committed could publish its config to callbacks
that outlive it. The new pins guard the next edit (legs B and C), not a break
that exists today.

Verification

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

  • pnpm exec vitest run packages/react/Test Files 64 passed (64),
    Tests 811 passed (811), including the 12 new pins.
  • pnpm --filter '@object-ui/react' run type-check — pass. It runs
    tsc --noEmit && tsc -p tsconfig.test.json; --listFiles confirms all three
    implementations are in the first program and all three new pin files are in the
    second, so the green covers the edits rather than merely coexisting with them.
  • pnpm exec eslint packages/react — 0 errors, 343 warnings (table above).
  • check:control-bytesOK (scanned 5651 tracked text file(s); skipped 85 binary).
  • check:vi-mock-specifiersOK.
  • check:phantom-depsEvery in-scope import is declared by the package that publishes it.
  • check:self-importNo package names itself inside its own src/.
  • check:esm-specifiersno un-ledgered package emits an extensionless relative specifier.

check:readme-exports is not measured here, not red: CI runs
turbo run build --filter='./packages/*' before it (readme-exports.yml), and
without that build all 334 findings are the same prerequisite —
its type entry ./dist/index.d.ts is not on disk -- run pnpm build first — in
packages this branch does not touch. Nothing in this diff adds or removes an
export or edits a README.

The lint run above is packages/react, the only package this branch touches,
rather than the repo-wide pnpm lint; CI runs the farm exactly once regardless.
That narrowing is a measurement rather than a gap: the population comes from
eslint's own config resolution, the count (129 files) is read from its
--format json output, and eslint.config.js enables no type-aware linting —
no project or projectService, languageOptions carries only ecmaVersion
and globals — so every rule is single-file, and this diff cannot move the
verdict on a file it does not contain.


Generated by Claude Code

…ender
The five resolved config scalars were assigned to `configRef` in the render
body — one `react-hooks/refs` "Cannot update ref during render" on
useETagCache.ts:204 — so a render React discarded or replayed still published
its config to the five `useCallback([])` readers that outlive it.
The ref itself is load-bearing: `isExpired`, `setEntry`, `removeEntry`,
`clearCache` and `fetchWithETag` all have `[]` deps and their identity is part
of the published result, so re-keying them on the config values would have
changed `fetchWithETag`'s identity whenever a caller's `ttl` moved. Only the
write moves, into `useInsertionEffect` — the mutation phase, ahead of every
layout effect, ref attachment and paint.
Four pins. The discriminating one drives the fully synchronous `clearCache`
from a CHILD's layout effect in the same commit as the prefix change, so it
fails under both `useEffect` and `useLayoutEffect`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
… render
`optionsRef.current = options` ran in the render body — one `react-hooks/refs`
"Cannot update ref during render" on useGlobalUndo.ts:57 — so the options of a
render React discarded or replayed still reached `executeOp`, `undo` and `redo`.
All three in-repo callers (AppContent, RecordDetailView, useConsoleActionRuntime)
pass a fresh inline literal with inline `onUndo` / `onRedo` closures on every
render, and the keydown effect is keyed on `undo` / `redo`, so the ref is the
only thing keeping those two stable while still reaching the newest callbacks.
The write moves to `useInsertionEffect`.
Four pins. The discriminating one calls `undo()` from a CHILD's layout effect in
the same commit as the dataSource swap; `executeOp` reads
`optionsRef.current.dataSource` synchronously, before `undo`'s first `await`, so
the pin fails under both `useEffect` and `useLayoutEffect`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
…n render
`syncConfigRef.current = syncConfig` ran in the render body — one
`react-hooks/refs` "Cannot update ref during render" on useOffline.ts:262.
This hook is the odd one of the three: its ref has exactly ONE reader (`sync`,
at `syncConfigRef.current?.batchSize`) and that reader is already unstable
(deps `[enabled, queue]`). So the ref is not protecting an identity — it is
protecting RETAINED closures: the auto-sync effect deliberately captures a
`sync` and fires it 100ms later, and that closure must still see the newest
`batchSize`. Dropping the ref for a `syncConfig?.batchSize` dep would change
what the retained closure reads, so it was rejected and only the write moves,
into `useInsertionEffect`.
Four pins. The discriminating one syncs from a CHILD's layout effect in the same
commit as the batchSize change; the `batchSize` read is synchronous, before
`sync`'s first `await`, so it fails under both `useEffect` and
`useLayoutEffect`. That caller fires exactly once on purpose: `sync` drains the
queue, which re-renders and re-keys the effect, and an unguarded version
re-fired until the queue emptied — reaching 0 whatever batchSize the first call
had read. Measured: without the guard the pin passed even with the write moved
to `useEffect`, so it was pinning nothing.
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.0 KB3222.7 KB
Main entry chunk (gzip)148.1 KB350 KB
Entry fileindex-BzqI85Px.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.50KB116.32KB
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)3.11KB1.48KB
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

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): three more react-hooks/refs warnings on published hooks — useETagCache, useGlobalUndo, useOffline

2 participants

@os-sam@claude