Skip to content

fix(i18n): hold useObjectLabel's memo when no i18next instance is bound - #5585

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5564-safe-field-label-identity
Aug 21, 2026
Merged

fix(i18n): hold useObjectLabel's memo when no i18next instance is bound#5585
os-sales merged 1 commit into
mainfrom
claude/issue-5564-safe-field-label-identity

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#5564

Verified at b492c9796 (the final commit on this branch; every gate below ran on that tree).

The defect

useSafeFieldLabel() delegates to useObjectLabel(), which memoizes its whole returned object on react-i18next's [t, i18n]. With no i18next instance bound, useTranslation warns NO_I18NEXT_INSTANCE and rebuilds its return value out of a fresh empty object every render (const finalI18n = i18n || {}, which then feeds that hook's own useMemo deps) — so i18n arrives with a new identity each render and the memo never holds.

Re-measured here by ablation: 4 distinct returned objects across 4 renders with no instance, 1 with one. The protection is present exactly where it is not needed and absent exactly where useSafeFieldLabel's docstring says it exists to serve.

One refinement to the card's account: only i18n churns. t is already stable when unbound — react-i18next returns a module-level notReadyT from a module-level notReadySnapshot. The single churning dependency is i18n.

Option 2 was chosen, and why not option 1

Option 1 does not fix the consumer the memo's own docstring names as its reason to exist.ListView.filterFields reaches the hook through useListFieldLabel, which calls useObjectLabel() directly (packages/plugin-list/src/ListView.tsx:683), not useSafeFieldLabel(). Option 1 only changes the wrapper, so ListView keeps the defect — along with 20+ other direct useObjectLabel() call sites across app-shell, plugin-dashboard, components and react. packages/plugin-timeline/src/ObjectTimeline.tsx:37 even carries its own hand-rolled useObjectLabel() ?? OBJECT_LABEL_FALLBACK wrapper, which option 1 would miss too.

The 27-vs-5 counts from the dispatch were re-verified independently and are exact: the memo returns 27 members, SAFE_FIELD_LABEL_FALLBACK has 5. Option 1 would therefore also cost a hand-maintained 27-member parallel shape plus a pin test to stop it drifting. Option 2 needs no parallel shape at all, because there is only one code path.

Option 2 also preserves today's return values exactly and changes only identity, which is precisely the defect.

The change

Both memo dependencies are pinned to module-level constants while no instance is bound. The substitution is unobservable rather than merely convenient, and that is the correctness argument:

  • every t() call in the module sits inside a for (... of getAppNamespaces()) loop;
  • getAppNamespaces() returns the empty array under exactly the same "is there a usable instance" predicate;
  • so while the substitution is in effect, the closures cannot read t or i18n at all.

The predicate now has one definition (hasUsableI18nInstance) used by both the memo key and getAppNamespaces, so the two cannot drift. NO_INSTANCE_T still mirrors react-i18next's own not-ready t so behaviour would not change silently if that reachability argument ever stopped holding.

When an instance appears the dependencies become the live values again, so a provider mounting after first render recomputes exactly once. On the bound path the dependency list is literally [t, i18n] as before — no behaviour change where the memo already held.

The now-false comment on useSafeFieldLabel ("It already returns a stable memoized object") is corrected.

No API change: no new exports, no signature changes, identical returned surface on both paths.

Tests

packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx, 5 cases, all asserting on identity / recompute counts rather than rendered output — nothing renders wrong today, so a rendering assertion is green against the broken code.

  1. no instance, 4 renders, one identity (and one fieldLabel identity)
  2. same through useSafeFieldLabel
  3. with an instance, one identity — regression guard on the path that already worked
  4. the late-instance transition (triage's dispatch clause): the provider element sits at the same tree position for every render, so the probe is updated rather than remounted when the instance arrives; asserts exactly two identities over six renders, that the first resolves no translations and the second does
  5. the member set is the same with and without an instance, pinned at 27, and objectLabel is callable unbound — this guards record:reference-rail, which does useSafeFieldLabel().objectLabel(...) and would get "objectLabel is not a function" if the no-instance path were ever narrowed to the 5-member fallback

The tests deliberately detach react-i18next's process-global instance and assert that precondition, because createI18n registers itself globally via initReactI18next; without that, a probe meant to run unbound would silently bind to an instance from an earlier test and assert nothing.

Reverse-verification

Fix committed first, then reverted (source only) under a trap ... EXIT INT TERM restore. Mutation confirmed on disk with anchored counts in both directions (fix markers 0, pre-fix gate and destructure 1 each) plus git diff --stat.

Ablated result — VERDICT command-exit 1, 3 failed / 2 passed:

expected 4 to be 1 (no instance, 4 renders)
expected 4 to be 1 (useSafeFieldLabel, 4 renders)
expected 3 to be 1 (late-instance test, unbound phase)

The first two reproduce the card's measurement exactly. The two that stayed green are the right two — the with-instance guard and the shape pin — which is what makes this an ablation rather than a blanket break. Restored and re-run green; git status --porcelain empty, so the tree is byte-identical.

No dist is in the loop: the test imports the ablation target by relative path from source, so there is no build step whose staleness could fake the result.

Gates

All at b492c9796, exit codes captured before any pipe:

gateverdict
pnpm --filter '@object-ui/i18n^...' buildexit 0 (packages/core build: Done)
pnpm --filter @object-ui/i18n type-checkexit 0, script echoed tsc --noEmit && tsc -p tsconfig.test.json
pnpm --filter @object-ui/i18n lint34 problems (0 errors, 34 warnings) — all warnings pre-existing any usages outside this diff; the new test file contributes none
pnpm exec vitest run packages/i18n/Test Files 50 passed (50) / Tests 878 passed (878)
pnpm exec vitest run packages/plugin-detail/Test Files 90 passed (90) / Tests 848 passed (848)
pnpm exec vitest run packages/plugin-dashboard/Test Files 72 passed (72) / Tests 653 passed (653)
node scripts/check-control-bytes.mjscheck-control-bytes: OK (scanned 4638 tracked text file(s))
node scripts/check-changeset-presence.mjs2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
node scripts/check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.

Both consumer packages were run deliberately:plugin-detail holds the two whole-object useSafeFieldLabel() consumers the dispatch flagged (record-reference-rail calling i18n.objectLabel(...), and record-related-list), so it is the package a shape change would break first; plugin-dashboard holds ObjectDataTable, the consumer whose derivedColumns recomputation the card measured.

Declared narrowing

  • Repo-wide pnpm lint was not run;pnpm --filter @object-ui/i18n lint was. This cannot hide a failure: the diff touches only packages/i18n, and eslint.config.js enables no type-aware linting (no projectService, no parserOptions.project), so this change cannot move the verdict on any file it does not contain.
  • 2 of roughly 15 consumer packages were tested locally. This one is a real narrowing — the hook is consumed broadly and a behaviour change could in principle surface in plugin-list, plugin-grid, plugin-form, app-shell, plugin-charts, plugin-timeline, components, react, fields, plugin-report, plugin-kanban or plugin-tree. CI runs the full farm.

Notes

  • packages/plugin-charts/src/ObjectChart.tsx:268-271 carries a hand-rolled ref workaround for this exact defect ("the i18n hook returns a fresh function reference on every render ... trigger an infinite refetch loop"). This fix makes that workaround removablefieldOptionLabel is now identity-stable with or without a provider. Not removed here; it is outside this surface and wants its own card. The same applies to plugin-timeline's useSafeObjectLabel.
  • Scope held to packages/i18n/src/useObjectLabel.ts, its tests, and one changeset. No consumer package was modified.

Generated by Claude Code

react-i18next rebuilds its return value from a fresh `{}` every render when it
has no instance to bind to, so the `i18n` object `useObjectLabel` keyed its memo
on churned on every render — the memo never held on exactly the no-provider path
`useSafeFieldLabel` advertises (4 distinct objects in 4 renders, against 1 with
an instance), re-keying every consumer memo it feeds.
Pin both memo dependencies to module-level constants while no instance is bound.
The substitution is unobservable: every `t()` call in the module sits inside a
`for (… of getAppNamespaces())` loop, and `getAppNamespaces()` returns `[]` under
exactly the same predicate. When an instance appears the dependencies become the
live values again, so a provider mounting late recomputes exactly once.
Fixes#5564
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.9 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-CZvIH0HR.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.88KB113.68KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.96KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.48KB20.67KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

Labels

Projects

None yet

2 participants

@os-sales@claude