langsys-js-react 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README-SSR.md +144 -170
- package/README.md +339 -34
- package/dist/app-zQKL7-vo.d.mts +61 -0
- package/dist/app-zQKL7-vo.d.ts +61 -0
- package/dist/babel.d.mts +122 -0
- package/dist/babel.d.ts +122 -0
- package/dist/babel.js +547 -0
- package/dist/babel.js.map +1 -0
- package/dist/babel.mjs +545 -0
- package/dist/babel.mjs.map +1 -0
- package/dist/index.d.mts +140 -76
- package/dist/index.d.ts +140 -76
- package/dist/index.js +502 -97
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +423 -89
- package/dist/index.mjs.map +1 -1
- package/dist/next-loader.d.mts +8 -0
- package/dist/next-loader.d.ts +8 -0
- package/dist/next-loader.js +623 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next-loader.mjs +621 -0
- package/dist/next-loader.mjs.map +1 -0
- package/dist/next.d.mts +15 -0
- package/dist/next.d.ts +15 -0
- package/dist/next.js +41 -0
- package/dist/next.js.map +1 -0
- package/dist/next.mjs +37 -0
- package/dist/next.mjs.map +1 -0
- package/dist/server.d.mts +36 -0
- package/dist/server.d.ts +36 -0
- package/dist/server.js +57 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +25 -0
- package/dist/server.mjs.map +1 -0
- package/dist/vite.d.mts +14 -0
- package/dist/vite.d.ts +14 -0
- package/dist/vite.js +623 -0
- package/dist/vite.js.map +1 -0
- package/dist/vite.mjs +621 -0
- package/dist/vite.mjs.map +1 -0
- package/package.json +38 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## 0.7.0 - 2026-11-14
|
|
2
|
+
|
|
3
|
+
> Requires the base SDK `langsys-js-typescript@^0.7.4`, which carries write gating (ticket 838), the move (`previousCategory`), the previous messages category (`messagesPreviousCategory`), `<plaintext>` among the elements that are never translated, numbers formatted for the locale inside a translation's plural or gender form, and the VAR-3 marker reader this release's transform emits for.
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Moving content to another category (spec MOVE-1 to MOVE-5).** `<Translate>` and `<Phrase>` take `previousCategory`, and `inCategory`, `isCategoryRef`, `CategoryRef` and `CategoryMoveOptions` are re-exported for `t('Buy now', inCategory('Checkout', { previousCategory: 'Header' }))`. The binding only forwards it: the base SDK sends `previous_category` on the registration a miss triggers, keeps it out of `custom_id`, renders the previous category's translation until the new one is in the catalog, and stamps the host so hydration agrees. Verified against a local API: the four moved items came back with their own translations (`memory_translated: false`) and were not registered again after the refresh. Needs base SDK `^0.7.3`.
|
|
8
|
+
- **A nested `<Phrase>` or `<Translate>` that names no category takes its block's category and previous category (spec MOVE-4).** A block that holds a component renders through the core's DOM class, and a unit inside it now registers and renders under the block's `category` and `previousCategory` (a React context the block provides), on the client, in a request scope and on hydration, where it was registered with no category. A unit that names its own `category` (`''` included) takes neither and moves only with its own `previousCategory`. `category` is now absent rather than `''` when a unit names none; a unit outside any block still has none. `inCategory`, `isCategoryRef`, `CategoryRef` and `CategoryMoveOptions` are also exported from `langsys-js-react/server`, the same references as the main entry's.
|
|
9
|
+
- **Write gating (ticket 838).** Public keys are read-only; the server decides per session whether it may register content, and read-only sessions report the page URL only (never phrase content) so Langsys can visit and register from an allow-listed address.
|
|
10
|
+
- **`useWriteEnabled()`** → `boolean | undefined`. Tri-state, and the states are distinct: `undefined` means authorization hasn't landed (and is the value throughout SSR), `false` means read-only, `true` means this session registers directly. Don't collapse it to a boolean or default `undefined` to `false`.
|
|
11
|
+
- **`setWriteGrant(grant)`** → `Promise<void>`, also on `LangsysApp`. For login-walled apps: supply a short-lived JWT after `init()` and the session is re-authorized with an `X-Write-Grant` header. It re-authorizes rather than only storing the token.
|
|
12
|
+
- **`writeGrant`** init option, inherited from the base config type. Prefer the function form — grants are short-lived and a static string expires while the app is still mounted.
|
|
13
|
+
- **`WriteGrant`** type re-exported. The raw **`writeEnabled`** signal is deliberately *not* re-exported: it is the one signal that needs adapting, since it is `undefined` for the whole of a server render, and re-exporting it alongside `useWriteEnabled()` would offer a supported-looking way to defeat the hydration guard. Advanced consumers can import it from `langsys-js-typescript` directly.
|
|
14
|
+
- **`useNotifyNavigation(location)`** and **`notifyNavigation()`** (re-exported by reference). Content that stays mounted across a route change — a persistent layout, a header — is not re-rendered by React when only the route changes, so the SDK never learns its phrases now appear on the new page. Pass the router's location to the hook (React Router's `useLocation().key`, Next's `usePathname()`), and every mounted translated node looks itself up again at the new URL.
|
|
15
|
+
- **Server messages.** `resolveServerMessages(body, { key | resolver, pieces? })` and `renderServerMessage(entry, category?)` re-exported by reference, with `DEFAULT_SERVER_MESSAGE_CATEGORY` and the `ServerMessage` / `ResolveServerMessagesOptions` / `ServerMessagePieces` types; `messagesCategory` and `messagesPreviousCategory` init options inherited from the base config. Entries are read only from where the server attached them — a configured `key` or an app `resolver` — beside the framework's own error body, which is never searched or changed; `field` and `code` are the framework's own, passed through. **`useRenderServerMessage()`** renders entries in components and re-renders them when the locale changes: the translation of the entry's `template` when the catalog has one, its `message` otherwise. Works unchanged for the entries an Inertia server hands to the page after a failed form.
|
|
16
|
+
- **Legacy-key migration.** The core's `legacyKeys` init option (inherited from the base config) lets an app keep calling `t()` / `useT()` with its i18next, vue-i18n or plain JSON keys: a key the files hold registers and renders as its converted value, never as the key. `LegacyFormatError` and the `LegacyKeyFile` type are re-exported; `LangsysApp.Translations.setLegacyKeys` turns the mode on or off later.
|
|
17
|
+
- **Catalog snapshots.** `LangsysApp.loadSnapshot(snapshot, locale?)` (the core's, by reference) loads an exported catalog synchronously before the first render; the fetched catalog still replaces it. `SnapshotError` and the `CatalogSnapshot` type are re-exported.
|
|
18
|
+
- **Server rendering per request — `langsys-js-react/server`.** A Node-only entry over the core's request scope: `renderInRequestScope({ locale, catalog?, url?, credentials? }, render)` renders one request inside a scope of its own and returns the HTML, the seed the client hydrates from (`LangsysApp.seedCatalog`), and `close()` for after the response; `installRequestScopeStorage()` plus the core's `createRequestScope` / `scope.enter()` serve a host that cannot wrap its render. The entry loads no React and also exports `LangsysApp` and `createLocaleStore`, the same ones as the main entry, so a server process, a Next Server Component included, calls `init()` through it; a scope fetches and registers with the project and key `init()` supplies, or with its own `credentials: { projectid, key }`, so two requests for two projects in one process never share a credential. Inside a scope every hook reads that request's locale and catalog, so concurrent requests in different locales never see each other's. `RegistrationResult` and `RegistrationReason` are re-exported.
|
|
19
|
+
- **`<Translate>` and `<Phrase>` render through the core's block tree.** Content made of markup and text is translated as data (`renderBlock`) and rendered by React: on a server it arrives translated with its block id on the element, it hydrates without a mismatch, a locale change re-renders it, and a translation that reorders a phrase's inline elements moves your elements with their handlers; a portal stays under the element that holds it, so its events bubble there. A new `tag` moves the unit to the element it makes, on either path. Registration goes through `registerBlock`, with no DOM walk over nodes React owns; a block is registered once however blocks nest, and a block nested in one that falls back to the DOM translation stays its own, rendered and registered with its own params. `<DontTranslate>` is the host element it renders, like any other markup. Content holding any other component, `lazy`, `Suspense` or `dangerouslySetInnerHTML` renders as written and is translated after mount, with an explicit `custom_id` on the element from the first render. Such a block is keyed and registered once its content has stopped changing for 500 ms, so a `Suspense` fallback that resolves within that time never becomes a block; a fallback still showing then is registered, and when it later resolves the SDK says so in a debug notice naming both block ids.
|
|
20
|
+
- **Variables become placeholders at build time (`langsys-js-react/vite`, `/babel`, `/next`).** Inside `<Translate>`, `<Phrase>` and `t()` calls, an interpolated value — `{user.firstName}`, `` `${items.length}` `` — becomes a named placeholder with its value as the param, so the sentence registers once for every user and can take plural forms. Only the `t` a `useT()` call returns is rewritten, and its values join the params where `t()` reads them, in `t(text, params)` and `t(text, category, params)` alike (a second argument the build cannot see into is read when the call runs); `+` joins as JavaScript joins it; a key built in the call (its own text has no space: `errors.${code}`, `status_${status}`) is left for legacy-key mode to look up. Names come from the expression in snake_case (`first_name`, `items_count`), derived by the SDK so they match every other Langsys SDK, and never take a name the developer wrote; an expression no name can be derived from is named `value`, a second one `value_2` even when it repeats the first, and the build warns. String, number and plain template literals stay text, a fragment's content joins the text around it, and each run of text reaches React as one string, as JSX renders the source. An expression holding JSX is left as it is, and so is `{children}` that is all the text between two elements of translated text; beside text, or in a part that is not translated, it is a value. A value that is text, a number, a boolean or a date is the param (a boolean stays a boolean, which a select can branch on), a bigint is passed as its digits, `null` or `undefined` renders nothing, on React 19 a promise or a context is read as React reads one, and anything else, such as an element, a list or a function, renders in its place as React renders it. In a `<Translate>`, a value in an attribute the SDK translates (`alt`, `title`, `placeholder`, `aria-label` and the rest, a button's `value`) is a placeholder too, written into the attribute's text as JavaScript writes it, unless it also appears in the text or as a whole attribute; a value that is the whole attribute fills it as a value fills text (`null` or `undefined` with nothing), and one on an element whose opt-out or input type is an expression is left as written, and that block registers nothing. A value right after a `%` and a word (`%s{x}`) gets its placeholder in the stored form, `{x}`, so the `%s` stays text. A `<Phrase>` holding a value marker of its own has its other values marked too (one in its translated text where no marker can stand is left as written, and that phrase registers nothing), and a name such a marker carries is never given to another value. A value inside `<DontTranslate>`, an element with `translate="no"` / `data-notrans` (in a `<Translate>`, an expression value included, since it is known only at runtime; in a `<Phrase>` a value under one is a placeholder, filled either way) or code such as `<template>` is shown as written, so it stays where it is and is marked as a value (`<span data-ls-param="name">`); Langsys never stores a marked value: a block's content holds its placeholder, and a `<Phrase>` keys the whole part as one empty piece of markup. A value inside `<script>`, `<style>` or `<plaintext>` is left as written, and so is one in a part that is not translated where no marker can stand: inside `<math>` or an excluded `<svg>`, where a `<span>` draws nothing, or in `<textarea>`, `<title>` or `<option>`, which hold text only. A `<Translate>` holding one registers nothing. One line enables it: the Vite plugin, the Babel plugin, or `withLangsys(nextConfig)`, which adds it to Turbopack and webpack with Next's compiler left on. The Vite plugin and the Next loader read TypeScript's and the standard decorators, and Flow in a `.js` file, and leave a file they cannot read as written, with a build warning, so they never fail a build your compiler passes. Adds `@babel/core` as a dependency, loaded only by these build entries.
|
|
21
|
+
- **A value that can't be named is not registered.** Without the build transform, a value interpolated in `<Translate>` or `<Phrase>` markup splits the text around it, and the component registers nothing for that unit rather than one phrase per value — on its own render path and when it falls back to the SDK's DOM translation — and the SDK notes it once as a debug notice. Translations already in the catalog still render. In a `<Phrase>`, a value inside a part that is not translated, or inside notation such as `<math>`, doesn't count, since the phrase's key leaves that part out, so the phrase still registers. A value that is an element's only text can't be told from literal text at runtime, so that block registers the text it renders; enable the transform for those. The transform leaves a conditional as written, so a conditional inside other text registers nothing either.
|
|
22
|
+
- **`localeHeaders()`** re-exported: the `Accept-Language` header for the user's chosen locale, for an app's calls to its own API.
|
|
23
|
+
- **`<LangsysProvider seed>` for Next.js's App Router.** The root layout opens the request's scope and passes `scope.seed()` to the provider; `useT()`, `useCurrentLocale()`, `useTranslations()`, `<Translate>` and `<Phrase>` then render the request's locale on the server, and the browser hydrates from the same catalog with no mismatch. Requires the core's `scopeFromSeed`.
|
|
24
|
+
- **Running without credentials.** `init()` without a project id, API key or locale store leaves the client unconfigured rather than throwing: `t()` and `<Translate>` / `<Phrase>` render source text, nothing is requested or registered (a registration resolves skipped with `RegistrationReason` `'not-configured'`), and `LangsysApp.isConfigured()` reports it. The `requireCredentials` init option, inherited from the base config, fails loudly instead: `t()` throws, so a component calling it reaches its error boundary, and a block's registration rejects, left unhandled so it surfaces; a block holding a value the transform did not name only reads and never rejects. `LangsysConfigurationError` is re-exported by reference from `langsys-js-react` and `langsys-js-react/server`.
|
|
25
|
+
- **`interpolate` re-exported.** `interpolate(text, params, locale)` fills a string's placeholders, ICU included, where the hooks cannot run. `langsys-js-typescript` is a plain dependency of this package rather than a peer, so importing it from there resolves under npm's hoisting and fails under pnpm, Yarn PnP or `--no-hoist`.
|
|
26
|
+
- **E2E testbed** (`example/Testbed.tsx` + `example/e2e/write-gating.mjs`, `npm run test:e2e`) — drives a real browser against a live API and asserts on captured request payloads *and* response status.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **`useWriteEnabled()` hydration safety.** It pins `getServerSnapshot` to `undefined` rather than reusing the live signal getter. React uses that snapshot for the hydration render as well as the server render, so reading the live value there makes a session whose authorization resolved before hydration render markup disagreeing with the server HTML, and React discards the subtree.
|
|
31
|
+
|
|
32
|
+
### Docs
|
|
33
|
+
|
|
34
|
+
- **Plurals and gender: write the sentence flat.** The README showed `t('{count, plural, one {# item} other {# items}}', …)` as where `t()` calls were headed. It now says never to write ICU: write the sentence with the count or the name as a `{placeholder}`, Langsys makes each language's plural and gender forms, the base language included, and `t()` renders them with the CLDR plural rules of the loaded catalog's locale. Pass `name_gender` beside `name` to choose a gender form. Numbers and dates format for the locale inside those forms as everywhere else.
|
|
35
|
+
- **`translationsLoadingPromise` settles when the fetch ends, on failure as well as on success, and never rejects.** On success `t()` already returns the new locale's text when it settles, and `useCurrentLocale()` follows about 100 ms later. On failure the page renders the source text, or the catalog stored for that locale, and `useCurrentLocale()` keeps naming the last locale that loaded. The README says to treat a settled promise with a matching locale as the only sign a switch worked, and that an error state needs the app's own timeout.
|
|
1
36
|
|
|
2
37
|
## 0.6.7 - 2026-08-21
|
|
3
38
|
|
package/README-SSR.md
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
# SSR Usage Guide
|
|
1
|
+
# SSR Usage Guide
|
|
2
2
|
|
|
3
|
-
This guide
|
|
3
|
+
This guide covers rendering `langsys-js-react` on a server: serving each request its own locale's translations, handing the client the catalog the server rendered with, and collecting what the server render missed.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## How it works
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
1. The server fetches translations during render.
|
|
9
|
-
2. The client re-fetches the same translations after hydration.
|
|
10
|
-
3. Duplicate API calls, slower initial render, possible flash of untranslated content.
|
|
7
|
+
A server process renders for many visitors at once. The SDK keeps its catalog and locale in module state, which is right for a browser page and wrong for a server, so each request renders inside a **request scope** of its own — the core's, from `langsys-js-typescript`. A scope has its own locale, its own view of the catalog, its own misses and its own hydration seed, and nothing one request's scope does reaches another's.
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
This package's server entry, `langsys-js-react/server`, opens a scope around one request's render. It is Node-only (it hands the core an `AsyncLocalStorage`, so the scope stays current across every `await` in the render) and is never part of the main bundle.
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
## Next.js — App Router
|
|
17
|
-
|
|
18
|
-
### Step 1: Fetch translations on the server
|
|
11
|
+
## Rendering one request
|
|
19
12
|
|
|
20
13
|
```tsx
|
|
21
|
-
//
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
// server.tsx
|
|
15
|
+
import { renderToString } from 'react-dom/server';
|
|
16
|
+
import { LangsysApp, createLocaleStore, renderInRequestScope } from 'langsys-js-react/server';
|
|
17
|
+
|
|
18
|
+
// Once per process: request scopes fetch catalogs, and close() sends misses, with this project and key.
|
|
19
|
+
const ready = LangsysApp.init({
|
|
20
|
+
projectid: PROJECT_ID,
|
|
21
|
+
key: SERVER_KEY,
|
|
22
|
+
UserLocaleStore: createLocaleStore('en'),
|
|
23
|
+
baseLocale: 'en',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
app.get('*', async (req, res) => {
|
|
27
|
+
await ready;
|
|
28
|
+
const locale = resolveLocale(req); // your framework's or app's resolved locale
|
|
29
|
+
|
|
30
|
+
const { result: html, seed, close } = await renderInRequestScope(
|
|
31
|
+
{ locale, url: req.url },
|
|
32
|
+
() => renderToString(<App />),
|
|
29
33
|
);
|
|
30
|
-
const result = await res.json();
|
|
31
|
-
return result.data as iCategories;
|
|
32
|
-
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
res.send(`<!doctype html>
|
|
36
|
+
<div id="root">${html}</div>
|
|
37
|
+
<script>window.__LANGSYS_SEED__ = ${JSON.stringify(seed)}</script>
|
|
38
|
+
<script type="module" src="/client.js"></script>`);
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<body>
|
|
41
|
-
<LangsysClient
|
|
42
|
-
locale={locale}
|
|
43
|
-
translations={translations}
|
|
44
|
-
projectId={process.env.LANGSYS_PROJECT_ID!}
|
|
45
|
-
apiKey={process.env.NEXT_PUBLIC_LANGSYS_API_KEY!} // read-only key for the client
|
|
46
|
-
>
|
|
47
|
-
{children}
|
|
48
|
-
</LangsysClient>
|
|
49
|
-
</body>
|
|
50
|
-
</html>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
40
|
+
await close(); // after the response: hands this request's misses to the core
|
|
41
|
+
});
|
|
53
42
|
```
|
|
54
43
|
|
|
55
|
-
|
|
44
|
+
- `LangsysApp.init()` in the server process supplies the project and key a scope fetches its catalog with and sends its misses under. A scope given `credentials: { projectid, key }` uses those instead, so one process can render for several projects: two scopes never share a credential, however their requests interleave. Without either, a scope not given `catalog` renders source text.
|
|
45
|
+
- `renderInRequestScope({ locale, catalog?, url?, credentials? }, render)` opens a scope, runs `render` inside it and resolves once it has finished. The scope fetches the locale's catalog at most once per request, shared read-only with other requests rendering the same locale under the same credentials; pass `catalog` when you already have it (from a snapshot or your own fetch).
|
|
46
|
+
- Inside the render, `useT()` and `LangsysApp.t` read this request's catalog.
|
|
47
|
+
- `seed` is `{ locale, catalog, blocks, phrases }`: the catalog this request rendered with, the content blocks it served, and the misses it collected.
|
|
48
|
+
- `close()` runs after the response has been sent. What it does with the misses is set by `ssrTokenStrategy` (below). It never throws, and calling it again returns the first result.
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
// app/LangsysClient.tsx
|
|
59
|
-
'use client';
|
|
50
|
+
Where your framework cannot wrap its render in a function, open the scope and enter it instead, in the request's own async function, before anything renders:
|
|
60
51
|
|
|
61
|
-
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
locale: string;
|
|
72
|
-
translations: iCategories;
|
|
73
|
-
projectId: string;
|
|
74
|
-
apiKey: string;
|
|
75
|
-
children: ReactNode;
|
|
76
|
-
}) {
|
|
77
|
-
const [, , localeStore] = useLocaleStore(locale);
|
|
78
|
-
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
LangsysApp.init({
|
|
81
|
-
projectid: projectId,
|
|
82
|
-
key: apiKey,
|
|
83
|
-
UserLocaleStore: localeStore,
|
|
84
|
-
baseLocale: 'en',
|
|
85
|
-
initialTranslations: translations,
|
|
86
|
-
initialTranslationsLocale: locale,
|
|
87
|
-
ssrTokenStrategy: 'client',
|
|
88
|
-
});
|
|
89
|
-
}, [localeStore, projectId, apiKey, translations, locale]);
|
|
90
|
-
|
|
91
|
-
return <>{children}</>;
|
|
92
|
-
}
|
|
52
|
+
```ts
|
|
53
|
+
import { createRequestScope, installRequestScopeStorage } from 'langsys-js-react/server';
|
|
54
|
+
|
|
55
|
+
installRequestScopeStorage(); // once, at startup, beside LangsysApp.init() as above
|
|
56
|
+
|
|
57
|
+
// in the request handler, before rendering:
|
|
58
|
+
const scope = await createRequestScope({ locale, url });
|
|
59
|
+
scope.enter(); // current for the rest of this request's async context
|
|
60
|
+
// ...render, send scope.seed() with the page, then after the response:
|
|
61
|
+
await scope.close();
|
|
93
62
|
```
|
|
94
63
|
|
|
95
|
-
|
|
64
|
+
Prefer `renderInRequestScope` (or `scope.run()`) wherever you can wrap the render. `scope.enter()` binds the scope to the async continuation it is called in, so it holds only when that same function goes on to render. Any function your host awaits and returns from before rendering — a middleware, a route handler that returns before the render, a helper that opens the scope — loses it on return, even when `enter()` comes after that function's own `await`, and the render then reads another request's state.
|
|
96
65
|
|
|
97
|
-
|
|
98
|
-
'use client';
|
|
99
|
-
import { useT } from 'langsys-js-react';
|
|
66
|
+
## Hydrating on the client
|
|
100
67
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
68
|
+
Seed the client with the server's catalog **before** hydrating, so the first client render matches the server's HTML:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// client.tsx
|
|
72
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
73
|
+
import { LangsysApp, createLocaleStore } from 'langsys-js-react';
|
|
74
|
+
|
|
75
|
+
const seed = window.__LANGSYS_SEED__;
|
|
76
|
+
LangsysApp.seedCatalog(seed.catalog, seed.locale, seed); // synchronous
|
|
77
|
+
|
|
78
|
+
const localeStore = createLocaleStore(seed.locale);
|
|
79
|
+
hydrateRoot(document.getElementById('root')!, <App localeStore={localeStore} />);
|
|
80
|
+
|
|
81
|
+
LangsysApp.init({
|
|
82
|
+
projectid: PROJECT_ID,
|
|
83
|
+
key: PUBLIC_KEY, // a read-only key in the browser
|
|
84
|
+
UserLocaleStore: localeStore,
|
|
85
|
+
baseLocale: 'en',
|
|
86
|
+
});
|
|
110
87
|
```
|
|
111
88
|
|
|
112
|
-
|
|
89
|
+
`seedCatalog` is synchronous and marks the locale as loaded, so `init()` does not fetch it again. Passing the seed itself as the third argument hands over the content blocks the server rendered and the misses it collected, so the client never registers again what the server already collects. `<Translate>` and `<Phrase>` register themselves as they render, so nothing else is needed.
|
|
113
90
|
|
|
114
|
-
|
|
115
|
-
// pages/_app.tsx
|
|
116
|
-
import type { AppProps } from 'next/app';
|
|
117
|
-
import { useEffect } from 'react';
|
|
118
|
-
import { LangsysApp, useLocaleStore } from 'langsys-js-react';
|
|
91
|
+
## Next.js
|
|
119
92
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
});
|
|
133
|
-
}, [localeStore, pageProps.translations, locale]);
|
|
134
|
-
|
|
135
|
-
return <Component {...pageProps} />;
|
|
136
|
-
}
|
|
93
|
+
In the App Router, server components and client components are built against separate copies of the SDK, so a scope opened in a server component is not visible to the client components rendering the same request. The request's **seed** crosses that boundary instead: the root layout opens the scope and passes its seed to `<LangsysProvider>`, which rebuilds the scope on the client-component side.
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// next.config.mjs — the placeholder transform, in Turbopack and webpack
|
|
97
|
+
import { withLangsys } from 'langsys-js-react/next';
|
|
98
|
+
export default withLangsys({ /* your config */ });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// app/langsys-client.tsx — this package ships no 'use client' directive, so re-export the provider through one
|
|
103
|
+
'use client';
|
|
104
|
+
export { LangsysProvider } from 'langsys-js-react';
|
|
137
105
|
```
|
|
138
106
|
|
|
139
107
|
```tsx
|
|
140
|
-
//
|
|
141
|
-
import
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
108
|
+
// app/layout.tsx — a server component
|
|
109
|
+
import { LangsysApp, createLocaleStore, createRequestScope } from 'langsys-js-react/server';
|
|
110
|
+
import { LangsysProvider } from './langsys-client';
|
|
111
|
+
|
|
112
|
+
// The server's own SDK, initialized once per process: scopes fetch catalogs with this project and key.
|
|
113
|
+
let ready: Promise<unknown> | undefined;
|
|
114
|
+
const initServer = () =>
|
|
115
|
+
(ready ??= LangsysApp.init({
|
|
116
|
+
projectid: process.env.LANGSYS_PROJECT_ID!,
|
|
117
|
+
key: process.env.LANGSYS_SERVER_KEY!,
|
|
118
|
+
UserLocaleStore: createLocaleStore('en'),
|
|
119
|
+
baseLocale: 'en',
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
123
|
+
await initServer();
|
|
124
|
+
const locale = await resolveLocale(); // your app's own resolution: route segment, cookie, header
|
|
125
|
+
const scope = await createRequestScope({ locale });
|
|
126
|
+
return (
|
|
127
|
+
<html lang={locale}>
|
|
128
|
+
<body>
|
|
129
|
+
<LangsysProvider seed={scope.seed()}>{children}</LangsysProvider>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|
|
148
132
|
);
|
|
149
|
-
const { data } = await res.json();
|
|
150
|
-
return { props: { locale, translations: data as iCategories } };
|
|
151
133
|
}
|
|
152
134
|
```
|
|
153
135
|
|
|
136
|
+
The layout imports `LangsysApp` and `createLocaleStore` from `langsys-js-react/server`, which loads no React, so a server component can call `init()`. It runs when a request first renders the layout, not when the module loads.
|
|
137
|
+
|
|
138
|
+
Use the hooks, `<Translate>`, `<Phrase>` and `<DontTranslate>` in Client Components (files that start with `'use client'`), imported from `langsys-js-react` itself, as `example-next/app/Client.tsx` does; a Server Component renders them through a Client Component of yours. The build transform makes values placeholders only in units imported from the package, so a unit taken through a re-export of your own is left as written ([Variables in translated text](./README.md#variables-in-translated-text)).
|
|
139
|
+
|
|
140
|
+
Under the provider, `useT()`, `useCurrentLocale()`, `useTranslations()`, `<Translate>` and `<Phrase>` render the request's locale and catalog on the server, and the browser hydrates from the same catalog, so the first client render matches the served HTML. Initialize the SDK in the browser as usual (`LangsysApp.init()` in a Client Component's `useEffect`); the provider has already seeded it with the request's catalog.
|
|
141
|
+
|
|
142
|
+
Content a client component misses during the server render is registered by the browser after hydration. Under the `server` SSR strategy those misses are not sent from the server, because the provider's side of the render has no after-response hook.
|
|
143
|
+
|
|
144
|
+
## What a server render translates and discovers
|
|
145
|
+
|
|
146
|
+
| What renders the text | Translated on the server | Discovered |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| `t()` / `useT()` inside a scope | Yes, in the request's locale; `useCurrentLocale()` and `useTranslations()` read the request's too | Yes: the miss is recorded in the scope and handed on by `close()` |
|
|
149
|
+
| `<Translate>`, `<Phrase>` with markup and text | Yes, with the block's id on its element | Yes: registered through the scope, handed on by `close()` |
|
|
150
|
+
| `<Translate>` / `<Phrase>` holding a component, `lazy`, `Suspense` or `dangerouslySetInnerHTML` | No — source text, with an explicit `custom_id` on the element; the SDK warns once | On the client, when it mounts |
|
|
151
|
+
|
|
152
|
+
A block of markup and text renders through the core on the server and again in the browser, with the same result, so the page arrives translated and hydrates without a mismatch. A block whose content is only known once React renders it is served as written and translated in the browser.
|
|
153
|
+
|
|
154
154
|
## Locale switching
|
|
155
155
|
|
|
156
156
|
Update the store from the same `useLocaleStore` call; the SDK reacts and fetches the new locale's translations:
|
|
@@ -164,7 +164,7 @@ export function LocaleSwitcher() {
|
|
|
164
164
|
|
|
165
165
|
function changeLocale(next: string) {
|
|
166
166
|
setLocale(next); // subscribers in the SDK trigger a fetch
|
|
167
|
-
return LangsysApp.translationsLoadingPromise; // optional:
|
|
167
|
+
return LangsysApp.translationsLoadingPromise; // optional: settles when the fetch ends (success OR failure)
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
return (
|
|
@@ -177,24 +177,9 @@ export function LocaleSwitcher() {
|
|
|
177
177
|
}
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
>
|
|
181
|
-
|
|
182
|
-
## Benefits
|
|
183
|
-
|
|
184
|
-
### Performance
|
|
185
|
-
- No duplicate API calls (server + client).
|
|
186
|
-
- Translations ready immediately on hydration.
|
|
187
|
-
- Faster Time to Interactive (TTI).
|
|
188
|
-
- Reduced API usage and costs.
|
|
189
|
-
|
|
190
|
-
### User experience
|
|
191
|
-
- No flash of untranslated content.
|
|
192
|
-
- Instant translation display.
|
|
193
|
-
- Better SEO with server-rendered translations.
|
|
180
|
+
> `translationsLoadingPromise` settles when the fetch ends, on failure as well as on success, so it does not mean the new language is showing. [Waiting for translations to load](./README.md#waiting-for-translations-to-load) covers how to tell the two apart.
|
|
194
181
|
|
|
195
|
-
|
|
196
|
-
- Simple configuration.
|
|
197
|
-
- Full TypeScript support, including compile-time-checked interpolation params on `t()`.
|
|
182
|
+
> Keep one locale store for the app (created where you call `init`) and thread `setLocale` down via context or props, rather than calling `useLocaleStore` with a fresh initial value in unrelated trees.
|
|
198
183
|
|
|
199
184
|
## Configuration options
|
|
200
185
|
|
|
@@ -204,45 +189,34 @@ export function LocaleSwitcher() {
|
|
|
204
189
|
{ ssrTokenStrategy: 'client' | 'server' | 'auto' }
|
|
205
190
|
```
|
|
206
191
|
|
|
207
|
-
|
|
208
|
-
- `'server'` — send tokens immediately from server.
|
|
209
|
-
- `'auto'` — small batches (≤5) from server, larger batches from client.
|
|
192
|
+
This decides what `close()` does with the phrases a scoped server render missed. It acts on an SDK initialized in the rendering process (`LangsysApp.init()` on the server):
|
|
210
193
|
|
|
211
|
-
|
|
194
|
+
- `'client'` (default) — nothing is sent from the server. Content that also renders on the client is caught there, when `t()` runs in the browser.
|
|
195
|
+
- `'server'` — `close()` sends the misses from the server, when the server's key may write. Note that this originates from your server's IP rather than a browser's.
|
|
196
|
+
- `'auto'` — `close()` sends a short list (fewer than 5) from the server and leaves a longer one to the client.
|
|
212
197
|
|
|
213
|
-
|
|
214
|
-
{ debug: true, initialTranslations: translations, initialTranslationsLocale: locale }
|
|
215
|
-
```
|
|
198
|
+
`close()` never throws; its result says whether it sent or why it skipped.
|
|
216
199
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
- `Locale change detected!` — fires on a subsequent locale switch.
|
|
200
|
+
### Debug mode
|
|
201
|
+
|
|
202
|
+
Pass `debug: true` to `init()` to log the SDK's lifecycle to the console, including `Locale change detected!` on a locale switch.
|
|
221
203
|
|
|
222
204
|
## Important notes
|
|
223
205
|
|
|
224
|
-
1. **
|
|
225
|
-
2. **
|
|
226
|
-
3. **Data format.**
|
|
227
|
-
4. **
|
|
228
|
-
5. **
|
|
229
|
-
6. **`'use client'`.** `useT`, `useLocaleStore`, `<Translate>`, and `LangsysApp.init` run on the client — keep them in Client Components.
|
|
206
|
+
1. **Seed before hydrating.** Call `LangsysApp.seedCatalog(seed.catalog, seed.locale, seed)` before `hydrateRoot`, with the seed from the same request. Seeding after hydration, or from another request, renders different text from the server's HTML.
|
|
207
|
+
2. **One scope per request.** Open a scope for every request and close it after that request's response. Never share one across requests: sharing one is exactly the cross-visitor leak the scope exists to prevent.
|
|
208
|
+
3. **Data format.** A `catalog` passed to a scope must match the `iCategories` shape returned by `LangsysAppAPI.getTranslations()`.
|
|
209
|
+
4. **Keys.** Use a read-only API key in the browser — anything shipped in public JS is extractable. A write key belongs on the server, where `close()` can send a scope's misses with it under the `'server'` or `'auto'` strategy.
|
|
210
|
+
5. **Server-only entry.** Import `langsys-js-react/server` only from server code; it imports `node:async_hooks`. The main entry imports React hooks, so in a React Server Component environment put its hooks and components behind a Client Component boundary of your own (this package ships no `'use client'` directive). The server entry loads no React and exports the same `LangsysApp` and `createLocaleStore`, so a server component initializes the SDK through it.
|
|
230
211
|
|
|
231
212
|
## Troubleshooting
|
|
232
213
|
|
|
233
|
-
###
|
|
234
|
-
-
|
|
235
|
-
- Verify the translations payload matches the `iCategories` shape.
|
|
236
|
-
- Enable `debug: true` and look for the messages above.
|
|
237
|
-
|
|
238
|
-
### Still seeing duplicate API calls
|
|
239
|
-
- Confirm both `initialTranslations` *and* `initialTranslationsLocale` are passed.
|
|
240
|
-
- Confirm init runs before any rendering that calls `t(...)`.
|
|
241
|
-
- Confirm the locale hasn't drifted between server and client.
|
|
214
|
+
### Another visitor's language appears in a page
|
|
215
|
+
- Every render must run inside its own request's scope: `renderInRequestScope`, or `scope.enter()` called in the function that itself renders — never in a middleware, handler or helper that returns before the render.
|
|
242
216
|
|
|
243
217
|
### Hydration mismatch warnings
|
|
244
|
-
-
|
|
245
|
-
-
|
|
218
|
+
- Seed the client with the seed of the request that rendered the page, before `hydrateRoot`.
|
|
219
|
+
- Make sure the locale store's initial value on the client is the seed's locale.
|
|
246
220
|
|
|
247
221
|
### TypeScript errors on `t()`
|
|
248
222
|
- Placeholders are compile-time-checked: `t('Hello, {name}!', 'Cat')` *requires* a params object with `name`. Either add the key or remove the placeholder.
|