facebook / facebook/astryx

i18n: server-side t() for RSC — reduce client bundle by ~1,900 LOC

Open
#4,030 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

# i18n: server-side path for React Server Components

## Status

Design under investigation. A prototype validates the runtime mechanism; the right API surface is still open. This issue is for discussion — nothing to review yet.

## Problem

`useTranslator` currently reads locale + messages from React context. Any astryx component that calls it must be a Client Component, and its compiled code ships to the client. Framing borrowed from the scaffold PR discussion (https://github.com/facebook/astryx/pull/3765#issuecomment-4986624599): astryx components fall into three categories —

- **Client Components** — need client-side React APIs (state, refs, effects, event handlers)
- **Server Components** — never need the client; can render on the server only
- **Pure Components** — could run in either environment

Making more astryx components Client Components does not eliminate most RSC benefits — consumers still get server-side data fetching, server logic, and page composition. But **Pure Components would not contribute to the client-side JavaScript bundle at all**, which is the specific benefit the client-only i18n hook forecloses.

**Which astryx components could actually be Pure today if `useTranslator` were server-safe?** Very few. To qualify, a component must have no `useState`, `useEffect`, `useRef`, and no event handlers bound to DOM elements — DOM event handlers can't cross the RSC boundary, so components that internally render `` are Client Components regardless of i18n. Even some display components like `` need the client — it uses `useRef` + lazy Suspense for its overflow-tooltip feature.

Auditing the current library: of ~214 `.tsx` components, ~14 use `useTranslator` and have no other client-only React APIs on the file itself. Of those, **only 2 have zero interactive internals** — `Citation` and `ChatMessageMetadata` are the pure-passive cases. The other 12 have internal `onClick`/`onRemove`/`onSelect` bindings on DOM elements and are Client Components regardless of what i18n does.

So the realistic bundle-cost story is small today. Making `useTranslator` server-safe unlocks maybe 2–5 currently-Pure components, and creates the option-space for future ones (a ``, or a hypothetical stripped `` without the tooltip escape hatch). Real but bounded.

## What a server-side path looks like

The RFC (#3641) sketched a server variant selected via the `"react-server"` export condition. A prototype validates this works. The server module can be small:

```ts
// index.react-server.tsx
import {cache} from 'react';
import {resolve} from './resolve';

interface Store {
locale: string;
messages: MessagesByLocale;
overrides?: Overrides;
}

const getRequestStore = cache((): {current: Store | null} => ({current: null}));

export function runWithLocale(store: Store, fn: () => T): T {
getRequestStore().current = store;
return fn();
}

export function useTranslator(): TranslatorFn {
const store = getRequestStore().current;
return (key, values) =>
resolve(key, values, store?.locale ?? 'en', store?.messages ?? {}, store?.overrides);
}
```

Wired via the export map:

```json
"./i18n": {
"source": "./src/i18n/index.ts",
"types": "./dist/i18n/index.d.ts",
"react-server": "./dist/i18n/index.react-server.js",
"default": "./dist/i18n/index.js"
}
```

Consumer usage:

```tsx
// app/[locale]/page.tsx (server component)
import {Citation} from '@astryxdesign/core';
import {runWithLocale} from '@astryxdesign/core/i18n';

export default async function Page({params}) {
const {locale} = await params;
return runWithLocale({locale, messages: locale === 'fr' ? FR_CATALOG : {}}, () => (

));
}
```

Verified end-to-end: `/en` and `/fr` render locale-correct HTML with zero astryx JS in the client bundle for pure-passive components. Works on both Node and Edge runtimes with the pure-`cache` implementation above — no `node:async_hooks` dependency.

## Two non-obvious things the prototype had to solve

1. **`AsyncLocalStorage.run(...)` alone does not survive React's async rendering.** React schedules component renders on separate tasks; the ALS scope ends before nested components run. `React.cache` (or another per-request holder) is what actually carries state across render boundaries. Same technique next-intl uses.

2. **`'use client'` on the hook file breaks the export condition.** Marking `useTranslator.ts` as `'use client'` makes bundlers register it as a client boundary — the server variant is never resolved for anything that imports the hook. Only the `` component itself genuinely needs the directive.

## The API-surface question

Independent of whether the runtime path works, calling something `useTranslator` and using it as a plain server-side function is at least confusing. The `use*` naming pattern is a widely-recognized signal for "obeys the Rules of Hooks" — used by upstream `eslint-plugin-react-hooks` and various internal lint rules as a purely syntactic check. Anything matching `/^use[A-Z]/` gets flagged as a hook regardless of what it actually does inside.

Options:

- **A. Keep the hook name; ship the server variant behind the `"react-server"` export condition.** Same import, environment-conditional resolution. Simplest for consumers moving a component between server and client. Downside: the server implementation is a plain function that happens to be named like a hook, which is what the naming pattern is trying to warn about.
- **B. Add a hookless companion** (`getTranslator()` or `translate()`) for the server case. Two names, but each is honest about its constraints. Downside: two APIs to teach and maintain.
- **C. Do nothing runtime-side.** Keep the client-only hook; accept the small bundle cost given how few components genuinely benefit today.

The prototype validates (A) at runtime. (B) hasn't been tried. (C) is the current state — and given the ~2 realistic wins in today's library, it's a reasonable position on its own merits.

## Open questions

1. **Is the win worth the API expansion?** Given ~2 pure-passive candidates today, is this solving a real problem or creating optionality for hypothetical future components?
2. **Hook shape vs. hookless surface.** If we do add a server path, is preserving `useTranslator` (A) better than a hookless companion (B)? What does each cost in tooling friction and consumer mental model?
3. **Framework coupling.** Where does `runWithLocale` get wired? Direct in the page, a `@astryxdesign/core/i18n/next` subpath, or a framework-agnostic provider? Each has different reach.
4. **Concurrent-render isolation** for the `React.cache` holder. The prototype didn't exercise sibling `runWithLocale` calls in the same request tree; needs verification if we ship.
5. **Priority vs. `Translator` adapter (#4029).** External i18n runtime interop may deliver more consumer value than shaving bundle cost off a handful of components.

## Non-goals

- Not deprecating the client hook — interactive components keep using it.
- Not switching to a CSS-based mechanism — i18n needs per-locale message lookup at render time.
- Not shipping first-party astryx translations (tracked separately).

## Related

- RFC #3641
- PR #3765 (v1 client implementation)
- #4029 (Translator adapter for external i18n runtime interop)
- Precedent: [`next-intl` server-side pattern](https://next-intl.dev/docs/environments/server-client-components) uses the same `"react-server"` export condition + `React.cache` combination.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.