Skip to content

[seam 2 of #8408] Public form /f/:slug never translates its section labels — I18nProvider IS mounted (measured); the blocker is buildSections dropping sec.name #8813

Description

@os-zhuang

Seam 2 of #8408, carved out by the PM ruling on that card (comment 5600634119). Seam 1 (the title / description read) shipped separately; this card is the i18n half, and it arrives carrying the measurement the ruling required before it could be scoped.

The warning this card was NOT allowed to inherit unmeasured

The triage seat (comment 5582235823 on #8408) raised this as a hard constraint, and the PM promoted it to a gate on the work:

useSafeFieldLabel degrades to identity functions rather than throwing when no I18nProvider is in scope — so a fix that compiles and ships can silently preserve today's behaviour.

A reading that cannot fail is indistinguishable from a reading that passed. So the ruling ordered one cheap measurement before any code: is I18nProvider actually mounted on /f/:slug?

MEASURED: yes, the provider IS mounted

Runtime measurement, on the real tree. ./FormPage was mocked so that the element App.tsx:215 renders for /f/:slug is the probe — it stands in FormPage's exact route position, and the real App plus the real main.tsx wrapper are used as they ship. The probe calls useI18nContext(), which throws outside a provider (packages/i18n/src/provider.tsx:717-722); that is what makes this reading capable of failing, unlike useSafeFieldLabel.

LIT CONTROL (discrimination): probe with no provider above it  -> NO
LIT CONTROL (positive):       probe under a bare I18nProvider  -> YES
SUBJECT:  real App at /f/:slug, wrapped as main.tsx wraps it   -> YES
CONTROL:  real App at /f/:slug with NO wrapper, same probe     -> NO

The fourth line is what gives the third one meaning: the YES is caused by the wrapper, not by anything inside App. Static chain, for the record: main.tsx:134-139 renders StrictMode, MobileProvider, I18nProvider, App, unconditionally, inside a Promise.all(...).finally() that gates the whole root render; App.tsx:215 declares /f/:slug as a plain route inside App's route table; there is no nested React root or portal between them. The route being "outside the console shell" is about ProtectedRoute and chrome, not about this provider.

The ruling's conditional does not fire. Mounting a provider is not this card's first work item, and a PR that mounts one is doing something the tree does not need.

⚠️ But the hazard the warning names is still live for the opposite reason: the provider is present, so useSafeFieldLabel will return real resolvers and a green test proves only that the resolver ran — not that a key was found. A regression test must assert a translated string against a bundle that actually carries the key, and must go red when the key is removed.

How to re-run it — the probe was deliberately not committed, because it is a measurement, not a pin. Spelled out rather than pasted, since markup shapes do not survive the body sanitizer:

  1. A Probe component whose body is let verdict = 'NO'; try { useI18nContext(); verdict = 'YES'; } catch { verdict = 'NO'; } and which renders that verdict into an element carrying a data-testid. Exactly one hook call on every path, so hook order stays stable.
  2. vi.mock('./FormPage', ...) returning a FormPage that renders the probe. Same for ./InternalFormRoute if you want the internal route as a second reading.
  3. vi.mock('sonner', ...) and a fetch stub answering 401, so AuthProvider settles instead of hanging.
  4. window.history.pushState({}, '', '/f/apply'), then render App — once wrapped in I18nProvider (the SUBJECT), once bare (the CONTROL).
  5. Read the probe's text content. waitFor the probe element first; the shell paints a splash before the route resolves.

One trap that cost a run: resolveBasename() (App.tsx:105) reads a base href tag and returns / when there is none. jsdom has none, so the path must be pushed bare — pushing the production /_console prefix matches no route and the probe never mounts, which looks exactly like "no provider" if you are not watching for it.

The real blocker: the key cannot be constructed at the render site

Re-verified on origin/main at 5dee0146d:

  • buildSections (apps/console/src/components/FormPage.tsx:522) returns { label: sec.label, columns, collapsible, collapsed, ... } — it copies the authored label and drops sec.name.
  • RenderableSection (:466) has no name member at all.

The convention is {ns}.objects.{objectName}._sections.{sectionName}.label, resolved by useObjectLabel().sectionLabel(objectName, sectionName, fallback). With sec.name gone, {sectionName} cannot be supplied at the render site. So carrying sec.name onto RenderableSection is a precondition for the section half, not a tidy-up that can follow it.

Do not reach for the authored label as the key instead. "The key is the section's stable name, never a guess derived from its authored label" is pinned by packages/plugin-form/src/__tests__/sectionLabelI18n.test.tsx:1-22, and that pin exists precisely because the two halves of the renderer had drifted once already.

⛔ Do not start from ats#59's inference

ats#59 reasoned that field labels rendering translated proves the bundle reaches the page, so the page must be skipping the _views / _sections resolvers that app pages use. #8408 corrected its own source report on this, the triage seat endorsed the correction and the PM confirmed it:

FormPage applies no client-side translation to anything, field labels included — those arrive already localized in the server payload. The distinguishing factor is not "resolver vs. no resolver"; it is that field labels have a server-supplied localized value while the title and section labels either have none or were never read. The observable symptoms are unchanged; the mechanism is different, and the mechanism decides the fix. Fixing per ats#59's inference misses the sec.name blocker entirely.

Scope

apps/console/src/components/FormPage.tsx, and the call sites #8408 enumerated:

  • carry sec.name onto RenderableSection in buildSections (:522) — the precondition above;
  • route the section heading through sectionLabel(objectName, sec.name, sec.label);
  • route the field label through fieldLabel;
  • decide, deliberately, what the page heading should do. Seam 1 made it the form's authored title; useObjectLabel resolves a view label as objects.{object}._views.{view}.label (packages/i18n/src/useObjectLabel.ts:481-482), which is a view key and this route has no view name in hand — so this is a real design question, not a mechanical substitution.

Compare the app path, which routes all three through useSafeFieldLabel() / useObjectLabel(): packages/plugin-form/src/ObjectForm.tsx, ModalForm.tsx, plugin-detail/src/renderers/record-details.tsx, app-shell/src/views/ObjectView.tsx.

Acceptance

  1. The regression test asserts a translated string on the /f/:slug route itself — not on a provider-wrapped harness built for the test.
  2. Reverse verification: remove the translation key from the bundle and the test must go red. Given the measurement above, this is the leg that matters — the provider is present, so nothing throws and nothing else will tell you the lookup missed.

Observations carried over from the seam-1 PR

Recorded there as not-filed, and landing in this card's blast radius:

  • resolveInternalForm (FormPage.tsx:1205) never reads form.title either: on /forms/:name, a form whose envelope carries no label heads the page with the raw view name. Not the same defect as [finding] Public form page /f/:slug renders the object API name as its <h1>: the fallback chain reads form.label, a key FormViewSchema rejects, while the config carries title — and description is never rendered #8408's (the envelope label is the correct heading there, and the flattened-overlay form.label arm genuinely is reachable on that route) — but it is the same header render this card edits.
  • FormSectionSpec.description (packages/app-shell/src/views/metadata-admin/form-spec.ts:259-260) is declared and still rendered nowhere. Seam 1 fixed the form-level description; the section-level one is one layer down, in the same buildSections this card must change anyway.
  • The payload.form?.label arm survives in loadPublicForm, still naming a key FormViewSchema rejects. Seam 1 left it deliberately — form.title now outranks it, and its only reachable shape (a flattened overlay from the public resolver) cannot be exercised from this repo. If this card touches that chain, that arm is the thing to settle.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

appsbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seati18npm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions