i18n-keyless-react 2.2.0 → 2.3.1

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,7 +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
+ import { getRequestScope, recordUsedKey } from "./request-scope.js";
6
6
  const warnAboutWhitespace = (text) => {
7
7
  if (process.env.NODE_ENV === "development" && text !== text.trim()) {
8
8
  console.warn(`I18nKeylessText received text with leading/trailing whitespace: "${text}". ` +
@@ -29,9 +29,11 @@ export const I18nKeylessText = ({ children, replace, context, debug = false, for
29
29
  useEffect(() => {
30
30
  getTranslation(sourceText, { context, debug, forceTemporary });
31
31
  }, [sourceText, currentLanguage, context, debug, forceTemporary]);
32
- const translatedText = currentLanguage === config.languages.primary
33
- ? sourceText
34
- : translations[context ? `${sourceText}__${context}` : sourceText] || sourceText;
32
+ const storageKey = context ? `${sourceText}__${context}` : sourceText;
33
+ // Record the key for the per-page SSR snapshot (no-op off-server; pure Set.add, no
34
+ // setState, so no render-time update warning). See docs/SSR.md.
35
+ recordUsedKey(storageKey);
36
+ const translatedText = currentLanguage === config.languages.primary ? sourceText : translations[storageKey] || sourceText;
35
37
  const finalText = useMemo(() => {
36
38
  if (!replace) {
37
39
  return translatedText;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ 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";
7
+ export { runWithI18nKeyless, getRequestScope, getUsedTranslationsSnapshot } from "./request-scope.ts";
8
8
  export type { I18nRequestScope } from "./request-scope.ts";
9
9
  export { clearI18nKeylessStorageAndStore } from "./store.ts";
10
10
  export type { I18nKeylessTextProps } from "./I18nKeylessText.tsx";
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@ export { init, hydrateFromServer, setCurrentLanguage, useCurrentLanguage, getTra
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
+ export { runWithI18nKeyless, getRequestScope, getUsedTranslationsSnapshot } from "./request-scope.js";
7
7
  export { clearI18nKeylessStorageAndStore } from "./store.js";
8
8
  export { AVAILABLE_LANGS, getAllTranslationsFromLanguage, queue } from "i18n-keyless-core";
@@ -29,8 +29,27 @@ export interface I18nRequestScope {
29
29
  */
30
30
  export declare function runWithI18nKeyless<R>(scope: I18nRequestScope, fn: () => R): Promise<R>;
31
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.
32
+ * Returns the active per-request scope (`{ lang, translations }` with the FULL set
33
+ * available for resolution), or `undefined` when none is set (browser, or outside
34
+ * `runWithI18nKeyless`). Read internally by `getTranslation` and `<I18nKeylessText>`;
35
+ * exported for advanced/server use.
35
36
  */
36
37
  export declare function getRequestScope(): I18nRequestScope | undefined;
38
+ /**
39
+ * Records that `key` (the storage key, i.e. `key` or `key__context`) was used in the
40
+ * current render. No-op outside a `runWithI18nKeyless` scope (browser / SPA). Pure
41
+ * `Set.add` — no store write, no setState. Internal: called by `getTranslation` and
42
+ * `<I18nKeylessText>`.
43
+ */
44
+ export declare function recordUsedKey(key: string): void;
45
+ /**
46
+ * Returns a snapshot containing ONLY the keys used during the current render (∩ the keys
47
+ * actually available in the scope's translations), for serializing a per-page subset into
48
+ * the SSR HTML instead of the full language set. Use it at the serialization site in
49
+ * place of `getRequestScope()` when the language set is large.
50
+ *
51
+ * The full set is still used for resolution during render; only the serialized snapshot is
52
+ * narrowed. Pair it with `init()`'s background full fetch on the client so subsequent
53
+ * client-side navigation has every key. Returns `undefined` outside a scope. See docs/SSR.md.
54
+ */
55
+ export declare function getUsedTranslationsSnapshot(): I18nRequestScope | undefined;
@@ -7,23 +7,36 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
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;
10
+ // `run()` creates an isolated store, so the instance is correctly a process singleton.
11
+ //
12
+ // CRITICAL: the instance is stored on `globalThis` under a `Symbol.for(...)` key, NOT in a
13
+ // module-level variable. Bundlers that build separate server-entry and SSR-render
14
+ // environments (e.g. TanStack Start / Vite SSR) instantiate this package more than once in
15
+ // the same process. With a module-local `als`, `runWithI18nKeyless` (called from the server
16
+ // entry) would set `als` on copy A, while `getRequestScope`/`recordUsedKey` (called during
17
+ // the React render) would read copy B's `als` — `undefined` — and SSR would silently fall
18
+ // back to the primary language with a hydration mismatch. Routing every read and write
19
+ // through the shared `globalThis` slot guarantees all module copies use ONE ALS.
20
+ const ALS_KEY = Symbol.for("i18n-keyless.als");
21
+ const ALS_INIT_KEY = Symbol.for("i18n-keyless.alsInit");
22
+ const registry = globalThis;
23
+ function getAls() {
24
+ return registry[ALS_KEY];
25
+ }
13
26
  async function ensureALS() {
14
27
  // Browser: no request scoping (single user → the store is correct). Also avoids
15
28
  // pulling a Node builtin into client bundles.
16
- if (als || typeof window !== "undefined") {
29
+ if (registry[ALS_KEY] || typeof window !== "undefined") {
17
30
  return;
18
31
  }
19
- if (!alsInit) {
20
- alsInit = (async () => {
32
+ if (!registry[ALS_INIT_KEY]) {
33
+ registry[ALS_INIT_KEY] = (async () => {
21
34
  try {
22
35
  // Variable specifier + ignore hints keep bundlers from trying to resolve a Node
23
36
  // builtin into the browser graph. tsc keeps these comments (removeComments=false).
24
37
  const specifier = "node:async_hooks";
25
38
  const mod = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ /* webpackIgnore: true */ specifier)));
26
- als = new mod.AsyncLocalStorage();
39
+ registry[ALS_KEY] = new mod.AsyncLocalStorage();
27
40
  }
28
41
  catch {
29
42
  // AsyncLocalStorage unavailable (e.g. some edge runtimes). Scoping degrades to a
@@ -31,7 +44,7 @@ async function ensureALS() {
31
44
  }
32
45
  })();
33
46
  }
34
- await alsInit;
47
+ await registry[ALS_INIT_KEY];
35
48
  }
36
49
  /**
37
50
  * Runs `fn` with a per-request translation scope active. Every `getTranslation(...)`
@@ -51,13 +64,52 @@ async function ensureALS() {
51
64
  */
52
65
  export async function runWithI18nKeyless(scope, fn) {
53
66
  await ensureALS();
54
- return als ? als.run(scope, fn) : fn();
67
+ const als = getAls();
68
+ if (!als) {
69
+ return fn();
70
+ }
71
+ // Each run gets its own `used` Set, isolated to this request's async context.
72
+ return als.run({ lang: scope.lang, translations: scope.translations, used: new Set() }, fn);
55
73
  }
56
74
  /**
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.
75
+ * Returns the active per-request scope (`{ lang, translations }` with the FULL set
76
+ * available for resolution), or `undefined` when none is set (browser, or outside
77
+ * `runWithI18nKeyless`). Read internally by `getTranslation` and `<I18nKeylessText>`;
78
+ * exported for advanced/server use.
60
79
  */
61
80
  export function getRequestScope() {
62
- return als?.getStore();
81
+ const s = getAls()?.getStore();
82
+ return s ? { lang: s.lang, translations: s.translations } : undefined;
83
+ }
84
+ /**
85
+ * Records that `key` (the storage key, i.e. `key` or `key__context`) was used in the
86
+ * current render. No-op outside a `runWithI18nKeyless` scope (browser / SPA). Pure
87
+ * `Set.add` — no store write, no setState. Internal: called by `getTranslation` and
88
+ * `<I18nKeylessText>`.
89
+ */
90
+ export function recordUsedKey(key) {
91
+ getAls()?.getStore()?.used.add(key);
92
+ }
93
+ /**
94
+ * Returns a snapshot containing ONLY the keys used during the current render (∩ the keys
95
+ * actually available in the scope's translations), for serializing a per-page subset into
96
+ * the SSR HTML instead of the full language set. Use it at the serialization site in
97
+ * place of `getRequestScope()` when the language set is large.
98
+ *
99
+ * The full set is still used for resolution during render; only the serialized snapshot is
100
+ * narrowed. Pair it with `init()`'s background full fetch on the client so subsequent
101
+ * client-side navigation has every key. Returns `undefined` outside a scope. See docs/SSR.md.
102
+ */
103
+ export function getUsedTranslationsSnapshot() {
104
+ const s = getAls()?.getStore();
105
+ if (!s) {
106
+ return undefined;
107
+ }
108
+ const translations = {};
109
+ for (const key of s.used) {
110
+ if (key in s.translations) {
111
+ translations[key] = s.translations[key];
112
+ }
113
+ }
114
+ return { lang: s.lang, translations };
63
115
  }
package/dist/store.js CHANGED
@@ -1,7 +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
+ import { getRequestScope, recordUsedKey } from "./request-scope.js";
5
5
  /**
6
6
  * True when running without a DOM (server-side rendering). On the server the lib is
7
7
  * read-only: usage analytics are neither recorded nor sent. Evaluated at call time so
@@ -294,6 +294,9 @@ export function getTranslation(key, options) {
294
294
  if (!isServerEnv() && !base.config.ssr) {
295
295
  queueMicrotask(() => base.setTranslationUsage(key, options?.context));
296
296
  }
297
+ // Record the key for the per-page SSR snapshot (no-op off-server). Pure Set.add — no
298
+ // store write. Use the storage key (with context) so it matches the translations map.
299
+ recordUsedKey(options?.context ? `${key}__${options.context}` : key);
297
300
  // SSR: if a per-request scope is active (set by runWithI18nKeyless), translate against
298
301
  // that request's language/translations instead of the process-global store — so
299
302
  // getTranslation, like <T>, renders the right language without leaking across
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "i18n-keyless-react",
3
3
  "private": false,
4
- "version": "2.2.0",
4
+ "version": "2.3.1",
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.2.0",
27
+ "i18n-keyless-core": "2.3.1",
28
28
  "zustand": "^5.0.3"
29
29
  },
30
30
  "peerDependencies": {