@bleedingdev/modern-js-i18n-runtime-extensions 3.9.0-ultramodern.1 → 3.9.0-ultramodern.11

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 (51) hide show
  1. package/dist/cjs/index.js +9 -2
  2. package/dist/cjs/localeRedirectPolicy.js +96 -0
  3. package/dist/cjs/localisedUrls/index.js +7 -0
  4. package/dist/cjs/localisedUrls/redirect.js +3 -6
  5. package/dist/cjs/localisedUrls/routeIdentity.js +118 -0
  6. package/dist/cjs/localisedUrls/routes.js +8 -0
  7. package/dist/cjs/paths.js +56 -0
  8. package/dist/cjs/router-navigation/adapter.js +235 -0
  9. package/dist/cjs/router-navigation/index.js +40 -0
  10. package/dist/esm/index.mjs +1 -0
  11. package/dist/esm/localeRedirectPolicy.mjs +46 -0
  12. package/dist/esm/localisedUrls/index.mjs +1 -0
  13. package/dist/esm/localisedUrls/redirect.mjs +3 -6
  14. package/dist/esm/localisedUrls/routeIdentity.mjs +77 -0
  15. package/dist/esm/localisedUrls/routes.mjs +8 -0
  16. package/dist/esm/paths.mjs +12 -0
  17. package/dist/esm/router-navigation/adapter.mjs +197 -0
  18. package/dist/esm/router-navigation/index.mjs +1 -0
  19. package/dist/esm-node/index.mjs +1 -0
  20. package/dist/esm-node/localeRedirectPolicy.mjs +47 -0
  21. package/dist/esm-node/localisedUrls/index.mjs +1 -0
  22. package/dist/esm-node/localisedUrls/redirect.mjs +3 -6
  23. package/dist/esm-node/localisedUrls/routeIdentity.mjs +78 -0
  24. package/dist/esm-node/localisedUrls/routes.mjs +8 -0
  25. package/dist/esm-node/paths.mjs +13 -0
  26. package/dist/esm-node/router-navigation/adapter.mjs +198 -0
  27. package/dist/esm-node/router-navigation/index.mjs +2 -0
  28. package/dist/types/index.d.ts +1 -0
  29. package/dist/types/localeRedirectPolicy.d.ts +22 -0
  30. package/dist/types/localisedUrls/index.d.ts +1 -0
  31. package/dist/types/localisedUrls/redirect.d.ts +8 -0
  32. package/dist/types/localisedUrls/routeIdentity.d.ts +24 -0
  33. package/dist/types/paths.d.ts +9 -0
  34. package/dist/types/router-navigation/adapter.d.ts +71 -0
  35. package/dist/types/router-navigation/index.d.ts +1 -0
  36. package/package.json +29 -5
  37. package/src/index.ts +1 -0
  38. package/src/localeRedirectPolicy.ts +127 -0
  39. package/src/localisedUrls/index.ts +4 -0
  40. package/src/localisedUrls/redirect.ts +16 -2
  41. package/src/localisedUrls/routeIdentity.ts +107 -0
  42. package/src/localisedUrls/routes.ts +10 -0
  43. package/src/paths.ts +37 -0
  44. package/src/router-navigation/adapter.tsx +536 -0
  45. package/src/router-navigation/index.ts +6 -0
  46. package/tests/languageSyncController.test.ts +0 -77
  47. package/tests/localeRedirectPolicy.test.ts +173 -0
  48. package/tests/localisedUrls.test.ts +0 -141
  49. package/tests/paths.test.ts +24 -0
  50. package/tests/reactLanguageSync.test.tsx +20 -1
  51. package/tests/clientRedirectIntegration.test.tsx +0 -74
@@ -80,7 +80,6 @@ describe('latest language synchronization coordinator', () => {
80
80
 
81
81
  binding.request('cs');
82
82
  binding.request('de');
83
- expect(pending.map(change => change.language)).toEqual(['cs', 'de']);
84
83
 
85
84
  pending[1].resolve();
86
85
  await pending[1].promise;
@@ -91,7 +90,6 @@ describe('latest language synchronization coordinator', () => {
91
90
  await pending[0].promise;
92
91
  await flush();
93
92
  expect(target.language).toBe('cs');
94
- expect(pending.map(change => change.language)).toEqual(['cs', 'de', 'de']);
95
93
 
96
94
  pending[2].resolve();
97
95
  await pending[2].promise;
@@ -100,81 +98,6 @@ describe('latest language synchronization coordinator', () => {
100
98
  expect(commits.at(-1)).toBe('de');
101
99
  });
102
100
 
103
- test('repairs a shared singleton after the starting mount leaves', async () => {
104
- const target = { language: 'en' };
105
- const { changeLanguage, pending } = deferredChanger(target);
106
- const commits: string[] = [];
107
- const first = createLatestLanguageSyncBinding<Target>();
108
- const second = createLatestLanguageSyncBinding<Target>();
109
- for (const binding of [first, second]) {
110
- binding.updateCallbacks({
111
- changeLanguage,
112
- commitLanguage: (_target, language) => commits.push(language),
113
- readLanguage: current => current.language,
114
- });
115
- }
116
-
117
- first.activate(target);
118
- first.request('cs');
119
- first.deactivate();
120
- second.activate(target);
121
- second.request('de');
122
-
123
- pending[1].resolve();
124
- await pending[1].promise;
125
- await flush();
126
- pending[0].resolve();
127
- await pending[0].promise;
128
- await flush();
129
-
130
- expect(pending.map(change => change.language)).toEqual(['cs', 'de', 'de']);
131
- pending[2].resolve();
132
- await pending[2].promise;
133
- await flush();
134
- expect(target.language).toBe('de');
135
- expect(commits.at(-1)).toBe('de');
136
- });
137
-
138
- test('supersedes a hung same-language attempt and repairs it after a later route', async () => {
139
- rstest.useFakeTimers();
140
- const target = { language: 'en' };
141
- const { changeLanguage, pending } = deferredChanger(target);
142
- const commits: string[] = [];
143
- const binding = createLatestLanguageSyncBinding<Target>({
144
- attemptTimeoutMs: 100,
145
- retryDelayMs: () => 10,
146
- });
147
- binding.updateCallbacks({
148
- changeLanguage,
149
- commitLanguage: (_target, language) => commits.push(language),
150
- readLanguage: current => current.language,
151
- });
152
- binding.activate(target);
153
- binding.request('cs');
154
-
155
- await rstest.advanceTimersByTimeAsync(110);
156
- expect(pending.map(change => change.language)).toEqual(['cs', 'cs']);
157
- pending[1].resolve();
158
- await pending[1].promise;
159
- await flush();
160
- expect(commits.at(-1)).toBe('cs');
161
-
162
- binding.request('de');
163
- pending[2].resolve();
164
- await pending[2].promise;
165
- await flush();
166
- expect(target.language).toBe('de');
167
-
168
- pending[0].resolve();
169
- await pending[0].promise;
170
- await flush();
171
- expect(pending.at(-1)?.language).toBe('de');
172
- pending.at(-1)?.resolve();
173
- await pending.at(-1)?.promise;
174
- await flush();
175
- expect(target.language).toBe('de');
176
- });
177
-
178
101
  test('bounds permanent failures and reports the final consequence once', async () => {
179
102
  rstest.useFakeTimers();
180
103
  const target = { language: 'en' };
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import {
3
+ buildLocalizedUrl,
4
+ createLocaleRedirectResponse,
5
+ getLanguageFromPath,
6
+ isStaticResourceRequest,
7
+ shouldIgnoreRedirect,
8
+ } from '../src/localeRedirectPolicy';
9
+
10
+ const languages = ['en', 'cs'];
11
+ const request = (url: string) => ({
12
+ url,
13
+ header: () => ({ host: 'example.com' }),
14
+ });
15
+
16
+ describe('locale redirect resource policy', () => {
17
+ test('exempts default static and federation routes, including locale prefixes', () => {
18
+ const paths = [
19
+ '/backend-mf-manifest.json',
20
+ '/backendRemoteEntry.cjs',
21
+ '/mf-manifest.json',
22
+ '/mf-stats.json',
23
+ '/remoteEntry.js',
24
+ // A manifest may name a root entry; the federation layer serves these.
25
+ '/remoteEntry.catalog.js',
26
+ '/backendRemoteEntry.catalog-v2.cjs',
27
+ '/static/app.js',
28
+ '/upload/avatar.png',
29
+ ];
30
+
31
+ for (const pathname of paths) {
32
+ expect(isStaticResourceRequest(pathname, [], languages)).toBe(true);
33
+ expect(isStaticResourceRequest(`/cs${pathname}`, [], languages)).toBe(
34
+ true,
35
+ );
36
+ expect(shouldIgnoreRedirect(`/store${pathname}`, '/store/*')).toBe(true);
37
+ }
38
+ // Only the entry itself, not a page or asset that merely starts like one.
39
+ for (const pathname of [
40
+ '/remoteEntry.catalog.js.map',
41
+ '/remoteEntry.catalog/js',
42
+ '/products/remoteEntry.js',
43
+ ]) {
44
+ expect(isStaticResourceRequest(pathname, [], languages)).toBe(false);
45
+ }
46
+ });
47
+
48
+ test('configured static prefixes match complete path segments with or without a locale', () => {
49
+ for (const pathname of ['/assets', '/assets/app.js', '/en/assets/app.js']) {
50
+ expect(isStaticResourceRequest(pathname, ['/assets/'], languages)).toBe(
51
+ true,
52
+ );
53
+ }
54
+ for (const pathname of [
55
+ '/assets-extra/app.js',
56
+ '/cs/assets-extra/app.js',
57
+ ]) {
58
+ expect(isStaticResourceRequest(pathname, ['/assets'], languages)).toBe(
59
+ false,
60
+ );
61
+ }
62
+ expect(
63
+ isStaticResourceRequest('/fr/assets/app.js', ['/assets'], languages),
64
+ ).toBe(false);
65
+ expect(
66
+ isStaticResourceRequest('/remoteEntry.js/product', [], languages),
67
+ ).toBe(false);
68
+ expect(isStaticResourceRequest('/static-product', [])).toBe(false);
69
+ });
70
+
71
+ test('custom ignore lists operate after stripping the mount path', () => {
72
+ expect(
73
+ shouldIgnoreRedirect('/store/health/live', '/store/*', ['/health']),
74
+ ).toBe(true);
75
+ expect(
76
+ shouldIgnoreRedirect('/store/healthy', '/store/*', ['/health']),
77
+ ).toBe(false);
78
+ expect(shouldIgnoreRedirect('/store/products', '/store/*')).toBe(false);
79
+ });
80
+
81
+ test('custom callbacks receive the remaining pathname and default exemptions take priority', () => {
82
+ const seen: string[] = [];
83
+ const ignore = (pathname: string) => {
84
+ seen.push(pathname);
85
+ return pathname === '/health';
86
+ };
87
+
88
+ expect(shouldIgnoreRedirect('/store/health', '/store/*', ignore)).toBe(
89
+ true,
90
+ );
91
+ expect(shouldIgnoreRedirect('/store/products', '/store/*', ignore)).toBe(
92
+ false,
93
+ );
94
+ expect(
95
+ shouldIgnoreRedirect('/store/static/app.js', '/store/*', ignore),
96
+ ).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('locale redirect URL policy', () => {
101
+ test('detects a configured first language segment for relative or absolute URLs', () => {
102
+ expect(
103
+ getLanguageFromPath(
104
+ request('/store/cs/products?x=1'),
105
+ '/store/*',
106
+ languages,
107
+ ),
108
+ ).toBe('cs');
109
+ expect(
110
+ getLanguageFromPath(
111
+ request('https://example.com/en/products'),
112
+ '/',
113
+ languages,
114
+ ),
115
+ ).toBe('en');
116
+ expect(getLanguageFromPath(request('/cs/products'), '', languages)).toBe(
117
+ 'cs',
118
+ );
119
+ expect(
120
+ getLanguageFromPath(request('/storecs/products'), '/store/*', languages),
121
+ ).toBe('cs');
122
+ });
123
+
124
+ test('switches localized dynamic paths while preserving mount, query and hash', () => {
125
+ expect(
126
+ buildLocalizedUrl(
127
+ request(
128
+ 'https://example.com/store/en/products/red%20tractor?q=a%2Fb&sort=price#details',
129
+ ),
130
+ '/store/*',
131
+ 'cs',
132
+ languages,
133
+ { '/products/:slug': { en: '/products/:slug', cs: '/produkty/:slug' } },
134
+ ),
135
+ ).toBe('/store/cs/produkty/red%20tractor?q=a%2Fb&sort=price#details');
136
+ });
137
+
138
+ test('supports root and empty mounts and missing locale mapping', () => {
139
+ for (const mount of ['/', '', '/*']) {
140
+ expect(
141
+ buildLocalizedUrl(
142
+ request('https://example.com/products?x=1#top'),
143
+ mount,
144
+ 'cs',
145
+ languages,
146
+ ),
147
+ ).toBe('/cs/products?x=1#top');
148
+ }
149
+ expect(
150
+ buildLocalizedUrl(
151
+ request('https://example.com/store'),
152
+ '/store/*',
153
+ 'cs',
154
+ languages,
155
+ false,
156
+ ),
157
+ ).toBe('/store/cs');
158
+ });
159
+
160
+ test('returns a non-cacheable temporary redirect with language negotiation headers', async () => {
161
+ const response = createLocaleRedirectResponse(
162
+ '/cs/produkty?sort=price#details',
163
+ );
164
+
165
+ expect(response.status).toBe(302);
166
+ expect(response.headers.get('Location')).toBe(
167
+ '/cs/produkty?sort=price#details',
168
+ );
169
+ expect(response.headers.get('Cache-Control')).toBe('private, no-store');
170
+ expect(response.headers.get('Vary')).toBe('Accept-Language, Cookie');
171
+ expect(await response.text()).toBe('');
172
+ });
173
+ });
@@ -7,7 +7,6 @@ import {
7
7
  matchPathPattern,
8
8
  resolveCanonicalLocalisedPath,
9
9
  resolveLocalisedPath,
10
- resolveLocalisedUrlsConfig,
11
10
  validateLocalisedUrls,
12
11
  } from '../src/localisedUrls/index';
13
12
 
@@ -24,21 +23,6 @@ const createRoute = (
24
23
  children,
25
24
  });
26
25
 
27
- describe('resolveLocalisedUrlsConfig', () => {
28
- test('is opt-in: only a non-empty map enables the feature', () => {
29
- const map = { '/about': { en: '/about', cs: '/o-nas' } };
30
- expect(resolveLocalisedUrlsConfig(map)).toEqual({ enabled: true, map });
31
- });
32
-
33
- test('absent, boolean and empty-map options resolve to disabled', () => {
34
- const disabled = { enabled: false, map: {} };
35
- expect(resolveLocalisedUrlsConfig(undefined)).toEqual(disabled);
36
- expect(resolveLocalisedUrlsConfig(false)).toEqual(disabled);
37
- expect(resolveLocalisedUrlsConfig(true)).toEqual(disabled);
38
- expect(resolveLocalisedUrlsConfig({})).toEqual(disabled);
39
- });
40
- });
41
-
42
26
  describe('localisedUrls', () => {
43
27
  test('leaves framework-internal Module Federation routes unlocalised', () => {
44
28
  const internalRoute = createRoute('_mf', [
@@ -207,9 +191,6 @@ describe('localisedUrls', () => {
207
191
  localisedUrls,
208
192
  );
209
193
 
210
- expect(
211
- applyLocalisedUrlsToRoutes(routes, ['en', 'cs', 'de'], localisedUrls),
212
- ).toEqual(localisedRoutes);
213
194
  expect(localisedRoutes.map(route => route.path)).toEqual([
214
195
  ':lang/about',
215
196
  ':lang/o.nas',
@@ -304,62 +285,6 @@ describe('localisedUrls', () => {
304
285
  ]);
305
286
  });
306
287
 
307
- test('expands flat locale-prefixed route paths with canonical keys', () => {
308
- const routes = [
309
- createRoute('/:lang/about'),
310
- createRoute('/:lang/products/:slug'),
311
- ];
312
-
313
- const localisedRoutes = applyLocalisedUrlsToRoutes(routes, ['en', 'cs'], {
314
- '/about': {
315
- en: '/about',
316
- cs: '/o-nas',
317
- },
318
- '/products/:slug': {
319
- en: '/products/:slug',
320
- cs: '/produkty/:slug',
321
- },
322
- });
323
-
324
- expect(localisedRoutes.map(route => route.path)).toEqual([
325
- ':lang/about',
326
- ':lang/o-nas',
327
- ':lang/products/:slug',
328
- ':lang/produkty/:slug',
329
- ]);
330
- });
331
-
332
- test('resolves current localized path to the target language', () => {
333
- const localisedUrls = {
334
- '/terms-of-service': {
335
- en: '/terms-of-service',
336
- cs: '/podminky-pouzivani',
337
- },
338
- '/products/:slug': {
339
- en: '/products/:slug',
340
- cs: '/produkty/:slug',
341
- },
342
- };
343
-
344
- expect(
345
- resolveLocalisedPath(
346
- '/terms-of-service',
347
- 'cs',
348
- ['en', 'cs'],
349
- localisedUrls,
350
- ),
351
- ).toBe('/podminky-pouzivani');
352
- expect(
353
- resolveLocalisedPath('/produkty/cervena-bota', 'en', ['en', 'cs'], {
354
- ...localisedUrls,
355
- '/products/:slug': {
356
- en: '/products/:slug',
357
- cs: '/produkty/:slug',
358
- },
359
- }),
360
- ).toBe('/products/cervena-bota');
361
- });
362
-
363
288
  test('resolves optional route params', () => {
364
289
  const localisedUrls = {
365
290
  '/products/:slug?': {
@@ -455,30 +380,6 @@ describe('localisedUrls', () => {
455
380
  ).toBe('/products/new');
456
381
  });
457
382
 
458
- test('reverse resolution is deterministic for equally specific matches', () => {
459
- const firstOrder = {
460
- '/z/:slug': {
461
- en: '/x/:slug',
462
- cs: '/z/:slug',
463
- },
464
- '/a/:section': {
465
- en: '/:section/y',
466
- cs: '/a/:section',
467
- },
468
- };
469
- const reverseOrder = {
470
- '/a/:section': firstOrder['/a/:section'],
471
- '/z/:slug': firstOrder['/z/:slug'],
472
- };
473
-
474
- expect(
475
- resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], firstOrder),
476
- ).toBe('/a/x');
477
- expect(
478
- resolveCanonicalLocalisedPath('/x/y', ['en', 'cs'], reverseOrder),
479
- ).toBe('/a/x');
480
- });
481
-
482
383
  test('reverse resolution prefers the most specific localised source pattern', () => {
483
384
  const localisedUrls = {
484
385
  '/catalog/:section/:item/:detail': {
@@ -500,23 +401,6 @@ describe('localisedUrls', () => {
500
401
  ).toBe('/shoes/red');
501
402
  });
502
403
 
503
- test('a specific localised source outranks a broad canonical source', () => {
504
- const localisedUrls = {
505
- '/:section/:slug': {
506
- en: '/generic/:section/:slug',
507
- cs: '/obecne/:section/:slug',
508
- },
509
- '/products/:id': {
510
- en: '/fixed/:id',
511
- cs: '/produkty/:id',
512
- },
513
- };
514
-
515
- expect(
516
- resolveCanonicalLocalisedPath('/fixed/red', ['en', 'cs'], localisedUrls),
517
- ).toBe('/products/red');
518
- });
519
-
520
404
  test('missing required target params never collapse into empty segments', () => {
521
405
  expect(() =>
522
406
  resolveLocalisedPath('/products/red-shoe', 'cs', ['en', 'cs'], {
@@ -537,31 +421,6 @@ describe('localisedUrls', () => {
537
421
  ).toThrow('Missing required path parameter "toString"');
538
422
  });
539
423
 
540
- test('localises and canonicalises full target pathnames through one helper', () => {
541
- const localisedUrls = {
542
- '/products/:slug': {
543
- en: '/products/:slug',
544
- cs: '/produkty/:slug',
545
- },
546
- };
547
-
548
- expect(
549
- localiseTargetPathname(
550
- '/en/products/cervena-bota',
551
- 'cs',
552
- ['en', 'cs'],
553
- localisedUrls,
554
- ),
555
- ).toBe('/cs/produkty/cervena-bota');
556
- expect(
557
- canonicalTargetPathname(
558
- '/cs/produkty/cervena-bota',
559
- ['en', 'cs'],
560
- localisedUrls,
561
- ),
562
- ).toBe('/products/cervena-bota');
563
- });
564
-
565
424
  test('strips case-insensitive locale prefixes before relocalising full pathnames', () => {
566
425
  const localisedUrls = {
567
426
  '/products/:slug': {
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from '@rstest/core';
2
+ import { canonicalPath, localizePath } from '../src/paths';
3
+
4
+ const config = {
5
+ languages: ['en', 'cs'],
6
+ localisedUrls: {
7
+ '/products/:slug': { en: '/products/:slug', cs: '/produkty/:slug' },
8
+ },
9
+ };
10
+
11
+ describe('@modern-js/i18n-runtime-extensions/paths', () => {
12
+ test('localises the pathname while leaving query and hash verbatim', () => {
13
+ expect(
14
+ localizePath(
15
+ '/products/red-shoe?tag=boots&tag=sale#details',
16
+ 'cs',
17
+ config,
18
+ ),
19
+ ).toBe('/cs/produkty/red-shoe?tag=boots&tag=sale#details');
20
+ expect(
21
+ canonicalPath('/CS/produkty/red-shoe?tag=boots#details', config),
22
+ ).toBe('/products/red-shoe?tag=boots#details');
23
+ });
24
+ });
@@ -7,6 +7,14 @@ interface Target {
7
7
  language: string;
8
8
  }
9
9
 
10
+ beforeEach(() => {
11
+ rstest.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
12
+ });
13
+
14
+ afterEach(() => {
15
+ rstest.unstubAllGlobals();
16
+ });
17
+
10
18
  test('an abandoned suspended render cannot publish an uncommitted target', async () => {
11
19
  let resolveFirst!: () => void;
12
20
  const firstChange = new Promise<void>(resolve => {
@@ -14,6 +22,7 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
14
22
  });
15
23
  const changes: string[] = [];
16
24
  const commits: string[] = [];
25
+ let suspended = false;
17
26
  const neverSettles = new Promise<never>(() => undefined);
18
27
  const first = { id: 'first', language: 'en' };
19
28
  const speculative = { id: 'speculative', language: 'en' };
@@ -41,6 +50,7 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
41
50
  },
42
51
  });
43
52
  if (suspend) {
53
+ suspended = true;
44
54
  throw neverSettles;
45
55
  }
46
56
  return <main>{target.id}</main>;
@@ -49,7 +59,11 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
49
59
  const container = document.createElement('div');
50
60
  const root = createRoot(container);
51
61
  await act(async () => {
52
- root.render(<Harness target={first} />);
62
+ root.render(
63
+ <Suspense fallback={<p>loading</p>}>
64
+ <Harness target={first} />
65
+ </Suspense>,
66
+ );
53
67
  });
54
68
  expect(changes).toEqual(['first']);
55
69
 
@@ -64,6 +78,11 @@ test('an abandoned suspended render cannot publish an uncommitted target', async
64
78
  await Promise.resolve();
65
79
  });
66
80
 
81
+ expect(suspended).toBe(true);
82
+ expect(container.textContent).toBe('first');
83
+ expect(changes).toEqual(['first']);
84
+ expect(commits).toEqual([]);
85
+
67
86
  await act(async () => {
68
87
  resolveFirst();
69
88
  await firstChange;
@@ -1,74 +0,0 @@
1
- import React, { act } from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { useClientSideRedirect } from '../../plugin-i18n/src/runtime/hooks';
4
-
5
- const navigate = rstest.fn(async () => undefined);
6
- let location = {
7
- pathname: '/cs/about',
8
- search: '?from=test',
9
- hash: '#section',
10
- };
11
-
12
- rstest.mock('../../plugin-i18n/src/runtime/routerAdapter', () => ({
13
- useI18nRouterAdapter: () => ({
14
- hasRouter: true,
15
- location,
16
- navigate,
17
- }),
18
- }));
19
-
20
- test('canonicalizes successive prefixed routes in one mounted provider', async () => {
21
- const previousTarget = process.env.MODERN_TARGET;
22
- process.env.MODERN_TARGET = 'browser';
23
- const instance = {
24
- language: 'cs',
25
- isInitialized: true,
26
- } as Parameters<typeof useClientSideRedirect>[0];
27
- const localisedUrls = {
28
- '/about': { en: '/about', cs: '/o-nas' },
29
- '/terms-of-service': {
30
- en: '/terms-of-service',
31
- cs: '/podminky-pouzivani',
32
- },
33
- };
34
- const Harness = () => {
35
- useClientSideRedirect(
36
- instance,
37
- true,
38
- ['en', 'cs'],
39
- 'en',
40
- undefined,
41
- localisedUrls,
42
- );
43
- return null;
44
- };
45
- const container = document.createElement('div');
46
- const root = createRoot(container);
47
-
48
- try {
49
- window.history.replaceState(null, '', '/cs/about?from=test#section');
50
- await act(async () => root.render(<Harness />));
51
- expect(navigate).toHaveBeenLastCalledWith('/cs/o-nas?from=test#section', {
52
- replace: true,
53
- });
54
-
55
- location = {
56
- pathname: '/cs/terms-of-service',
57
- search: '',
58
- hash: '',
59
- };
60
- window.history.replaceState(null, '', '/cs/terms-of-service');
61
- await act(async () => root.render(<Harness />));
62
- expect(navigate).toHaveBeenLastCalledWith('/cs/podminky-pouzivani', {
63
- replace: true,
64
- });
65
- expect(navigate).toHaveBeenCalledTimes(2);
66
- } finally {
67
- await act(async () => root.unmount());
68
- if (previousTarget === undefined) {
69
- delete process.env.MODERN_TARGET;
70
- } else {
71
- process.env.MODERN_TARGET = previousTarget;
72
- }
73
- }
74
- });