@cosmicdrift/kumiko-renderer 0.125.1 → 0.126.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/package.json +3 -3
- package/src/__tests__/i18n.test.tsx +41 -0
- package/src/i18n.tsx +57 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.126.0",
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.126.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.126.0",
|
|
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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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 {
|