Skip to content

feat(core): wire the translator adapter into i18n - #4404

Open
AKnassa wants to merge 7 commits into
meta:mainfrom
AKnassa:ak-4029-i18n-translator-adapter
Open

AKnassa wants to merge 7 commits into
meta:mainfrom
AKnassa:ak-4029-i18n-translator-adapter

Conversation

@AKnassa

@AKnassa AKnassa commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Teams that already run an i18n library (react-intl, i18next, LinguiJS) can now have Astryx's own built-in strings go through their translation setup instead of Astryx's bundled formatter.

Why

The Translator interface was already exported publicly with nothing behind it — the type shipped, InternationalizationProvider had no way to accept one, and resolve() hard-coded the runtime. The CLI docs even advertised it as unbuilt, pointing readers at this issue.

What changed

InternationalizationProvider takes an optional translator. Astryx still does its own lookup — your overrides, then your messages, then the parent language (pt-BR → pt), then the shipped English catalogue — and hands your translator the finished ICU message, never a raw @astryx.* key. That means you don't have to load Astryx's catalogue into your own store.

Every Astryx string goes through your translator, including the ones with nothing to interpolate — which is most of them (162 of 197 shipped keys; 171 of 228 callsites). values is undefined for those calls. This is deliberate: if only the interpolating quarter reached your runtime, a catalogue keyed on the English text would translate Go to page {page} and silently miss Close, with no signal that half your UI never arrived. Two things follow — your runtime's missing-translation path runs on every string, so keep it cheap; and this is what makes the issue's one-provider example (translator, no messages) actually do what it claims.

format must return a string. Anything else — react-intl's ReactNode[] for rich text, i18next's null for a miss — warns once in development and falls back to Astryx's resolved message, because that output also feeds aria-label and title, where a non-string becomes [object Object] or drops the attribute. Exceptions are not caught: a broken adapter should be loud, not quietly papered over.

Leaving the prop off is a no-op: the bundled path and its formatter cache behave exactly as before, at the cost of one != null comparison.

Needs a maintainer call

This adds a new public prop on a core provider, plus a new optional field on the publicly-exported context value. The shape isn't invented — the shipped Translator signature format(message, values?, locale?) forces the lookup/format split, and the issue names the prop verbatim — but whether the API should exist is a maintainer's decision, not a contributor's.

Two specifics worth confirming:

  1. Where fallback lives. The issue lists this as an open question. The shipped type answers it by construction — format takes a message, not a key — and the code follows that answer: Astryx owns the lookup chain, the adapter owns formatting.
  2. Value-less strings go through too. The alternative is to short-circuit them, which is what the first revision of this PR did. It left the adapter covering roughly a quarter of Astryx's strings while the docs advertised full catalogue override, so I moved the branch. Astryx pays nothing either way — the ICU parser is skipped in both cases; the cost is one call into a runtime the consumer opted into.

What this does not do

No catalogue-merge helper (the issue lists it as an optional nice-to-have).

No server/RSC path. translator holds a function, so it can only be passed from a client component — a server-rendered subtree stays on the bundled runtime, and a future server resolver will need to thread translator too. Relatedly, a nested InternationalizationProvider replaces the translator rather than inheriting it, matching how messages and overrides already behave. Both are documented and pinned by tests.

The react-intl example added to the docs is written from its public API but not executed, since react-intl isn't a dependency of this repo. Anyone who uses it should sanity-check that snippet — particularly the values cast, which exists because react-intl types values more narrowly than Astryx's Record<string, unknown> and its rich-text overload returns ReactNode[].

How to check it

pnpm vitest run packages/core/src/i18n/ — 73 tests (30 in this directory before the PR, 47 at the first revision). They assert that the translator receives the resolved message rather than the key (proving the fallback chain stayed inside Astryx), that value-less strings reach it, that a non-string return degrades safely instead of reaching an aria-label, and that a live translator or locale swap propagates. Edge coverage includes odd locale tags, empty-string catalogue entries, parent-locale overrides, and nested providers.

Four assertions were mutation-tested. One turned out to be fake: the dep-array test passed even with ctx.translator deleted from useTranslator's useCallback, because the provider's messages ?? {} allocates a fresh object on every memo recompute, so ctx.messages invalidated the callback on its own. Pinning messages to a stable reference kills the mutant.

Full packages/core suite: 225 files / 5254 tests, exit 0.

Refs #4029

The `Translator` interface already shipped as a public export from
`@astryxdesign/core/i18n` with zero runtime behind it, and the CLI docs
advertised it as unbuilt. Thread it through: `InternationalizationProvider`
gains an optional `translator` prop, carried on the context value and
consumed at the final format step in `resolve()`.

Astryx keeps its own lookup chain — overrides, then `messages`, then the
parent locale, then the shipped `en` catalog — and hands the translator the
already-resolved ICU message rather than an `@astryx.*` key. That split is
forced by the shipped signature `format(message, values?, locale?)`, so no
API shape is invented here. Omitting the prop is a no-op: the bundled
`intl-messageformat` path and its formatter cache are unchanged, and
value-less messages still short-circuit before the translator.

Refs meta#4029
@vercel

vercel Bot commented Jul 26, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Sep 26, 2026 4:30am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 26, 2026
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Jul 26, 2026
@github-actions

github-actions Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

1 of 420 shot(s) changed. View the report

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
SideNav Resizable in AppShell probe dark 3,208
SideNav — Resizable in AppShell — probe dark
BeforeAfterDiff
Before visual regression frame After visual regression frame Pixel difference frame

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

The translator sat behind the `values === undefined` short-circuit, so 171
of astryx's 228 `t()` callsites never reached it — while the docs promised
an app catalog could override any astryx string by keying on its text. Only
the interpolating quarter could, and the miss was silent.

Move the translator ahead of the short-circuit. A single consumer catalog
now owns all of astryx's strings, and issue meta#4029's headline example —
`translator` with no `messages` — does what it claims. The bundled path
keeps its fast return; omitting the prop costs one extra `!= null` compare.

Also:

- Guard what `format` returns. A translator is consumer code and its string
  return type is not enforced at runtime — react-intl hands back a
  ReactNode[] for rich text, i18next can return null for a miss. Astryx's
  output feeds aria-label and title, so a non-string warns once in dev and
  falls back to the resolved message instead of leaking "[object Object]"
  into the accessibility tree. Exceptions stay uncaught, deliberately: a
  broken adapter should be loud.

- Tolerate `translator={null}`. The prop is typed optional so TS callers
  cannot pass null, but a JS consumer writing `translator={on ? t : null}`
  can, and `.format` on null took down every string in the tree.

- Document the shapes that bite: a translator holds a function so it only
  crosses from a client component, a nested provider replaces it rather
  than inheriting it, and `locale` is the locale asked for — not
  necessarily the language `message` is written in.

- 14 edge tests: odd locales, empty-string catalog entries, parent-locale
  overrides, placeholder-introducing overrides, live translator swap and
  removal, nested translators. Mutation testing caught one of the new
  dep-array tests being fake — the provider's `messages ?? {}` allocates a
  fresh object per memo recompute, so omitting `messages` invalidated
  useCallback by itself and `ctx.translator` was never exercised. Pinned to
  a stable reference; the mutant dies now.
Upstream meta#4269 (RTL direction API) added `direction` to the i18n
context, a `dir` prop to the provider, and useDirection /
getLocaleDirection exports — the same three spots this branch
touches for `translator`. Every conflict was add-add, so both
features are kept side by side.

- provider memo now carries {locale, direction, messages,
  overrides, translator} with all five deps
- SYNC list keeps this branch's t.client.ts -> translator.ts fix
  (t.client.ts no longer exists in the tree); upstream's
  useDirection and getLocaleDirection entries added alongside it

Verified: 83 i18n tests and 59 useTableSortable tests pass,
typecheck and lint exit 0, and the net diff against upstream/main
is unchanged at 1123+/15- across the same 10 files.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for wiring the Translator adapter through the provider — the lookup staying in Astryx, the resolved-message (never key) handoff, and the test coverage are clear.

Approval is waiting on a maintainer decision: this adds a new public translator prop (and context field), and a maintainer needs to provide or identify the governing spec for that addition, settling (1) that Astryx keeps lookup/locale fallback and the adapter only formats the resolved ICU message, and (2) that every resolved string, including value-less ones, is delegated. Please do not treat this as a request to remove or split the change for that reason alone.

One independent fix is also needed before approval: when translator.format returns a non-string, the fallback in resolve returns the raw ICU template (for example Go to page {page, number}), which reaches Pagination's visible label and aria-label. Please format the resolved message with its values through the bundled formatter on that path (raw message only when there are no values) and update the test/docs that currently expect the template.

[Automated review]

Resolved conflicts (upstream meta#4696 replaced resolve() with a
getResolve(locale, messages, overrides) closure stored on the context as
`translate`):

- packages/core/src/i18n/resolve.ts: took upstream's getResolve/getLookup
  shape and moved the translator delegation into the returned closure;
  getResolve gains an optional fourth `translator` argument.
- packages/core/src/i18n/InternationalizationProvider.tsx: build
  `translate: getResolve(locale, messages ?? {}, overrides, translator)`
  and keep `translator` in the context value and the useMemo deps.
- packages/core/src/i18n/InternationalizationContext.ts: keep both the
  PR's `translator?` field and upstream's `translate` field.
- packages/core/src/i18n/useTranslator.ts: took upstream's
  `return ctx.translate` (the PR's useCallback change no longer applies).

Merge fallout: translator.test.tsx called the removed `resolve` export, so
it now goes through a local getResolve shim like resolve.test.ts's, and
the dep-array comments point at the provider memo instead of useCallback.
F2 (astracat): when `translator.format` returned a non-string, resolve
returned the raw ICU template, so with an i18next-style translator that
returns null every Pagination page button got the accessible name
"Go to page {page, number}". The non-string path now warns once and falls
through to the bundled formatter, so the output matches the no-translator
path (raw message only when there are no values) and reuses the formatter
cache.

- resolve.ts: `if (typeof output === 'string') return output;` then
  warnOnce and fall through instead of `return result`.
- translator.test.tsx: the four bad-return cases (undefined, null,
  ReactNode[], number) now expect "Go to page 5"; new Pagination test
  with a null-returning translator asserts a "Go to page 1" button and no
  aria-label containing "{". Red before the fix: expected
  'Go to page {page, number}' to be 'Go to page 5', and no button named
  "Go to page 1". Green after: 44/44.
- translator.ts, InternationalizationProvider.tsx,
  InternationalizationProvider.doc.mjs, the CLI internationalization doc
  and the changeset now say the fallback is the bundled formatter.
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Sep 26, 2026
@AKnassa

AKnassa commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I've pushed an update at 5f2d7b6.

  • I merged main in f08c9f3. It conflicted in all four i18n source files because refactor(i18n): pre-build lookup map for translations #4696 replaced resolve() with the getResolve() closure. The translator is now bound inside that closure, via an optional fourth argument to getResolve. The provider passes it and keeps it in the memo deps, and useTranslator is back to main's ctx.translate.
  • You were right about the non-string fallback, fixed in 5f2d7b6. With a translator that returns null, every Pagination page button got the accessible name "Go to page {page, number}", and my own test was pinning that output. The non-string path now warns once and falls through to the bundled formatter, so the output matches what you get with no translator. The bad-return tests now expect "Go to page 5". A new Pagination test checks that no aria-label contains {. The docs and the changeset now describe the fallback as the bundled formatter.

Still open, for a maintainer: this adds a public translator prop, and the draft contract in #6048 leaves it as OQ3. Could someone confirm (1) that Astryx keeps lookup and fallback while the adapter only formats, which INV14 there already supports? And for (2), should value-less strings go through the adapter too? The context's translator field is no longer read internally. I've kept it for now, and I'm happy to drop it if you'd prefer a smaller public surface.

On my side, the PR description still needs updating to match.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for wiring the Translator adapter through the provider, and for the follow-up push — I verified the non-string fallback fix at this revision: a bad adapter return now falls through to the bundled formatter, and the tests expect formatted labels (for example "Go to page 5") with no raw placeholders in Pagination's accessible names. That blocker from the previous review is resolved.

Approval is still waiting on a maintainer decision. This adds a new public translator prop on InternationalizationProvider, plus a translator field on the exported context value, and a maintainer needs to provide or identify the governing spec for that addition, settling:

  • that Astryx keeps lookup and locale fallback, and the adapter only formats the resolved ICU message;
  • that every resolved string, including value-less ones, is delegated to the adapter; and
  • whether the context translator field is kept or dropped, since nothing internal reads it now and you noted you are happy to drop it for a smaller public surface.

Please do not treat this as a request to remove or split the change for that reason alone. Once a maintainer settles that spec question, no other fix from this review is outstanding — though the PR description update you already flagged would help the next reader.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — 5f2d7b6a Deployed Sep 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants