Skip to content

finding(i18n): outside an i18next instance useSafeFieldLabel returns a FRESH object every render — measured 4 distinct identities in 4 renders, re-keying every consumer memo it feeds #5564

Description

@os-sales

Measured while fixing #4629. Filed unassigned; out of that card's scope (its file surface is packages/plugin-dashboard/**, this lives in packages/i18n). Duplicate-searched (keyword + useObjectLabel / useSafeFieldLabel / identity / I18nProvider): nothing open.

What was measured

useSafeFieldLabel()useObjectLabel(), which memoizes its whole returned object on react-i18next's [t, i18n] (packages/i18n/src/useObjectLabel.ts:58:617). When there is no i18next instance to bind to, useTranslation warns NO_I18NEXT_INSTANCE and hands back a fresh t/i18n each render, so that memo never holds.

Direct probe — one component calling useSafeFieldLabel(), mounted with no provider anywhere in the module, then re-rendered three times by its host:

NO i18next instance: { renders: 4, distinctFieldLabel: 4, distinctObject: 4 }
i18next instance present: { renders: 4, distinctFieldLabel: 1, distinctObject: 1 }

So the hook is stable exactly when an instance exists and unstable exactly when one does not.

Why that is the wrong way round

useSafeFieldLabel's own docstring names the no-provider case as the one it exists to serve:

Safe wrapper for useObjectLabel that falls back to identity functions when no I18nProvider is available. Suitable for plugin components that may be rendered outside an i18n context.

And useObjectLabel's memo docstring names the failure it was added to prevent:

Returning a fresh object on every render was busting downstream useMemo/useCallback deps in heavy consumers like ListView.filterFields, causing avoidable recomputation.

That protection is present with a provider and absent without one — i.e. it lapses in the configuration the safe wrapper advertises. The module-level SAFE_FIELD_LABEL_FALLBACK constant is not reached here: useObjectLabel() returns a (fresh) object, so the ?? never fires.

Measured consequence in a real consumer

ObjectDataTable's derivedColumns memo lists fieldLabel and fieldOptionLabel among its dependencies. Rendered outside an i18next instance, that memo recomputes on every render regardless of #4629 — measured on the #4629 branch as 8 buildFieldMeta calls over mount + 3 host re-renders (2 with a provider). #4629's fix is unaffected and correct; this is a second, independent identity source in the same dependency list, which is why ObjectDataTable.stableEmptyRows.test.tsx renders inside a real I18nProvider and says so in its docblock.

Every other memo keyed on these closures is exposed the same way — ListView.filterFields is named in the docstring above.

Options for whoever takes it

  1. Bind the fallback to the module-level constant. Return SAFE_FIELD_LABEL_FALLBACK (already frozen at module scope, already the intended shape) when there is no instance, instead of a fresh memo output. Smallest change; makes the advertised no-provider path genuinely stable.
  2. Stabilize useObjectLabel's memo key. Key on something that does not churn without an instance (e.g. i18n?.language plus a ready flag) rather than the t/i18n object identities.

Option 1 looks right on its face — the constant exists for exactly this case — but the two differ in behaviour once an instance appears later, so this wants a deliberate call rather than a drive-by. Not attempted here.


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions