@bleedingdev/modern-js-plugin-i18n 3.9.0-ultramodern.2 → 3.9.0-ultramodern.21

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.
Files changed (127) hide show
  1. package/dist/cjs/cli/index.js +10 -21
  2. package/dist/cjs/cli/localisedRoutes.js +47 -0
  3. package/dist/cjs/runtime/Link.js +19 -32
  4. package/dist/cjs/runtime/context.js +30 -73
  5. package/dist/cjs/runtime/contextHelpers.js +5 -5
  6. package/dist/cjs/runtime/contexts.js +49 -0
  7. package/dist/cjs/runtime/core.js +27 -14
  8. package/dist/cjs/runtime/hooks.js +7 -8
  9. package/dist/cjs/runtime/localizedPaths.js +11 -9
  10. package/dist/cjs/runtime/navigation.js +48 -0
  11. package/dist/cjs/runtime/providerComposition.js +37 -13
  12. package/dist/cjs/runtime/routerAdapter.js +33 -140
  13. package/dist/cjs/runtime/sharedContexts.js +43 -0
  14. package/dist/cjs/runtime/utils.js +21 -19
  15. package/dist/cjs/server/defaultUrlStrategy.js +48 -0
  16. package/dist/cjs/server/index.js +11 -7
  17. package/dist/cjs/server/mappedUrlStrategy.js +43 -0
  18. package/dist/cjs/server/redirectPolicy.js +33 -24
  19. package/dist/cjs/shared/mappedUrlStrategy.js +63 -0
  20. package/dist/cjs/shared/urlStrategy.js +48 -0
  21. package/dist/esm/cli/index.mjs +10 -21
  22. package/dist/esm/cli/localisedRoutes.mjs +9 -0
  23. package/dist/esm/runtime/Link.mjs +19 -32
  24. package/dist/esm/runtime/context.mjs +20 -72
  25. package/dist/esm/runtime/contextHelpers.mjs +5 -5
  26. package/dist/esm/runtime/contexts.mjs +5 -0
  27. package/dist/esm/runtime/core.mjs +13 -6
  28. package/dist/esm/runtime/hooks.mjs +7 -8
  29. package/dist/esm/runtime/localizedPaths.mjs +11 -9
  30. package/dist/esm/runtime/navigation.mjs +7 -0
  31. package/dist/esm/runtime/providerComposition.mjs +38 -14
  32. package/dist/esm/runtime/routerAdapter.mjs +28 -141
  33. package/dist/esm/runtime/sharedContexts.mjs +1 -0
  34. package/dist/esm/runtime/utils.mjs +19 -17
  35. package/dist/esm/server/defaultUrlStrategy.mjs +7 -0
  36. package/dist/esm/server/index.mjs +11 -7
  37. package/dist/esm/server/mappedUrlStrategy.mjs +1 -0
  38. package/dist/esm/server/redirectPolicy.mjs +33 -24
  39. package/dist/esm/shared/mappedUrlStrategy.mjs +19 -0
  40. package/dist/esm/shared/urlStrategy.mjs +7 -0
  41. package/dist/esm-node/cli/index.mjs +10 -21
  42. package/dist/esm-node/cli/localisedRoutes.mjs +10 -0
  43. package/dist/esm-node/runtime/Link.mjs +19 -32
  44. package/dist/esm-node/runtime/context.mjs +20 -72
  45. package/dist/esm-node/runtime/contextHelpers.mjs +5 -5
  46. package/dist/esm-node/runtime/contexts.mjs +6 -0
  47. package/dist/esm-node/runtime/core.mjs +13 -6
  48. package/dist/esm-node/runtime/hooks.mjs +7 -8
  49. package/dist/esm-node/runtime/localizedPaths.mjs +11 -9
  50. package/dist/esm-node/runtime/navigation.mjs +8 -0
  51. package/dist/esm-node/runtime/providerComposition.mjs +38 -14
  52. package/dist/esm-node/runtime/routerAdapter.mjs +28 -141
  53. package/dist/esm-node/runtime/sharedContexts.mjs +2 -0
  54. package/dist/esm-node/runtime/utils.mjs +19 -17
  55. package/dist/esm-node/server/defaultUrlStrategy.mjs +8 -0
  56. package/dist/esm-node/server/index.mjs +11 -7
  57. package/dist/esm-node/server/mappedUrlStrategy.mjs +2 -0
  58. package/dist/esm-node/server/redirectPolicy.mjs +33 -24
  59. package/dist/esm-node/shared/mappedUrlStrategy.mjs +20 -0
  60. package/dist/esm-node/shared/urlStrategy.mjs +8 -0
  61. package/dist/types/cli/localisedRoutes.d.ts +19 -0
  62. package/dist/types/runtime/context.d.ts +8 -11
  63. package/dist/types/runtime/contextHelpers.d.ts +2 -2
  64. package/dist/types/runtime/contexts.d.ts +6 -0
  65. package/dist/types/runtime/core.d.ts +12 -2
  66. package/dist/types/runtime/hooks.d.ts +4 -5
  67. package/dist/types/runtime/localizedPaths.d.ts +4 -4
  68. package/dist/types/runtime/navigation.d.ts +10 -0
  69. package/dist/types/runtime/providerComposition.d.ts +13 -1
  70. package/dist/types/runtime/routerAdapter.d.ts +23 -6
  71. package/dist/types/runtime/sharedContexts.d.ts +1 -0
  72. package/dist/types/runtime/utils.d.ts +4 -12
  73. package/dist/types/server/defaultUrlStrategy.d.ts +11 -0
  74. package/dist/types/server/index.d.ts +2 -0
  75. package/dist/types/server/mappedUrlStrategy.d.ts +7 -0
  76. package/dist/types/server/redirectPolicy.d.ts +5 -13
  77. package/dist/types/shared/mappedUrlStrategy.d.ts +27 -0
  78. package/dist/types/shared/type.d.ts +7 -11
  79. package/dist/types/shared/urlStrategy.d.ts +18 -0
  80. package/package.json +42 -19
  81. package/rslib.config.mts +8 -1
  82. package/rstest.config.mts +6 -5
  83. package/src/cli/index.ts +42 -42
  84. package/src/cli/localisedRoutes.ts +53 -0
  85. package/src/runtime/Link.tsx +20 -40
  86. package/src/runtime/context.tsx +33 -157
  87. package/src/runtime/contextHelpers.ts +7 -5
  88. package/src/runtime/contexts.ts +16 -0
  89. package/src/runtime/core.tsx +53 -6
  90. package/src/runtime/hooks.ts +14 -9
  91. package/src/runtime/i18n/instance.ts +1 -2
  92. package/src/runtime/localizedPaths.ts +24 -19
  93. package/src/runtime/navigation.tsx +20 -0
  94. package/src/runtime/providerComposition.tsx +71 -23
  95. package/src/runtime/routerAdapter.tsx +65 -339
  96. package/src/runtime/sharedContexts.ts +8 -0
  97. package/src/runtime/utils.ts +49 -31
  98. package/src/server/defaultUrlStrategy.ts +27 -0
  99. package/src/server/index.ts +39 -9
  100. package/src/server/mappedUrlStrategy.ts +10 -0
  101. package/src/server/redirectPolicy.ts +85 -70
  102. package/src/shared/mappedUrlStrategy.ts +59 -0
  103. package/src/shared/type.ts +7 -11
  104. package/src/shared/urlStrategy.ts +38 -0
  105. package/tests/backendDefaults.test.ts +2 -23
  106. package/tests/defaultUrlStrategy.test.ts +107 -0
  107. package/tests/federatedI18nBoundaryResolver.test.tsx +65 -0
  108. package/tests/i18nUtils.test.ts +31 -65
  109. package/tests/link.test.tsx +13 -511
  110. package/tests/localisedRouteGeneration.test.ts +200 -0
  111. package/tests/localisedUrls.test.ts +16 -208
  112. package/tests/mappedUrlStrategyDerivation.test.ts +62 -0
  113. package/tests/navigation.test.tsx +403 -0
  114. package/tests/reactI18nextRuntimeBoundary.test.ts +8 -83
  115. package/tests/redirectPolicy.test.ts +89 -27
  116. package/tests/routerAdapter.test.tsx +98 -1171
  117. package/tests/urlStrategySerialization.test.ts +122 -0
  118. package/tsconfig.json +4 -2
  119. package/tests/federatedI18nBoundary.client.test.tsx +0 -129
  120. package/tests/federatedI18nBoundary.test.tsx +0 -132
  121. package/tests/linkTypes.test.ts +0 -59
  122. package/tests/localisedUrlRewriteMatrix.fork.test.ts +0 -147
  123. package/tests/peerCohort.test.ts +0 -25
  124. package/tests/type-fixture/fixture-globals.d.ts +0 -11
  125. package/tests/type-fixture/i18nInstanceTypes.fixture.ts +0 -29
  126. package/tests/type-fixture/linkTypes.fixture.tsx +0 -51
  127. package/tests/type-fixture/tsconfig.json +0 -16
@@ -0,0 +1,403 @@
1
+ import {
2
+ InternalRuntimeContext,
3
+ RuntimeContext,
4
+ } from '@modern-js/runtime/context';
5
+ import { applyRouterRuntimeState } from '@modern-js/runtime-extensions/router-state';
6
+ import type React from 'react';
7
+ import type { ComponentType } from 'react';
8
+ import { act } from 'react';
9
+ import { createRoot, type Root } from 'react-dom/client';
10
+ import { useModernI18n } from '../src/runtime/context';
11
+ import { createI18nPlugin } from '../src/runtime/core';
12
+ import type { I18nInstance } from '../src/runtime/i18n';
13
+ import { Link } from '../src/runtime/Link';
14
+ import { useIntegratedRouterAdapter } from '../src/runtime/navigation';
15
+
16
+ (
17
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
18
+ ).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ const localisedUrls = {
21
+ '/terms-of-service': {
22
+ en: '/terms-of-service',
23
+ cs: '/obchodni-podminky',
24
+ },
25
+ };
26
+
27
+ function createI18nInstance(language = 'en'): I18nInstance {
28
+ return {
29
+ language,
30
+ isInitialized: true,
31
+ init: () => Promise.resolve(undefined),
32
+ use: () => {},
33
+ t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
34
+ createInstance: () => createI18nInstance(language),
35
+ setLang: rstest.fn(async (next: string) => {
36
+ void next;
37
+ return undefined;
38
+ }),
39
+ changeLanguage: rstest.fn(async () => undefined),
40
+ services: {},
41
+ options: {},
42
+ };
43
+ }
44
+
45
+ /** A provider capability whose location can be republished. */
46
+ function createMutableTanstackRouter(pathname: string) {
47
+ let snapshot = {
48
+ location: { pathname, search: '', hash: '' },
49
+ params: { lang: pathname.split('/')[1] ?? '' },
50
+ };
51
+ const listeners = new Set<() => void>();
52
+
53
+ return {
54
+ navigate: rstest.fn(async () => undefined),
55
+ navigation: {
56
+ getSnapshot: () => snapshot,
57
+ Link: TanstackLink,
58
+ navigate: rstest.fn(async () => undefined),
59
+ subscribe: (listener: () => void) => {
60
+ listeners.add(listener);
61
+ return () => listeners.delete(listener);
62
+ },
63
+ },
64
+ publishPathname(nextPathname: string) {
65
+ snapshot = {
66
+ location: { pathname: nextPathname, search: '', hash: '' },
67
+ params: { lang: nextPathname.split('/')[1] ?? '' },
68
+ };
69
+ for (const listener of listeners) {
70
+ listener();
71
+ }
72
+ },
73
+ };
74
+ }
75
+
76
+ const TanstackLink = ({ to, children, ...props }: any) => {
77
+ const { prefetch: _prefetch, preload: _preload, ...anchorProps } = props;
78
+ return (
79
+ <a href={to} data-router-link="tanstack" {...anchorProps}>
80
+ {children}
81
+ </a>
82
+ );
83
+ };
84
+
85
+ /**
86
+ * A bare `appTools()` consumer: only `@modern-js/plugin-i18n` is registered, so
87
+ * nothing supplies a `NavigationProvider`.
88
+ */
89
+ function setupBareI18nRuntime(i18nInstance: I18nInstance) {
90
+ let wrapRoot: ((App: ComponentType<any>) => ComponentType<any>) | undefined;
91
+ let beforeRender: ((context: any) => Promise<void>) | undefined;
92
+
93
+ createI18nPlugin()({
94
+ i18nInstance,
95
+ reactI18next: false,
96
+ localeDetection: {
97
+ fallbackLanguage: 'en',
98
+ i18nextDetector: false,
99
+ languages: ['en', 'cs'],
100
+ localePathRedirect: true,
101
+ localisedUrls,
102
+ },
103
+ }).setup?.({
104
+ getRuntimeConfig: () => ({}),
105
+ resolveComponent: () => undefined,
106
+ onBeforeRender: (callback: (context: any) => Promise<void>) => {
107
+ beforeRender = callback;
108
+ },
109
+ wrapRoot: (callback: (App: ComponentType<any>) => ComponentType<any>) => {
110
+ wrapRoot = callback;
111
+ },
112
+ } as any);
113
+
114
+ if (!wrapRoot || !beforeRender) {
115
+ throw new Error(
116
+ 'Expected the i18n runtime plugin to register wrapRoot and onBeforeRender',
117
+ );
118
+ }
119
+ return { wrapRoot, beforeRender };
120
+ }
121
+
122
+ function createTanstackRuntimeContext(
123
+ router: ReturnType<typeof createMutableTanstackRouter>,
124
+ ) {
125
+ const requestContext = { request: {}, response: {} };
126
+ const runtimeContext: Record<string, unknown> = {
127
+ isBrowser: true,
128
+ requestContext,
129
+ context: requestContext,
130
+ router: { Link: TanstackLink },
131
+ };
132
+ applyRouterRuntimeState(runtimeContext, {
133
+ framework: 'tanstack',
134
+ instance: router,
135
+ navigation: router.navigation,
136
+ } as any);
137
+ return runtimeContext;
138
+ }
139
+
140
+ /**
141
+ * The CSR boot order: `plugin-tanstack`'s `onBeforeRender` publishes only the
142
+ * hook API, and no router instance exists until `RouterWrapper` renders -
143
+ * which is below anything that wraps the app, this provider included.
144
+ */
145
+ function createTanstackCsrRuntimeContextWithoutRouter() {
146
+ const requestContext = { request: {}, response: {} };
147
+ return {
148
+ isBrowser: true,
149
+ requestContext,
150
+ context: requestContext,
151
+ router: {
152
+ Link: TanstackLink,
153
+ // `useRouter({ warn: false })` yields nothing before the instance exists.
154
+ useRouter: () => null,
155
+ },
156
+ } as Record<string, unknown>;
157
+ }
158
+
159
+ async function renderBareRuntime(
160
+ App: ComponentType<any>,
161
+ runtimeContext: Record<string, unknown>,
162
+ i18nInstance: I18nInstance,
163
+ ) {
164
+ const { wrapRoot, beforeRender } = setupBareI18nRuntime(i18nInstance);
165
+ // The runtime resolves the active instance in `onBeforeRender`; skipping it
166
+ // leaves the provider without the language-synchronisation seam, which is
167
+ // exactly the wiring these cases are about.
168
+ await beforeRender(runtimeContext);
169
+ const Wrapped = wrapRoot(App);
170
+ const container = document.createElement('div');
171
+ document.body.appendChild(container);
172
+ const root = createRoot(container);
173
+ await act(async () => {
174
+ root.render(
175
+ <InternalRuntimeContext.Provider value={runtimeContext as any}>
176
+ <RuntimeContext.Provider value={runtimeContext as any}>
177
+ <Wrapped />
178
+ </RuntimeContext.Provider>
179
+ </InternalRuntimeContext.Provider>,
180
+ );
181
+ });
182
+ return { container, root };
183
+ }
184
+
185
+ function cleanup(rendered?: { container: HTMLElement; root: Root }) {
186
+ if (!rendered) {
187
+ return;
188
+ }
189
+ act(() => {
190
+ rendered.root.unmount();
191
+ });
192
+ rendered.container.remove();
193
+ }
194
+
195
+ describe('bare plugin-i18n navigation adapter', () => {
196
+ let rendered: { container: HTMLElement; root: Root } | undefined;
197
+
198
+ afterEach(() => {
199
+ cleanup(rendered);
200
+ rendered = undefined;
201
+ rstest.restoreAllMocks();
202
+ });
203
+
204
+ test('renders mapped locale URLs through the selected router Link', async () => {
205
+ const router = createMutableTanstackRouter('/cs/obchodni-podminky');
206
+ const App = () => (
207
+ <Link to="/terms-of-service" data-testid="nav-terms">
208
+ terms
209
+ </Link>
210
+ );
211
+
212
+ rendered = await renderBareRuntime(
213
+ App,
214
+ createTanstackRuntimeContext(router),
215
+ createI18nInstance('cs'),
216
+ );
217
+
218
+ const anchor = rendered.container.querySelector(
219
+ '[data-testid="nav-terms"]',
220
+ );
221
+ // Without the adapter this is a plain `<a>`: no router link, no active state.
222
+ expect(anchor?.getAttribute('data-router-link')).toBe('tanstack');
223
+ expect(anchor?.getAttribute('href')).toBe('/cs/obchodni-podminky');
224
+ });
225
+
226
+ test('keeps active state language-invariant across mapped spellings', async () => {
227
+ const App = () => (
228
+ <Link to="/terms-of-service" data-testid="nav-terms">
229
+ terms
230
+ </Link>
231
+ );
232
+
233
+ for (const [language, pathname] of [
234
+ ['en', '/en/terms-of-service'],
235
+ ['cs', '/cs/obchodni-podminky'],
236
+ ] as const) {
237
+ const attempt = await renderBareRuntime(
238
+ App,
239
+ createTanstackRuntimeContext(createMutableTanstackRouter(pathname)),
240
+ createI18nInstance(language),
241
+ );
242
+ try {
243
+ const anchor = attempt.container.querySelector(
244
+ '[data-testid="nav-terms"]',
245
+ );
246
+ expect(anchor?.getAttribute('data-status')).toBe('active');
247
+ expect(anchor?.getAttribute('aria-current')).toBe('page');
248
+ } finally {
249
+ cleanup(attempt);
250
+ }
251
+ }
252
+ });
253
+
254
+ test('reports no router until the TanStack instance is installed', async () => {
255
+ const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
256
+ const App = () => {
257
+ const adapter = useIntegratedRouterAdapter();
258
+ return <span data-testid="has-router">{String(adapter.hasRouter)}</span>;
259
+ };
260
+
261
+ rendered = await renderBareRuntime(
262
+ App,
263
+ runtimeContext,
264
+ createI18nInstance('en'),
265
+ );
266
+
267
+ // The hook API is published but the instance is not, so claiming a router
268
+ // here would hand `changeLanguage()` a `navigate` that only throws.
269
+ expect(
270
+ rendered.container.querySelector('[data-testid="has-router"]')
271
+ ?.textContent,
272
+ ).toBe('false');
273
+
274
+ await act(async () => {
275
+ applyRouterRuntimeState(runtimeContext, {
276
+ framework: 'tanstack',
277
+ navigation: createMutableTanstackRouter('/en/terms-of-service')
278
+ .navigation,
279
+ } as any);
280
+ });
281
+
282
+ // Installing the instance has to reach a provider that already rendered.
283
+ expect(
284
+ rendered.container.querySelector('[data-testid="has-router"]')
285
+ ?.textContent,
286
+ ).toBe('true');
287
+ });
288
+
289
+ test('reports no router for a framework the adapter cannot drive', async () => {
290
+ // A custom provider may publish an instance under a name `navigate` has
291
+ // no implementation for. Claiming a router then makes `changeLanguage()`
292
+ // await a navigate that goes nowhere instead of the full-page fallback.
293
+ const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
294
+ const App = () => {
295
+ const adapter = useIntegratedRouterAdapter();
296
+ return <span data-testid="has-router">{String(adapter.hasRouter)}</span>;
297
+ };
298
+
299
+ rendered = await renderBareRuntime(
300
+ App,
301
+ runtimeContext,
302
+ createI18nInstance('en'),
303
+ );
304
+ await act(async () => {
305
+ applyRouterRuntimeState(runtimeContext, {
306
+ framework: 'custom-router',
307
+ instance: { navigate: () => undefined, state: { location: {} } },
308
+ } as any);
309
+ });
310
+
311
+ expect(
312
+ rendered.container.querySelector('[data-testid="has-router"]')
313
+ ?.textContent,
314
+ ).toBe('false');
315
+ });
316
+
317
+ test('leaves links unrouted until the TanStack instance is installed', async () => {
318
+ const runtimeContext = createTanstackCsrRuntimeContextWithoutRouter();
319
+ const App = () => (
320
+ <Link to="/terms-of-service" data-testid="nav-terms">
321
+ terms
322
+ </Link>
323
+ );
324
+
325
+ rendered = await renderBareRuntime(
326
+ App,
327
+ runtimeContext,
328
+ createI18nInstance('en'),
329
+ );
330
+ const anchor = () =>
331
+ rendered?.container.querySelector('[data-testid="nav-terms"]');
332
+ expect(anchor()?.getAttribute('data-router-link')).toBeNull();
333
+
334
+ await act(async () => {
335
+ applyRouterRuntimeState(runtimeContext, {
336
+ framework: 'tanstack',
337
+ navigation: createMutableTanstackRouter('/cs/obchodni-podminky')
338
+ .navigation,
339
+ } as any);
340
+ });
341
+
342
+ expect(anchor()?.getAttribute('data-router-link')).toBe('tanstack');
343
+ });
344
+
345
+ test('replaces providers and stops observing the previous router', async () => {
346
+ const first = createMutableTanstackRouter('/en/products/shoe');
347
+ const second = createMutableTanstackRouter('/cs/produkty/bota');
348
+ const runtimeContext = createTanstackRuntimeContext(first);
349
+ const App = () => {
350
+ const adapter = useIntegratedRouterAdapter();
351
+ return <span data-testid="path">{adapter.location?.pathname}</span>;
352
+ };
353
+ rendered = await renderBareRuntime(
354
+ App,
355
+ runtimeContext,
356
+ createI18nInstance('en'),
357
+ );
358
+ await act(async () => {
359
+ applyRouterRuntimeState(runtimeContext, {
360
+ framework: 'custom-provider',
361
+ navigation: second.navigation,
362
+ });
363
+ });
364
+ expect(
365
+ rendered.container.querySelector('[data-testid="path"]')?.textContent,
366
+ ).toBe('/cs/produkty/bota');
367
+ await act(async () => first.publishPathname('/en/stale'));
368
+ expect(
369
+ rendered.container.querySelector('[data-testid="path"]')?.textContent,
370
+ ).toBe('/cs/produkty/bota');
371
+ await act(async () => second.publishPathname('/en/current'));
372
+ expect(
373
+ rendered.container.querySelector('[data-testid="path"]')?.textContent,
374
+ ).toBe('/en/current');
375
+ });
376
+
377
+ test('follows the router location so the language tracks a client navigation', async () => {
378
+ const router = createMutableTanstackRouter('/en/products/shoe');
379
+ const App = () => {
380
+ const { language } = useModernI18n();
381
+ return <span data-testid="language">{language}</span>;
382
+ };
383
+ const i18nInstance = createI18nInstance('en');
384
+
385
+ rendered = await renderBareRuntime(
386
+ App,
387
+ createTanstackRuntimeContext(router),
388
+ i18nInstance,
389
+ );
390
+ expect(
391
+ rendered.container.querySelector('[data-testid="language"]')?.textContent,
392
+ ).toBe('en');
393
+
394
+ await act(async () => {
395
+ router.publishPathname('/cs/produkty/bota');
396
+ });
397
+
398
+ expect(
399
+ rendered.container.querySelector('[data-testid="language"]')?.textContent,
400
+ ).toBe('cs');
401
+ expect(i18nInstance.setLang).toHaveBeenCalledWith('cs');
402
+ });
403
+ });
@@ -1,13 +1,12 @@
1
1
  import { mkdtemp, rm } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
- import { basename, resolve } from 'node:path';
3
+ import { resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { describe, expect, test } from '@rstest/core';
6
6
  import { build } from 'esbuild';
7
7
  import i18next from 'i18next';
8
8
  import { createElement } from 'react';
9
9
  import { renderToStaticMarkup } from 'react-dom/server';
10
- import { i18nPlugin as noReactI18nextPlugin } from '../src/runtime/no-react-i18next';
11
10
 
12
11
  const runtimeRoot = resolve(__dirname, '../src/runtime');
13
12
 
@@ -37,31 +36,6 @@ describe('react-i18next runtime boundary', () => {
37
36
  ).resolves.toBeDefined();
38
37
  });
39
38
 
40
- test('bundles the default runtime entry with the react-i18next adapter', async () => {
41
- let adapterResolutions = 0;
42
- await build({
43
- bundle: true,
44
- entryPoints: [resolve(runtimeRoot, 'index.tsx')],
45
- format: 'esm',
46
- packages: 'external',
47
- platform: 'node',
48
- plugins: [
49
- {
50
- name: 'observe-react-i18next',
51
- setup(buildApi) {
52
- buildApi.onResolve({ filter: /^react-i18next$/ }, args => {
53
- adapterResolutions += 1;
54
- return { external: true, path: args.path };
55
- });
56
- },
57
- },
58
- ],
59
- write: false,
60
- });
61
-
62
- expect(adapterResolutions).toBeGreaterThan(0);
63
- });
64
-
65
39
  test('gets router capabilities from the selected runtime provider', async () => {
66
40
  await expect(
67
41
  build({
@@ -90,62 +64,7 @@ describe('react-i18next runtime boundary', () => {
90
64
  ).resolves.toBeDefined();
91
65
  });
92
66
 
93
- test('keeps expensive lifecycle helpers behind asynchronous bundle edges', async () => {
94
- const result = await build({
95
- bundle: true,
96
- entryPoints: [resolve(runtimeRoot, 'pluginSetup.ts')],
97
- format: 'esm',
98
- metafile: true,
99
- outdir: 'out',
100
- packages: 'external',
101
- platform: 'node',
102
- splitting: true,
103
- write: false,
104
- });
105
-
106
- const entryOutput = Object.values(result.metafile.outputs).find(output =>
107
- output.entryPoint?.endsWith('/pluginSetup.ts'),
108
- );
109
- if (!entryOutput) {
110
- throw new Error('pluginSetup entry output was not generated');
111
- }
112
- const dynamicOutputNames = entryOutput.imports
113
- .filter(moduleImport => moduleImport.kind === 'dynamic-import')
114
- .map(moduleImport => basename(moduleImport.path));
115
- const dynamicOutputs = Object.entries(result.metafile.outputs)
116
- .filter(([outputPath]) =>
117
- dynamicOutputNames.includes(basename(outputPath)),
118
- )
119
- .map(([, output]) => output);
120
- const dynamicEntries = dynamicOutputs.map(output =>
121
- basename(output.entryPoint ?? ''),
122
- );
123
-
124
- expect(dynamicEntries).toEqual(
125
- expect.arrayContaining(['middleware.ts', 'utils.ts']),
126
- );
127
- });
128
-
129
- test('registers plugin lifecycle hooks synchronously', () => {
130
- let beforeRender: unknown;
131
- let rootWrapper: unknown;
132
- const plugin = noReactI18nextPlugin({ reactI18next: false });
133
- const setupResult = plugin.setup?.({
134
- getRuntimeConfig: () => ({}),
135
- onBeforeRender: hook => {
136
- beforeRender = hook;
137
- },
138
- wrapRoot: wrapper => {
139
- rootWrapper = wrapper;
140
- },
141
- } as any);
142
-
143
- expect(setupResult).toBeUndefined();
144
- expect(typeof beforeRender).toBe('function');
145
- expect(typeof rootWrapper).toBe('function');
146
- });
147
-
148
- test('shares Modern i18n context across independently bundled runtime copies', async () => {
67
+ test('independently bundled runtime copies share the contexts subpath', async () => {
149
68
  const tempDir = await mkdtemp(
150
69
  resolve(__dirname, '.modern-i18n-runtime-boundary-'),
151
70
  );
@@ -162,6 +81,9 @@ describe('react-i18next runtime boundary', () => {
162
81
  outfile,
163
82
  packages: 'external',
164
83
  platform: 'node',
84
+ // Resolve the contexts self-reference like an installed package,
85
+ // not through this package's tsconfig `paths`.
86
+ tsconfigRaw: {},
165
87
  });
166
88
  return import(pathToFileURL(outfile).href);
167
89
  }),
@@ -191,6 +113,9 @@ describe('react-i18next runtime boundary', () => {
191
113
  );
192
114
 
193
115
  expect(html).toBe('<span>cs</span>');
116
+ expect(
117
+ Object.getOwnPropertySymbols(globalThis).map(String),
118
+ ).not.toContainEqual(expect.stringContaining('@modern-js/plugin-i18n'));
194
119
  } finally {
195
120
  await rm(tempDir, { force: true, recursive: true });
196
121
  }
@@ -5,41 +5,103 @@ import {
5
5
  shouldIgnoreRedirect as shouldIgnoreServerRedirect,
6
6
  } from '../src/server/redirectPolicy';
7
7
 
8
- const languages = ['en', 'cs'];
9
-
10
- const defaultSkippedPaths = [
11
- '/backend-mf-manifest.json',
12
- '/backendRemoteEntry.cjs',
13
- '/mf-manifest.json',
14
- '/mf-stats.json',
15
- '/remoteEntry.js',
16
- '/static/app.js',
17
- '/upload/avatar.png',
18
- ];
19
-
20
- describe('locale redirect default skip policy', () => {
21
- test('skips ADR-0002 Module Federation and static endpoints on the server', () => {
22
- for (const pathname of defaultSkippedPaths) {
23
- expect(isStaticResourceRequest(pathname, [], languages)).toBe(true);
24
- expect(shouldIgnoreServerRedirect(pathname, '/', undefined)).toBe(true);
8
+ describe('native locale redirect safeguards', () => {
9
+ test('never locale-redirects federation artifacts, with no URL strategy configured', () => {
10
+ // 3.9.0-ultramodern.9 regression: with `localisedUrls: {}` no strategy is
11
+ // derived, and the backend federation manifest was redirected to
12
+ // `/en/backend-mf-manifest.json` (an HTML page) — the Node backend
13
+ // federation proof then read a 404 instead of the manifest.
14
+ const languages = ['en', 'cs'];
15
+ for (const pathname of [
16
+ '/backend-mf-manifest.json',
17
+ '/backendRemoteEntry.cjs',
18
+ '/mf-manifest.json',
19
+ '/mf-stats.json',
20
+ '/remoteEntry.js',
21
+ '/remoteEntry.catalog.js',
22
+ '/backendRemoteEntry.catalog.cjs',
23
+ '/cs/backend-mf-manifest.json',
24
+ ]) {
25
+ expect(
26
+ shouldIgnoreServerRedirect(
27
+ pathname,
28
+ '/*',
29
+ undefined,
30
+ undefined,
31
+ languages,
32
+ ),
33
+ ).toBe(true);
34
+ expect(
35
+ shouldIgnoreServerRedirect(
36
+ `/app${pathname}`,
37
+ '/app/*',
38
+ undefined,
39
+ undefined,
40
+ languages,
41
+ ),
42
+ ).toBe(true);
43
+ expect(
44
+ shouldIgnoreRuntimeRedirect(pathname, languages, undefined, undefined),
45
+ ).toBe(true);
25
46
  }
47
+ // A page is still redirected.
48
+ expect(
49
+ shouldIgnoreServerRedirect(
50
+ '/products',
51
+ '/*',
52
+ undefined,
53
+ undefined,
54
+ languages,
55
+ ),
56
+ ).toBe(false);
57
+ expect(
58
+ shouldIgnoreRuntimeRedirect('/products', languages, undefined, undefined),
59
+ ).toBe(false);
26
60
  });
27
61
 
28
- test('skips language-prefixed static and upload endpoints', () => {
29
- expect(isStaticResourceRequest('/cs/static/app.js', [], languages)).toBe(
30
- true,
31
- );
62
+ test('retains configured ignores across runtime and server entry prefixes', () => {
63
+ const rules = ['/private'];
64
+ expect(
65
+ shouldIgnoreRuntimeRedirect('/cs/private/data', ['en', 'cs'], rules),
66
+ ).toBe(true);
67
+ expect(
68
+ shouldIgnoreRuntimeRedirect('/cs/private-data', ['en', 'cs'], rules),
69
+ ).toBe(false);
32
70
  expect(
33
- isStaticResourceRequest('/en/upload/avatar.png', [], languages),
71
+ shouldIgnoreServerRedirect(
72
+ '/app/cs/private/data',
73
+ '/app/*',
74
+ rules,
75
+ undefined,
76
+ ['en', 'cs'],
77
+ ),
34
78
  ).toBe(true);
79
+ expect(
80
+ shouldIgnoreServerRedirect(
81
+ '/app/cs/private-data',
82
+ '/app/*',
83
+ rules,
84
+ undefined,
85
+ ['en', 'cs'],
86
+ ),
87
+ ).toBe(false);
35
88
  });
36
-
37
- test('uses the same default skip policy in runtime redirects', () => {
38
- for (const pathname of defaultSkippedPaths) {
39
- expect(shouldIgnoreRuntimeRedirect(pathname, languages)).toBe(true);
40
- expect(shouldIgnoreRuntimeRedirect(`/cs${pathname}`, languages)).toBe(
89
+ test('retains configured and native static paths without fork policy', () => {
90
+ for (const pathname of [
91
+ '/static/app.js',
92
+ '/upload/a.png',
93
+ '/cs/assets/main.js',
94
+ ]) {
95
+ expect(isStaticResourceRequest(pathname, ['/assets'], ['en', 'cs'])).toBe(
41
96
  true,
42
97
  );
43
98
  }
99
+ expect(
100
+ isStaticResourceRequest(
101
+ '/assets-other/main.js',
102
+ ['/assets'],
103
+ ['en', 'cs'],
104
+ ),
105
+ ).toBe(false);
44
106
  });
45
107
  });