@cosmicdrift/kumiko-renderer 0.125.1 → 0.125.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.125.1",
3
+ "version": "0.125.2",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.125.1",
19
- "@cosmicdrift/kumiko-headless": "0.125.1",
18
+ "@cosmicdrift/kumiko-framework": "0.125.2",
19
+ "@cosmicdrift/kumiko-headless": "0.125.2",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -105,6 +105,47 @@ describe("useTranslation — re-render on locale change", () => {
105
105
  expect(getByTestId("msg").textContent).toBe("Hello");
106
106
  });
107
107
  });
108
+ describe("useTranslation — referential stability", () => {
109
+ // Prod-Incident 2026-07-07: admin-shell Overview-Screens hatten `t` in
110
+ // einem useEffect-Dependency-Array. Ein neues `t` pro Render triggerte
111
+ // einen Render/Effect-Endlos-Loop (~600 Queries/Sekunde). `t` (und der
112
+ // gesamte Context-Value) MUSS über Re-Renders hinweg stabil bleiben,
113
+ // solange sich Resolver/Bundles/Locale nicht ändern.
114
+ test("t keeps the same reference across re-renders when nothing changed", () => {
115
+ const resolver = createStaticLocaleResolver({ locale: "de" });
116
+ const { result, rerender } = renderHook(() => useTranslation(), {
117
+ wrapper: wrap(resolver),
118
+ });
119
+ const firstT = result.current;
120
+ rerender();
121
+ expect(result.current).toBe(firstT);
122
+ });
123
+ test("t stays stable across parent re-renders even with a fresh fallbackBundles literal per parent-render", () => {
124
+ // Realistischer Fall: eine App übergibt `fallbackBundles={[...]}` als
125
+ // Inline-Literal. Ohne Provider-seitige Memoization würde jeder
126
+ // Ahnen-Re-Render den Context-Value neu bauen. Hier prüfen wir nur
127
+ // den Provider-internen Memoization-Pfad bei stabilen Props.
128
+ const resolver = createStaticLocaleResolver({ locale: "de" });
129
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" } }];
130
+ function Probe(): ReactNode {
131
+ const t = useTranslation();
132
+ (Probe as unknown as { lastT?: unknown }).lastT = t;
133
+ return null;
134
+ }
135
+ const { rerender } = render(
136
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
137
+ <Probe />
138
+ </LocaleProvider>,
139
+ );
140
+ const firstT = (Probe as unknown as { lastT?: unknown }).lastT;
141
+ rerender(
142
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
143
+ <Probe />
144
+ </LocaleProvider>,
145
+ );
146
+ expect((Probe as unknown as { lastT?: unknown }).lastT).toBe(firstT);
147
+ });
148
+ });
108
149
  describe("useLocale", () => {
109
150
  test("returns the resolver", () => {
110
151
  const resolver = createStaticLocaleResolver({ locale: "de" });
package/src/i18n.tsx CHANGED
@@ -16,7 +16,14 @@
16
16
  // Session die Sprache umschalten ohne Reload.
17
17
 
18
18
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
19
- import { createContext, type ReactNode, useContext, useSyncExternalStore } from "react";
19
+ import {
20
+ createContext,
21
+ type ReactNode,
22
+ useCallback,
23
+ useContext,
24
+ useMemo,
25
+ useSyncExternalStore,
26
+ } from "react";
20
27
 
21
28
  /** Map von i18n-Key → Template-String. Templates dürfen `{name}`-
22
29
  * Platzhalter enthalten — identische Semantik zu i18next-t. */
@@ -48,6 +55,12 @@ type LocaleContextValue = {
48
55
 
49
56
  const LocaleContext = createContext<LocaleContextValue | undefined>(undefined);
50
57
 
58
+ // Stabile Referenz statt `fallbackBundles = []` als Default-Parameter:
59
+ // ein Literal-Default wird bei JEDEM Aufruf neu allokiert und würde die
60
+ // useMemo-Referenzprüfung im Provider unten aushebeln, sobald der
61
+ // Aufrufer fallbackBundles weglässt.
62
+ const EMPTY_FALLBACK_BUNDLES: readonly TranslationsByLocale[] = [];
63
+
51
64
  export type LocaleProviderProps = {
52
65
  readonly resolver: LocaleResolver;
53
66
  /** Von Feature-Plugins gelieferte Default-Bundles. Lookup-Reihenfolge
@@ -64,15 +77,21 @@ export type LocaleProviderProps = {
64
77
 
65
78
  export function LocaleProvider({
66
79
  resolver,
67
- fallbackBundles = [],
80
+ fallbackBundles = EMPTY_FALLBACK_BUNDLES,
68
81
  fallbackLocale = "en",
69
82
  children,
70
83
  }: LocaleProviderProps): ReactNode {
71
- return (
72
- <LocaleContext.Provider value={{ resolver, fallbackBundles, fallbackLocale }}>
73
- {children}
74
- </LocaleContext.Provider>
84
+ // Ohne Memoization baut jeder Re-Render des Providers (z.B. weil ein
85
+ // Ahnen-Component neu rendert) ein neues Context-Value-Objekt — jeder
86
+ // Consumer von useTranslation()/useLocale() sieht dann eine neue `ctx`-
87
+ // Referenz und damit selbst mit useCallback-Memoization einen neuen `t`.
88
+ // Konsequenz: `t` in einem useEffect-Dependency-Array triggert einen
89
+ // Endlos-Loop (siehe admin-shell Overview-Screens, Prod-Incident).
90
+ const value = useMemo(
91
+ () => ({ resolver, fallbackBundles, fallbackLocale }),
92
+ [resolver, fallbackBundles, fallbackLocale],
75
93
  );
94
+ return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
76
95
  }
77
96
 
78
97
  /** Liefert den aktuellen LocaleResolver und abonniert automatisch
@@ -112,38 +131,45 @@ export function useTranslation(): (
112
131
  // Re-Render bei Sprach-Wechsel. `ctx.resolver.subscribe` ist bereits
113
132
  // eine stable-reference aus dem Resolver, daher hier keine eigene
114
133
  // Memoization der Subscribe-Callback nötig.
115
- useSyncExternalStore(
134
+ const locale = useSyncExternalStore(
116
135
  ctx.resolver.subscribe,
117
136
  () => ctx.resolver.locale(),
118
137
  () => "en",
119
138
  );
120
139
 
121
- return (key, params) => {
122
- // 1. App-provided resolver zuerst. Convention: wenn der App-Resolver
123
- // den Key nicht kennt, gibt er den Key zurück das ist die
124
- // Fallback-Einladung an Plugin-Bundles. i18next verhält sich
125
- // exakt so per default.
126
- const resolved = ctx.resolver.translate(key, params);
127
- if (resolved !== key) return resolved;
128
-
129
- // 2. + 3. Plugin-Bundles durchlaufen für current + fallback-locale.
130
- const currentLocale = ctx.resolver.locale();
131
- const primaryLookup = currentLocale;
132
- // `primaryLookup` könnte z.B. "de-AT" sein — in den Bundles stehen
133
- // oft nur die Language-Roots ("de"). Wir versuchen beide.
134
- const languageRoot = primaryLookup.split("-")[0] ?? primaryLookup;
135
- const localesToTry = [primaryLookup, languageRoot, ctx.fallbackLocale];
136
-
137
- for (const bundle of ctx.fallbackBundles) {
138
- for (const locale of localesToTry) {
139
- const value = bundle[locale]?.[key];
140
- if (value !== undefined) return interpolate(value, params);
140
+ // `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
141
+ // nicht ändern Consumer nutzen `t` regelmäßig in useEffect-Deps
142
+ // (z.B. um Queries neu zu laden wenn sich die Sprache ändert). Ein neu
143
+ // erzeugtes `t` pro Render führt sonst zu einem Render/Effect-Endlos-
144
+ // Loop (siehe admin-shell Overview-Screens, Prod-Incident 2026-07-07).
145
+ return useCallback(
146
+ (key: string, params?: Readonly<Record<string, unknown>>): string => {
147
+ // 1. App-provided resolver zuerst. Convention: wenn der App-Resolver
148
+ // den Key nicht kennt, gibt er den Key zurück — das ist die
149
+ // Fallback-Einladung an Plugin-Bundles. i18next verhält sich
150
+ // exakt so per default.
151
+ const resolved = ctx.resolver.translate(key, params);
152
+ if (resolved !== key) return resolved;
153
+
154
+ // 2. + 3. Plugin-Bundles durchlaufen für current + fallback-locale.
155
+ const primaryLookup = locale;
156
+ // `primaryLookup` könnte z.B. "de-AT" sein — in den Bundles stehen
157
+ // oft nur die Language-Roots ("de"). Wir versuchen beide.
158
+ const languageRoot = primaryLookup.split("-")[0] ?? primaryLookup;
159
+ const localesToTry = [primaryLookup, languageRoot, ctx.fallbackLocale];
160
+
161
+ for (const bundle of ctx.fallbackBundles) {
162
+ for (const localeToTry of localesToTry) {
163
+ const value = bundle[localeToTry]?.[key];
164
+ if (value !== undefined) return interpolate(value, params);
165
+ }
141
166
  }
142
- }
143
167
 
144
- // 4. Nichts gefunden — key zurück, wie der Default-Resolver auch.
145
- return key;
146
- };
168
+ // 4. Nichts gefunden — key zurück, wie der Default-Resolver auch.
169
+ return key;
170
+ },
171
+ [ctx, locale],
172
+ );
147
173
  }
148
174
 
149
175
  function interpolate(template: string, params?: Readonly<Record<string, unknown>>): string {