i18n-keyless-react 2.0.0 → 2.1.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.
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useEffect, useMemo } from "react";
3
3
  import { useI18nKeyless, getTranslation } from "./store.js";
4
4
  import { useI18nKeylessContext } from "./I18nKeylessProvider.js";
5
+ import { getRequestScope } from "./request-scope.js";
5
6
  const warnAboutWhitespace = (text) => {
6
7
  if (process.env.NODE_ENV === "development" && text !== text.trim()) {
7
8
  console.warn(`I18nKeylessText received text with leading/trailing whitespace: "${text}". ` +
@@ -12,12 +13,13 @@ export const I18nKeylessText = ({ children, replace, context, debug = false, for
12
13
  const storeTranslations = useI18nKeyless((store) => store.translations);
13
14
  const storeCurrentLanguage = useI18nKeyless((store) => store.currentLanguage);
14
15
  const config = useI18nKeyless((store) => store.config);
15
- // In SSR/provider mode, the language and translations come from the per-request
16
- // context (so concurrent requests don't share state). Otherwise (SPA mode) they come
17
- // from the global store. See docs/SSR.md.
18
- const providerContext = useI18nKeylessContext();
19
- const translations = providerContext?.translations ?? storeTranslations;
20
- const currentLanguage = providerContext?.lang ?? storeCurrentLanguage;
16
+ // In SSR mode, language and translations come from the per-request scope so concurrent
17
+ // requests don't share state: the React-context provider first, then the
18
+ // AsyncLocalStorage request scope (set by runWithI18nKeyless). Otherwise (SPA mode)
19
+ // both are absent and we use the global store. See docs/SSR.md.
20
+ const requestScope = useI18nKeylessContext() ?? getRequestScope();
21
+ const translations = requestScope?.translations ?? storeTranslations;
22
+ const currentLanguage = requestScope?.lang ?? storeCurrentLanguage;
21
23
  // Trim the source text immediately
22
24
  const rawText = Array.isArray(children) ? children.join("") : String(children ?? "");
23
25
  const sourceText = rawText.trim();
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export { clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "
4
4
  export { I18nKeylessProvider, useI18nKeylessContext } from "./I18nKeylessProvider.tsx";
5
5
  export type { I18nKeylessProviderProps } from "./I18nKeylessProvider.tsx";
6
6
  export { getServerTranslations, clearServerTranslationsCache } from "./server.ts";
7
+ export { runWithI18nKeyless, getRequestScope } from "./request-scope.ts";
8
+ export type { I18nRequestScope } from "./request-scope.ts";
7
9
  export { clearI18nKeylessStorageAndStore } from "./store.ts";
8
10
  export type { I18nKeylessTextProps } from "./I18nKeylessText.tsx";
9
11
  export { type I18nConfig, type TranslationStoreState, type TranslationOptions, type TranslationStore } from "./types.ts";
package/dist/index.js CHANGED
@@ -3,5 +3,6 @@ export { init, setCurrentLanguage, useCurrentLanguage, getTranslation, getSuppor
3
3
  export { clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "./utils.js";
4
4
  export { I18nKeylessProvider, useI18nKeylessContext } from "./I18nKeylessProvider.js";
5
5
  export { getServerTranslations, clearServerTranslationsCache } from "./server.js";
6
+ export { runWithI18nKeyless, getRequestScope } from "./request-scope.js";
6
7
  export { clearI18nKeylessStorageAndStore } from "./store.js";
7
8
  export { AVAILABLE_LANGS, getAllTranslationsFromLanguage, queue } from "i18n-keyless-core";
@@ -0,0 +1,36 @@
1
+ import type { Lang, Translations } from "i18n-keyless-core";
2
+ /**
3
+ * Per-request translation scope for SSR.
4
+ *
5
+ * Set by `runWithI18nKeyless` and read by both `getTranslation(...)` and
6
+ * `<I18nKeylessText>` so a single server render resolves in `lang` using `translations`,
7
+ * without touching the process-global store and without leaking across concurrent
8
+ * requests. See docs/SSR.md.
9
+ */
10
+ export interface I18nRequestScope {
11
+ lang: Lang;
12
+ translations: Translations;
13
+ }
14
+ /**
15
+ * Runs `fn` with a per-request translation scope active. Every `getTranslation(...)`
16
+ * call and every `<I18nKeylessText>` rendered within `fn` — synchronously OR across
17
+ * `await`s/streaming — resolves in `scope.lang` using `scope.translations`, with no
18
+ * change to the global store and full isolation between concurrent requests.
19
+ *
20
+ * Wrap your server render in it and await the result:
21
+ *
22
+ * const html = await runWithI18nKeyless(
23
+ * { lang, translations },
24
+ * () => renderToString(<App />)
25
+ * );
26
+ *
27
+ * Server-only. In the browser (or where AsyncLocalStorage is unavailable) it simply
28
+ * calls `fn` with no scoping. See docs/SSR.md.
29
+ */
30
+ export declare function runWithI18nKeyless<R>(scope: I18nRequestScope, fn: () => R): Promise<R>;
31
+ /**
32
+ * Returns the active per-request scope, or `undefined` when none is set (browser, or
33
+ * outside `runWithI18nKeyless`). Read internally by `getTranslation` and
34
+ * `<I18nKeylessText>`; exported for advanced/server use.
35
+ */
36
+ export declare function getRequestScope(): I18nRequestScope | undefined;
@@ -0,0 +1,63 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // A single AsyncLocalStorage instance, created lazily on the server only. Each
10
+ // `run()` creates an isolated store, so the instance is correctly a module singleton.
11
+ let als;
12
+ let alsInit;
13
+ async function ensureALS() {
14
+ // Browser: no request scoping (single user → the store is correct). Also avoids
15
+ // pulling a Node builtin into client bundles.
16
+ if (als || typeof window !== "undefined") {
17
+ return;
18
+ }
19
+ if (!alsInit) {
20
+ alsInit = (async () => {
21
+ try {
22
+ // Variable specifier + ignore hints keep bundlers from trying to resolve a Node
23
+ // builtin into the browser graph. tsc keeps these comments (removeComments=false).
24
+ const specifier = "node:async_hooks";
25
+ const mod = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ /* webpackIgnore: true */ specifier)));
26
+ als = new mod.AsyncLocalStorage();
27
+ }
28
+ catch {
29
+ // AsyncLocalStorage unavailable (e.g. some edge runtimes). Scoping degrades to a
30
+ // no-op and getTranslation/<T> fall back to the global store. See docs/SSR.md.
31
+ }
32
+ })();
33
+ }
34
+ await alsInit;
35
+ }
36
+ /**
37
+ * Runs `fn` with a per-request translation scope active. Every `getTranslation(...)`
38
+ * call and every `<I18nKeylessText>` rendered within `fn` — synchronously OR across
39
+ * `await`s/streaming — resolves in `scope.lang` using `scope.translations`, with no
40
+ * change to the global store and full isolation between concurrent requests.
41
+ *
42
+ * Wrap your server render in it and await the result:
43
+ *
44
+ * const html = await runWithI18nKeyless(
45
+ * { lang, translations },
46
+ * () => renderToString(<App />)
47
+ * );
48
+ *
49
+ * Server-only. In the browser (or where AsyncLocalStorage is unavailable) it simply
50
+ * calls `fn` with no scoping. See docs/SSR.md.
51
+ */
52
+ export async function runWithI18nKeyless(scope, fn) {
53
+ await ensureALS();
54
+ return als ? als.run(scope, fn) : fn();
55
+ }
56
+ /**
57
+ * Returns the active per-request scope, or `undefined` when none is set (browser, or
58
+ * outside `runWithI18nKeyless`). Read internally by `getTranslation` and
59
+ * `<I18nKeylessText>`; exported for advanced/server use.
60
+ */
61
+ export function getRequestScope() {
62
+ return als?.getStore();
63
+ }
package/dist/store.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { queue, getAllTranslationsFromLanguage, getTranslationCore, sendTranslationsUsageToI18nKeyless, } from "i18n-keyless-core";
2
2
  import { create } from "zustand";
3
3
  import { storeKeys, setItem, getItem, clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "./utils.js";
4
+ import { getRequestScope } from "./request-scope.js";
4
5
  /**
5
6
  * True when running without a DOM (server-side rendering). On the server the lib is
6
7
  * read-only: usage analytics are neither recorded nor sent. Evaluated at call time so
@@ -243,11 +244,17 @@ export function getSupportedLanguages() {
243
244
  return useI18nKeyless.getState().config.languages.supported;
244
245
  }
245
246
  export function getTranslation(key, options) {
246
- const store = useI18nKeyless.getState();
247
+ const base = useI18nKeyless.getState();
247
248
  // Read-only on the server: don't record usage (a render may be a crawler hit).
248
- if (!isServerEnv() && !store.config.ssr) {
249
- store.setTranslationUsage(key, options?.context);
249
+ if (!isServerEnv() && !base.config.ssr) {
250
+ base.setTranslationUsage(key, options?.context);
250
251
  }
252
+ // SSR: if a per-request scope is active (set by runWithI18nKeyless), translate against
253
+ // that request's language/translations instead of the process-global store — so
254
+ // getTranslation, like <T>, renders the right language without leaking across
255
+ // concurrent requests. No scope (SPA / outside a scoped render) → use the store as-is.
256
+ const scope = getRequestScope();
257
+ const store = scope ? { ...base, currentLanguage: scope.lang, translations: scope.translations } : base;
251
258
  return getTranslationCore(key, store, options);
252
259
  }
253
260
  export function setCurrentLanguage(lang) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "i18n-keyless-react",
3
3
  "private": false,
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -24,7 +24,7 @@
24
24
  "postpublish": "rm -rf ./dist && rm *.tgz"
25
25
  },
26
26
  "dependencies": {
27
- "i18n-keyless-core": "2.0.0",
27
+ "i18n-keyless-core": "2.1.0",
28
28
  "zustand": "^5.0.3"
29
29
  },
30
30
  "peerDependencies": {