Skip to content

Build useETagCache's config object once per hook instance, not once per render - #6885

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6817-usetagcache-config-alloc
Aug 30, 2026
Merged

Build useETagCache's config object once per hook instance, not once per render#6885
os-sam merged 1 commit into
mainfrom
claude/issue-6817-usetagcache-config-alloc

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#6817

useETagCache seeded its config ref with an inline object literal:

constconfigRef=useRef({ enabled, storage, storagePrefix, maxEntries, ttl });

useRef evaluates that argument on every render and reads it only to seed
the initial value, so every render after the first allocated a five-key object
that was thrown away — in every component using the hook, forever, to no effect.

This is the same pattern PR #6796 repaired in useSchemaPersistence
(useRef(createLocalStorageAdapter())), and it takes the same shape that one
used: hoist the value into a useMemo and hand the variable to useRef. The
difference that earned this its own card is that clearing the
react-hooks/refs warning required the change there and does not here — so
this is the half of that class the lint rule structurally cannot see. The rule
is left alone deliberately: widening it is a gate change with its own
population, and the card does not ask for it.

What changed

packages/react/src/hooks/useETagCache.ts — the five resolved values now come
from a useMemo keyed on all five, which is also what the ref's
useInsertionEffect write publishes. The ref, its readers and the insertion
effect's scheduling are untouched.

Nothing observable moves. The object's identity is private to the hook: nothing
exports it, and every reader (isExpired, setEntry, removeEntry,
clearCache, fetchWithETag) reads fields off configRef.current. The
returned callbacks keep their [] deps and their identities. A memo React
chooses to discard is harmless — it rebuilds an equal object, which is exactly
what every render used to do unconditionally.

The verification problem, and how it was answered

useETagCache has zero in-repo consumers, so there is no call site to
regress and no existing test that would notice either way. A green suite proves
nothing here on its own — the whole burden is on showing the allocation really
changed.

packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx measures
it directly. The config object has exactly one external observation point: the
argument handed to useRef on each render. The file wraps useRef (delegating
to the real one, so the hook still runs on genuine React) and records that
argument.

  • Pin 1 — the defect. Three renders with unchanged config must hand useRef
    the same object. Measured red on untouched origin/main before the fix
    was written, then green after it, assertion unchanged.
  • Pins 2a-2e — not an over-fix.useMemo on an empty dependency list would
    satisfy pin 1 while freezing the config at its first render. One case per key
    requires a fresh object carrying the new value when that key changes, so a
    dependency list missing any one of the five fails here.
  • Pin 3 — the recorder is live. Without it, an interception that stopped
    working would let the identity pins pass on an empty array.
  • Behaviour unchanged is carried by the existing
    useETagCache.configTiming.test.tsx, untouched and still green: the newest
    config reaches the stable callbacks, it is in place before a child layout
    effect of the same commit, callback identity survives config changes, and
    isExpired judges against the latest ttl.

Evidence

Suite, pnpm exec vitest run packages/react/src from the repo root (the
canonical invocation the config guard demands):

Test FilesTests
before, at a04d7c666 passed (66)819 passed (819)
after, at 0cb48f967 passed (67)826 passed (826)

Plus one file and seven tests, which is exactly the new pin file; no other file
moved.

Red-first, on the untouched source:

AssertionError: expected { enabled: true, …(4) } to be { enabled: true, …(4) } // Object.is equality
❯ packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx:89:21
Tests 1 failed | 6 passed (7)

"Compared values have no visual difference" is the defect stated precisely:
equal content, different identity.

Reverse-verified from the commit, prediction stated first. Reverting only
useETagCache.ts to a04d7c6 and leaving the pin untouched was predicted to
fail at line 89 with that same Object.is message, 1 failed / 6 passed. That
is what happened. The mutation was proven on disk before the run
(hash-object = f1cf647e, old literal present once, new form absent), and
the restore proven byte-exact after it: hash-object = 496af5bf =
git rev-parse HEAD:packages/react/src/hooks/useETagCache.ts, and
git diff HEAD empty. The script carried an EXIT/INT/TERM trap using absolute
paths. No build step is involved on either leg: the pin imports the hook by a
relative source specifier, so it reads the source file itself and no dist
can go stale under it.

Type-checkpnpm --filter @object-ui/react type-check, echoing
tsc --noEmit && tsc -p tsconfig.test.json, exit 0 after building the
dependency closure. --listFiles confirms this is a real reading and not a
zero-match green: the source file appears in the tsconfig.json program and
both test files appear in the tsconfig.test.json program.

Lint — the affected package's own eslint ., exit 0, 132 files in
eslint's own reported population
(--format json), 0 errors. All three
touched files are in that population. useETagCache.ts carries exactly one
warning before and after — react-hooks/set-state-in-effect, at line 227 on
origin/main and line 241 here, the same pre-existing setCacheSize call in
the mount hydration effect, shifted by the added comment. The new test file
carries 0.

Narrowing declared: repo lint is turbo run lint over every package; this ran
the affected package only. eslint.config.js configures no type-aware linting
(no parserOptions.project, no projectService), so a diff confined to
packages/react cannot move the verdict on a file outside it. CI runs the full
farm regardless.

Gatespnpm changeset:check (No changeset declares a major bump),
pnpm check:control-bytes (OK (scanned 5737 tracked text file(s))), and
pnpm check:vi-mock-specifiers (OK (... 498 carry a mock ...), up from 497,
so the new mock is inside the scan and not skipped). All at 0cb48f9.

Changeset

patch on @object-ui/react. Nothing a published consumer can observe changes
— public shape, callback identities and the values read off the ref are all the
same, and the only difference is an allocation that no longer happens. Not
minor: there is no new capability. Not major, per the fixed-group rule.

Scope

One card, one defect. #6818 is not addressed here — it is a stale-closure logic
gap, a different defect in the same directory, and it keeps its own card.
useSchemaPersistence is not touched; PR #6796 already repaired it.


Generated by Claude Code

`useRef({ enabled, storage, storagePrefix, maxEntries, ttl })` evaluated that
literal on EVERY render and kept only the first result, so every later render
of every component using the hook allocated a five-key object that was thrown
away. The values now come from a `useMemo` keyed on the five of them, which is
also what the ref's `useInsertionEffect` write publishes.
Nothing observable changes: the object's identity is private to the hook, the
returned callbacks keep their `[]` deps and their identities, and every reader
still reads fields off `configRef.current`. A memo React chooses to discard is
harmless — it rebuilds an equal object, which is what every render used to do
unconditionally.
`useETagCache` has zero in-repo consumers, so no existing test would notice
either way. `useETagCache.configAlloc.test.tsx` measures the allocation
directly by recording the argument handed to `useRef` on each render: pin 1
fails on the previous code (three renders, three distinct objects) and pins
2a-2e keep a `useMemo(..., [])` over-fix from passing it by requiring a fresh
config, carrying the new value, per changed key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT

Landing gated on CI; the verdict on the diff is below.

I checked the one thing the dispatch fenced, rather than taking the claim

The order said: "if the memoization changes WHEN anything re-runs, that is a semantics change,
not hygiene — stop and report."
The PR asserts the insertion effect's scheduling is untouched.
That is verified true, and the reason is visible in the diff:

useInsertionEffect(()=>{configRef.current=config;});// ← no dependency array

No deps array means it ran on every commit before and still does. So the publish happens
exactly as often as it used to, with an object carrying exactly the same five values; the only
thing that changed is how many objects get allocated to do it. Had the effect carried
[config], this would have been a scheduling change wearing a one-liner's clothes — that is
the case the fence existed for, and it isn't this one.

Also confirmed the diff needs no import change because useMemo is already imported and
already used twice
in this file on main. The fix is in the idiom the file already speaks.

The verification is the part worth keeping

The dispatch flagged that zero in-repo consumers means a green suite proves nothing, and the
answer is better than the one I asked for:

  • Pin 1 measures the defect at its only external observation point — the argument handed to
    useRef — and was taken red on untouched origin/main first. "Compared values have no visual difference" is the defect stated exactly: equal content, different identity.
  • Pins 2a–2e are the ones that matter most, and I did not ask for them.useMemo(…, [])
    would satisfy pin 1 perfectly while freezing the config at first render — a fix that passes
    the test and breaks the hook. One case per key means a dependency list missing any of the
    five fails. That is guarding against the plausible wrong fix, not just the absence of the
    right one.
  • Pin 3 checks the recorder is live, so a useRef interception that silently stopped
    working cannot let the identity pins pass against an empty array. That is the zero-hit rule
    turned on the instrument itself — a green that could be produced by measuring nothing is not
    a green.
  • Reverse verification carries the prediction first, the mutation proven on disk by hash
    (f1cf647e), and the restore proven byte-exact (496af5bf == rev-parse HEAD:…, git diff HEAD empty) under an EXIT/INT/TERM trap. Noting explicitly that no dist is in play because
    the pin imports by relative source specifier is the right thing to say — it forecloses the
    stale-artifact question rather than leaving me to wonder.

Counts move by exactly the new file: 66/819 → 67/826. Lint's population is stated as measured
(132 files, all three touched files in it) rather than assumed, and the single pre-existing
react-hooks/set-state-in-effect warning is tracked across its line shift 227 → 241 instead of
being reported as "unchanged" and left to me to check.

Scope and grading

No human-floor or governed-surface concern: packages/react/src/hooks/ only, no published type
widened, no gate touched, no new dependency edge.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3176.8 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-qETeoOTg.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)12.46KB4.71KB
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)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)243.36KB61.51KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
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.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.43KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.69KB54.58KB
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.83KB21.11KB
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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 11:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 33a3b3cAug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6817-usetagcache-config-alloc branch August 30, 2026 11:30
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): useETagCache re-allocates its config object on every render inside useRef

1 participant

@os-sam