@xndrjs/i18n-react 0.8.2 → 0.8.3
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/README.md +125 -0
- package/dist/cli/codegen.js +305 -0
- package/dist/cli/codegen.js.map +1 -0
- package/package.json +6 -9
- package/bin/codegen.mjs +0 -17
- package/src/codegen/emit/react-bindings-file.test.ts +0 -44
- package/src/codegen/emit/react-bindings-file.ts +0 -223
- package/src/codegen/generate-react-bindings.ts +0 -86
- package/src/codegen/react-codegen-config.test.ts +0 -39
- package/src/codegen/react-codegen-config.ts +0 -70
- package/src/codegen/write-file-if-changed.ts +0 -16
- package/src/create-load-coordinator.test.ts +0 -421
- package/src/create-load-coordinator.ts +0 -340
- package/src/create-scoped-t.ts +0 -50
- package/src/index.ts +0 -18
- package/src/namespace-load-gate.test.tsx +0 -640
- package/src/namespace-load-gate.tsx +0 -285
- package/src/root-context.tsx +0 -21
- package/src/root-provider.tsx +0 -65
- package/src/runtime.test.tsx +0 -119
- package/src/types.ts +0 -138
|
@@ -1,640 +0,0 @@
|
|
|
1
|
-
import { act, Component, type ReactNode, useState } from "react";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { createI18nHandle, IcuTranslationProviderMulti } from "@xndrjs/i18n";
|
|
4
|
-
import { render, screen } from "@testing-library/react";
|
|
5
|
-
import { describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { createLoadCoordinator } from "./create-load-coordinator.js";
|
|
7
|
-
import { createI18nLoadGate, useNamespaceLoad, type I18nLoadArgs } from "./namespace-load-gate.js";
|
|
8
|
-
import type { ScopedScopeLike } from "./types.js";
|
|
9
|
-
|
|
10
|
-
type MultiSchema = {
|
|
11
|
-
default: { greeting: { en: string; it: string } };
|
|
12
|
-
billing: { invoice: { en: string; it: string } };
|
|
13
|
-
};
|
|
14
|
-
type MultiParams = {
|
|
15
|
-
default: { greeting: never };
|
|
16
|
-
billing: { invoice: never };
|
|
17
|
-
};
|
|
18
|
-
type TestLocale = "en" | "it";
|
|
19
|
-
|
|
20
|
-
function asScopedScope(scope: unknown): ScopedScopeLike {
|
|
21
|
-
return scope as ScopedScopeLike;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function deferred<T>() {
|
|
25
|
-
let resolve!: (value: T) => void;
|
|
26
|
-
let reject!: (reason?: unknown) => void;
|
|
27
|
-
const promise = new Promise<T>((res, rej) => {
|
|
28
|
-
resolve = res;
|
|
29
|
-
reject = rej;
|
|
30
|
-
});
|
|
31
|
-
return { promise, resolve, reject };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
class TestErrorBoundary extends Component<
|
|
35
|
-
{ children: ReactNode; fallback: ReactNode },
|
|
36
|
-
{ error: Error | null }
|
|
37
|
-
> {
|
|
38
|
-
override state = { error: null as Error | null };
|
|
39
|
-
|
|
40
|
-
static getDerivedStateFromError(error: Error) {
|
|
41
|
-
return { error };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
override render() {
|
|
45
|
-
if (this.state.error !== null) {
|
|
46
|
-
return this.props.fallback;
|
|
47
|
-
}
|
|
48
|
-
return this.props.children;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function createTestHandle() {
|
|
53
|
-
const engine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({});
|
|
54
|
-
return createI18nHandle(engine, {
|
|
55
|
-
namespaceLoaders: {
|
|
56
|
-
default: async (locale: string) => ({
|
|
57
|
-
greeting: { [locale]: locale === "it" ? "Ciao" : "Hello" },
|
|
58
|
-
}),
|
|
59
|
-
billing: async (locale: string) => ({
|
|
60
|
-
invoice: { [locale]: locale === "it" ? "Fattura" : "Invoice" },
|
|
61
|
-
}),
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function createGateFixture(options?: {
|
|
67
|
-
load?: (namespaces: readonly string[]) => Promise<ScopedScopeLike>;
|
|
68
|
-
locale?: TestLocale;
|
|
69
|
-
keepPreviousOnPartitionChange?: boolean;
|
|
70
|
-
}) {
|
|
71
|
-
const handle = createTestHandle();
|
|
72
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
73
|
-
const locale = options?.locale ?? "it";
|
|
74
|
-
const load =
|
|
75
|
-
options?.load ??
|
|
76
|
-
((namespaces: readonly string[]) =>
|
|
77
|
-
handle.load({ namespaces: [...namespaces] as ["default"], locale }).then(asScopedScope));
|
|
78
|
-
|
|
79
|
-
const { I18n, withI18n } = createI18nLoadGate({
|
|
80
|
-
...(options?.keepPreviousOnPartitionChange !== undefined
|
|
81
|
-
? { keepPreviousOnPartitionChange: options.keepPreviousOnPartitionChange }
|
|
82
|
-
: { keepPreviousOnPartitionChange: false }),
|
|
83
|
-
useLoadArgs: (): I18nLoadArgs => ({
|
|
84
|
-
coordinator,
|
|
85
|
-
engineRef: handle,
|
|
86
|
-
partition: locale,
|
|
87
|
-
locale,
|
|
88
|
-
load,
|
|
89
|
-
}),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
return { I18n, withI18n, coordinator, handle, locale };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
describe("useNamespaceLoad", () => {
|
|
96
|
-
it("re-renders when the coordinator entry resolves", async () => {
|
|
97
|
-
const pending = deferred<ScopedScopeLike>();
|
|
98
|
-
const handle = createTestHandle();
|
|
99
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
100
|
-
|
|
101
|
-
function Probe() {
|
|
102
|
-
const entry = useNamespaceLoad({
|
|
103
|
-
coordinator,
|
|
104
|
-
engineRef: handle,
|
|
105
|
-
partition: "it",
|
|
106
|
-
namespaces: ["default"],
|
|
107
|
-
locale: "it",
|
|
108
|
-
load: () => pending.promise,
|
|
109
|
-
});
|
|
110
|
-
return <span data-testid="status">{entry.status}</span>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
render(<Probe />);
|
|
114
|
-
expect(screen.getByTestId("status").textContent).toBe("pending");
|
|
115
|
-
|
|
116
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
117
|
-
await act(async () => {
|
|
118
|
-
pending.resolve(asScopedScope(scope));
|
|
119
|
-
await pending.promise;
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
expect(screen.getByTestId("status").textContent).toBe("ready");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("SSR getServerSnapshot does not call load without sync seed", () => {
|
|
126
|
-
const load = vi.fn(() => Promise.resolve(asScopedScope({ t: () => "" })));
|
|
127
|
-
const handle = createTestHandle();
|
|
128
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
129
|
-
|
|
130
|
-
function Probe() {
|
|
131
|
-
const entry = useNamespaceLoad({
|
|
132
|
-
coordinator,
|
|
133
|
-
engineRef: handle,
|
|
134
|
-
partition: "it",
|
|
135
|
-
namespaces: ["default"],
|
|
136
|
-
locale: "it",
|
|
137
|
-
load,
|
|
138
|
-
});
|
|
139
|
-
return <span>{entry.status}</span>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const html = renderToString(<Probe />);
|
|
143
|
-
expect(html).toContain("pending");
|
|
144
|
-
expect(load).not.toHaveBeenCalled();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("SSR getServerSnapshot uses tryResolveSync without calling load", async () => {
|
|
148
|
-
const handle = createTestHandle();
|
|
149
|
-
await handle.load({ namespaces: ["default"], locale: "it" });
|
|
150
|
-
const load = vi.fn(() => Promise.reject(new Error("should not load")));
|
|
151
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
152
|
-
|
|
153
|
-
function Probe() {
|
|
154
|
-
const entry = useNamespaceLoad({
|
|
155
|
-
coordinator,
|
|
156
|
-
engineRef: handle,
|
|
157
|
-
partition: "it",
|
|
158
|
-
namespaces: ["default"],
|
|
159
|
-
locale: "it",
|
|
160
|
-
load,
|
|
161
|
-
tryResolveSync: () => asScopedScope(handle.peek({ namespaces: ["default"], locale: "it" })),
|
|
162
|
-
});
|
|
163
|
-
return <span>{entry.status}</span>;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const html = renderToString(<Probe />);
|
|
167
|
-
expect(html).toContain("ready");
|
|
168
|
-
expect(load).not.toHaveBeenCalled();
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("createI18nLoadGate", () => {
|
|
173
|
-
it("preserves parent useState while the gate is pending", async () => {
|
|
174
|
-
const pending = deferred<ScopedScopeLike>();
|
|
175
|
-
const { I18n } = createGateFixture({ load: () => pending.promise });
|
|
176
|
-
|
|
177
|
-
function Parent() {
|
|
178
|
-
const [count, setCount] = useState(0);
|
|
179
|
-
return (
|
|
180
|
-
<div>
|
|
181
|
-
<button type="button" data-testid="inc" onClick={() => setCount((c) => c + 1)}>
|
|
182
|
-
inc
|
|
183
|
-
</button>
|
|
184
|
-
<span data-testid="count">{count}</span>
|
|
185
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="fallback">loading</span>}>
|
|
186
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
187
|
-
</I18n>
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
render(<Parent />);
|
|
193
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading");
|
|
194
|
-
expect(screen.getByTestId("count").textContent).toBe("0");
|
|
195
|
-
|
|
196
|
-
await act(async () => {
|
|
197
|
-
screen.getByTestId("inc").click();
|
|
198
|
-
});
|
|
199
|
-
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
200
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading");
|
|
201
|
-
|
|
202
|
-
const handle = createTestHandle();
|
|
203
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
204
|
-
await act(async () => {
|
|
205
|
-
pending.resolve(asScopedScope(scope));
|
|
206
|
-
await pending.promise;
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
expect(screen.getByTestId("greeting").textContent).toBe("Ciao");
|
|
210
|
-
expect(screen.getByTestId("count").textContent).toBe("1");
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("loads multiple namespaces and injects one scoped multi t", async () => {
|
|
214
|
-
const pending = deferred<ScopedScopeLike>();
|
|
215
|
-
const { I18n } = createGateFixture({ load: () => pending.promise });
|
|
216
|
-
|
|
217
|
-
render(
|
|
218
|
-
<I18n namespaces={["default", "billing"]} fallback={<span data-testid="fallback">wait</span>}>
|
|
219
|
-
{({ t, locale }) => (
|
|
220
|
-
<span data-testid="copy">
|
|
221
|
-
{locale}:{t("default", "greeting")}/{t("billing", "invoice")}
|
|
222
|
-
</span>
|
|
223
|
-
)}
|
|
224
|
-
</I18n>
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
expect(screen.getByTestId("fallback").textContent).toBe("wait");
|
|
228
|
-
|
|
229
|
-
const handle = createTestHandle();
|
|
230
|
-
const scope = await handle.load({ namespaces: ["default", "billing"], locale: "it" });
|
|
231
|
-
await act(async () => {
|
|
232
|
-
pending.resolve(asScopedScope(scope));
|
|
233
|
-
await pending.promise;
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
expect(screen.queryByTestId("fallback")).toBeNull();
|
|
237
|
-
expect(screen.getByTestId("copy").textContent).toBe("it:Ciao/Fattura");
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("defaults fallback to null while pending", () => {
|
|
241
|
-
const pending = deferred<ScopedScopeLike>();
|
|
242
|
-
const { I18n } = createGateFixture({ load: () => pending.promise });
|
|
243
|
-
|
|
244
|
-
const { container } = render(
|
|
245
|
-
<I18n namespaces={["default"]}>
|
|
246
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
247
|
-
</I18n>
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
expect(screen.queryByTestId("greeting")).toBeNull();
|
|
251
|
-
expect(container.textContent).toBe("");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("throws to an error boundary when load fails without renderError", async () => {
|
|
255
|
-
const pending = deferred<ScopedScopeLike>();
|
|
256
|
-
const { I18n, coordinator, handle, locale } = createGateFixture({
|
|
257
|
-
load: () => pending.promise,
|
|
258
|
-
});
|
|
259
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
260
|
-
|
|
261
|
-
render(
|
|
262
|
-
<TestErrorBoundary fallback={<span data-testid="caught">caught</span>}>
|
|
263
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="fallback">loading</span>}>
|
|
264
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
265
|
-
</I18n>
|
|
266
|
-
</TestErrorBoundary>
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading");
|
|
270
|
-
|
|
271
|
-
await act(async () => {
|
|
272
|
-
pending.reject(new Error("load failed"));
|
|
273
|
-
await pending.promise.catch(() => undefined);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
expect(screen.getByTestId("caught").textContent).toBe("caught");
|
|
277
|
-
expect(screen.queryByTestId("fallback")).toBeNull();
|
|
278
|
-
expect(screen.queryByTestId("greeting")).toBeNull();
|
|
279
|
-
expect(
|
|
280
|
-
coordinator.getEntry({
|
|
281
|
-
engineRef: handle,
|
|
282
|
-
partition: locale,
|
|
283
|
-
namespaces: ["default"],
|
|
284
|
-
})
|
|
285
|
-
).toEqual({ status: "error", error: expect.any(Error) });
|
|
286
|
-
|
|
287
|
-
consoleError.mockRestore();
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("renderError exposes retry for failed loads", async () => {
|
|
291
|
-
let attempts = 0;
|
|
292
|
-
const handle = createTestHandle();
|
|
293
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
294
|
-
let currentPromise = deferred<ScopedScopeLike>();
|
|
295
|
-
|
|
296
|
-
const { I18n } = createI18nLoadGate({
|
|
297
|
-
keepPreviousOnPartitionChange: false,
|
|
298
|
-
useLoadArgs: (): I18nLoadArgs => ({
|
|
299
|
-
coordinator,
|
|
300
|
-
engineRef: handle,
|
|
301
|
-
partition: "it",
|
|
302
|
-
locale: "it",
|
|
303
|
-
load: () => {
|
|
304
|
-
attempts += 1;
|
|
305
|
-
return currentPromise.promise;
|
|
306
|
-
},
|
|
307
|
-
}),
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
render(
|
|
311
|
-
<I18n
|
|
312
|
-
namespaces={["default"]}
|
|
313
|
-
fallback={<span data-testid="fallback">loading</span>}
|
|
314
|
-
renderError={({ retry }) => (
|
|
315
|
-
<button type="button" data-testid="retry" onClick={retry}>
|
|
316
|
-
retry
|
|
317
|
-
</button>
|
|
318
|
-
)}
|
|
319
|
-
>
|
|
320
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
321
|
-
</I18n>
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
await act(async () => {
|
|
325
|
-
currentPromise.reject(new Error("boom"));
|
|
326
|
-
await currentPromise.promise.catch(() => undefined);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
expect(screen.getByTestId("retry")).toBeTruthy();
|
|
330
|
-
expect(attempts).toBe(1);
|
|
331
|
-
|
|
332
|
-
currentPromise = deferred<ScopedScopeLike>();
|
|
333
|
-
await act(async () => {
|
|
334
|
-
screen.getByTestId("retry").click();
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
338
|
-
await act(async () => {
|
|
339
|
-
currentPromise.resolve(asScopedScope(scope));
|
|
340
|
-
await currentPromise.promise;
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
expect(attempts).toBe(2);
|
|
344
|
-
expect(screen.getByTestId("greeting").textContent).toBe("Ciao");
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("HOC Outer injects t when resolved and keeps parent state while pending", async () => {
|
|
348
|
-
const pending = deferred<ScopedScopeLike>();
|
|
349
|
-
const { withI18n } = createGateFixture({ load: () => pending.promise });
|
|
350
|
-
|
|
351
|
-
const Child = withI18n<{ label: string }>(
|
|
352
|
-
{
|
|
353
|
-
namespaces: ["default"],
|
|
354
|
-
fallback: <span data-testid="fallback">loading</span>,
|
|
355
|
-
},
|
|
356
|
-
function Child({ label }, { t }) {
|
|
357
|
-
return (
|
|
358
|
-
<span data-testid="child">
|
|
359
|
-
{label}:{t("default", "greeting")}
|
|
360
|
-
</span>
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
function Parent() {
|
|
366
|
-
const [n, setN] = useState(0);
|
|
367
|
-
return (
|
|
368
|
-
<div>
|
|
369
|
-
<button type="button" data-testid="bump" onClick={() => setN((x) => x + 1)}>
|
|
370
|
-
bump
|
|
371
|
-
</button>
|
|
372
|
-
<span data-testid="n">{n}</span>
|
|
373
|
-
<Child label="x" />
|
|
374
|
-
</div>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
render(<Parent />);
|
|
379
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading");
|
|
380
|
-
|
|
381
|
-
await act(async () => {
|
|
382
|
-
screen.getByTestId("bump").click();
|
|
383
|
-
});
|
|
384
|
-
expect(screen.getByTestId("n").textContent).toBe("1");
|
|
385
|
-
|
|
386
|
-
const handle = createTestHandle();
|
|
387
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
388
|
-
await act(async () => {
|
|
389
|
-
pending.resolve(asScopedScope(scope));
|
|
390
|
-
await pending.promise;
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
expect(screen.getByTestId("child").textContent).toBe("x:Ciao");
|
|
394
|
-
expect(screen.getByTestId("n").textContent).toBe("1");
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it("HOC forwards ref to the inner render when resolved", async () => {
|
|
398
|
-
const { withI18n } = createGateFixture();
|
|
399
|
-
const ref = { current: null as HTMLSpanElement | null };
|
|
400
|
-
|
|
401
|
-
const Child = withI18n<{ label: string }, HTMLSpanElement>(
|
|
402
|
-
{ namespaces: ["default"] },
|
|
403
|
-
function Child({ label }, { t }, forwarded) {
|
|
404
|
-
return (
|
|
405
|
-
<span ref={forwarded} data-testid="child">
|
|
406
|
-
{label}:{t("default", "greeting")}
|
|
407
|
-
</span>
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
await act(async () => {
|
|
413
|
-
render(<Child label="ref" ref={ref} />);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
expect(await screen.findByTestId("child")).toBeTruthy();
|
|
417
|
-
expect(ref.current?.tagName).toBe("SPAN");
|
|
418
|
-
expect(ref.current?.textContent).toBe("ref:Ciao");
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("HOC fallback can read own props", async () => {
|
|
422
|
-
const pending = deferred<ScopedScopeLike>();
|
|
423
|
-
const { withI18n } = createGateFixture({ load: () => pending.promise });
|
|
424
|
-
|
|
425
|
-
const Child = withI18n<{ label: string }>(
|
|
426
|
-
{
|
|
427
|
-
namespaces: ["default"],
|
|
428
|
-
fallback: ({ label }) => <span data-testid="fallback">loading:{label}</span>,
|
|
429
|
-
},
|
|
430
|
-
function Child({ label }, { t }) {
|
|
431
|
-
return (
|
|
432
|
-
<span data-testid="child">
|
|
433
|
-
{label}:{t("default", "greeting")}
|
|
434
|
-
</span>
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
render(<Child label="x" />);
|
|
440
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading:x");
|
|
441
|
-
|
|
442
|
-
const handle = createTestHandle();
|
|
443
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
444
|
-
await act(async () => {
|
|
445
|
-
pending.resolve(asScopedScope(scope));
|
|
446
|
-
await pending.promise;
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
expect(screen.getByTestId("child").textContent).toBe("x:Ciao");
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it("HOC render may use hooks across pending → ready (no hydrated state)", async () => {
|
|
453
|
-
const pending = deferred<ScopedScopeLike>();
|
|
454
|
-
const { withI18n } = createGateFixture({ load: () => pending.promise });
|
|
455
|
-
|
|
456
|
-
const Child = withI18n<{ label: string }>(
|
|
457
|
-
{
|
|
458
|
-
namespaces: ["default"],
|
|
459
|
-
fallback: <span data-testid="fallback">loading</span>,
|
|
460
|
-
},
|
|
461
|
-
function Child({ label }, { t }) {
|
|
462
|
-
const [n] = useState(0);
|
|
463
|
-
return (
|
|
464
|
-
<span data-testid="child">
|
|
465
|
-
{label}:{t("default", "greeting")}:{n}
|
|
466
|
-
</span>
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
);
|
|
470
|
-
|
|
471
|
-
render(<Child label="x" />);
|
|
472
|
-
expect(screen.getByTestId("fallback").textContent).toBe("loading");
|
|
473
|
-
|
|
474
|
-
const handle = createTestHandle();
|
|
475
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "it" });
|
|
476
|
-
await act(async () => {
|
|
477
|
-
pending.resolve(asScopedScope(scope));
|
|
478
|
-
await pending.promise;
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
expect(screen.getByTestId("child").textContent).toBe("x:Ciao:0");
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
it("dedupes loads across two gates sharing one coordinator", async () => {
|
|
485
|
-
const handle = createTestHandle();
|
|
486
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
487
|
-
let loadCount = 0;
|
|
488
|
-
const pending = deferred<ScopedScopeLike>();
|
|
489
|
-
|
|
490
|
-
const useLoadArgs = (): I18nLoadArgs => ({
|
|
491
|
-
coordinator,
|
|
492
|
-
engineRef: handle,
|
|
493
|
-
partition: "en",
|
|
494
|
-
locale: "en",
|
|
495
|
-
load: () => {
|
|
496
|
-
loadCount += 1;
|
|
497
|
-
return pending.promise;
|
|
498
|
-
},
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
const { I18n } = createI18nLoadGate({
|
|
502
|
-
keepPreviousOnPartitionChange: false,
|
|
503
|
-
useLoadArgs,
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
function Twin() {
|
|
507
|
-
return (
|
|
508
|
-
<>
|
|
509
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="a">a</span>}>
|
|
510
|
-
{({ t }) => <span data-testid="ga">{t("default", "greeting")}</span>}
|
|
511
|
-
</I18n>
|
|
512
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="b">b</span>}>
|
|
513
|
-
{({ t }) => <span data-testid="gb">{t("default", "greeting")}</span>}
|
|
514
|
-
</I18n>
|
|
515
|
-
</>
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
render(<Twin />);
|
|
520
|
-
expect(loadCount).toBe(1);
|
|
521
|
-
|
|
522
|
-
const scope = await handle.load({ namespaces: ["default"], locale: "en" });
|
|
523
|
-
await act(async () => {
|
|
524
|
-
pending.resolve(asScopedScope(scope));
|
|
525
|
-
await pending.promise;
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
expect(screen.getByTestId("ga").textContent).toBe("Hello");
|
|
529
|
-
expect(screen.getByTestId("gb").textContent).toBe("Hello");
|
|
530
|
-
});
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
describe("createI18nLoadGate ready path", () => {
|
|
534
|
-
it("renders immediately when the coordinator entry is already resolved", async () => {
|
|
535
|
-
const handle = createTestHandle();
|
|
536
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
537
|
-
const locale: TestLocale = "it";
|
|
538
|
-
const scope = asScopedScope(await handle.load({ namespaces: ["default"], locale }));
|
|
539
|
-
|
|
540
|
-
coordinator.request({
|
|
541
|
-
engineRef: handle,
|
|
542
|
-
partition: locale,
|
|
543
|
-
namespaces: ["default"],
|
|
544
|
-
load: async () => scope,
|
|
545
|
-
});
|
|
546
|
-
await coordinator.getPromise();
|
|
547
|
-
|
|
548
|
-
const { I18n } = createI18nLoadGate({
|
|
549
|
-
keepPreviousOnPartitionChange: false,
|
|
550
|
-
useLoadArgs: (): I18nLoadArgs => ({
|
|
551
|
-
coordinator,
|
|
552
|
-
engineRef: handle,
|
|
553
|
-
partition: locale,
|
|
554
|
-
locale,
|
|
555
|
-
load: async () => scope,
|
|
556
|
-
}),
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
render(
|
|
560
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="fallback">nope</span>}>
|
|
561
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
562
|
-
</I18n>
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
expect(screen.queryByTestId("fallback")).toBeNull();
|
|
566
|
-
expect(screen.getByTestId("greeting").textContent).toBe("Ciao");
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
describe("createI18nLoadGate keep-then-switch", () => {
|
|
571
|
-
it("keeps previous t across locale change until new partition resolves", async () => {
|
|
572
|
-
const handle = createTestHandle();
|
|
573
|
-
const coordinator = createLoadCoordinator<ScopedScopeLike>();
|
|
574
|
-
let activeLocale: TestLocale = "en";
|
|
575
|
-
const pendingIt = deferred<ScopedScopeLike>();
|
|
576
|
-
|
|
577
|
-
const { I18n } = createI18nLoadGate({
|
|
578
|
-
keepPreviousOnPartitionChange: true,
|
|
579
|
-
useLoadArgs: (): I18nLoadArgs => ({
|
|
580
|
-
coordinator,
|
|
581
|
-
engineRef: handle,
|
|
582
|
-
partition: activeLocale,
|
|
583
|
-
locale: activeLocale,
|
|
584
|
-
load: (namespaces) => {
|
|
585
|
-
if (activeLocale === "it") {
|
|
586
|
-
return pendingIt.promise;
|
|
587
|
-
}
|
|
588
|
-
return handle
|
|
589
|
-
.load({ namespaces: [...namespaces] as ["default"], locale: activeLocale })
|
|
590
|
-
.then(asScopedScope);
|
|
591
|
-
},
|
|
592
|
-
}),
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
function App({ locale }: { locale: TestLocale }) {
|
|
596
|
-
activeLocale = locale;
|
|
597
|
-
return (
|
|
598
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="fallback">loading</span>}>
|
|
599
|
-
{({ t, locale: displayLocale, pendingLocale }) => (
|
|
600
|
-
<span data-testid="row">
|
|
601
|
-
{displayLocale}:{t("default", "greeting")}:{pendingLocale ?? "-"}
|
|
602
|
-
</span>
|
|
603
|
-
)}
|
|
604
|
-
</I18n>
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const { rerender } = render(<App locale="en" />);
|
|
609
|
-
expect((await screen.findByTestId("row")).textContent).toBe("en:Hello:-");
|
|
610
|
-
|
|
611
|
-
rerender(<App locale="it" />);
|
|
612
|
-
expect(screen.queryByTestId("fallback")).toBeNull();
|
|
613
|
-
expect(screen.getByTestId("row").textContent).toBe("en:Hello:it");
|
|
614
|
-
|
|
615
|
-
const itScope = asScopedScope(await handle.load({ namespaces: ["default"], locale: "it" }));
|
|
616
|
-
await act(async () => {
|
|
617
|
-
pendingIt.resolve(itScope);
|
|
618
|
-
await pendingIt.promise;
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
expect(screen.getByTestId("row").textContent).toBe("it:Ciao:-");
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it("first mount without snapshot still uses fallback", () => {
|
|
625
|
-
const pending = deferred<ScopedScopeLike>();
|
|
626
|
-
const { I18n } = createGateFixture({
|
|
627
|
-
load: () => pending.promise,
|
|
628
|
-
keepPreviousOnPartitionChange: true,
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
render(
|
|
632
|
-
<I18n namespaces={["default"]} fallback={<span data-testid="fallback">wait</span>}>
|
|
633
|
-
{({ t }) => <span data-testid="greeting">{t("default", "greeting")}</span>}
|
|
634
|
-
</I18n>
|
|
635
|
-
);
|
|
636
|
-
|
|
637
|
-
expect(screen.getByTestId("fallback").textContent).toBe("wait");
|
|
638
|
-
expect(screen.queryByTestId("greeting")).toBeNull();
|
|
639
|
-
});
|
|
640
|
-
});
|