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

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 (73) hide show
  1. package/dist/cjs/index.js +10 -10
  2. package/dist/cjs/localeRedirectPolicy.js +6 -6
  3. package/dist/cjs/paths.js +3 -3
  4. package/dist/cjs/router-navigation/adapter.js +24 -166
  5. package/dist/esm/index.mjs +1 -1
  6. package/dist/esm/localeRedirectPolicy.mjs +1 -1
  7. package/dist/esm/paths.mjs +1 -1
  8. package/dist/esm/router-navigation/adapter.mjs +26 -168
  9. package/dist/esm-node/index.mjs +1 -1
  10. package/dist/esm-node/localeRedirectPolicy.mjs +1 -1
  11. package/dist/esm-node/paths.mjs +1 -1
  12. package/dist/esm-node/router-navigation/adapter.mjs +26 -168
  13. package/dist/types/index.d.ts +1 -1
  14. package/dist/types/localeRedirectPolicy.d.ts +1 -1
  15. package/dist/types/paths.d.ts +1 -1
  16. package/dist/types/router-navigation/adapter.d.ts +7 -55
  17. package/package.json +9 -9
  18. package/src/index.ts +1 -1
  19. package/src/localeRedirectPolicy.ts +2 -2
  20. package/src/paths.ts +2 -2
  21. package/src/router-navigation/adapter.tsx +62 -478
  22. package/tests/routerNavigation.test.tsx +80 -0
  23. package/dist/cjs/localisedUrls/config.js +0 -49
  24. package/dist/cjs/localisedUrls/index.js +0 -98
  25. package/dist/cjs/localisedUrls/normalise.js +0 -87
  26. package/dist/cjs/localisedUrls/pathname.js +0 -68
  27. package/dist/cjs/localisedUrls/patterns.js +0 -214
  28. package/dist/cjs/localisedUrls/redirect.js +0 -100
  29. package/dist/cjs/localisedUrls/resolve.js +0 -78
  30. package/dist/cjs/localisedUrls/routeIdentity.js +0 -118
  31. package/dist/cjs/localisedUrls/routes.js +0 -210
  32. package/dist/cjs/localisedUrls/types.js +0 -18
  33. package/dist/esm/localisedUrls/config.mjs +0 -11
  34. package/dist/esm/localisedUrls/index.mjs +0 -8
  35. package/dist/esm/localisedUrls/normalise.mjs +0 -37
  36. package/dist/esm/localisedUrls/pathname.mjs +0 -24
  37. package/dist/esm/localisedUrls/patterns.mjs +0 -152
  38. package/dist/esm/localisedUrls/redirect.mjs +0 -53
  39. package/dist/esm/localisedUrls/resolve.mjs +0 -37
  40. package/dist/esm/localisedUrls/routeIdentity.mjs +0 -77
  41. package/dist/esm/localisedUrls/routes.mjs +0 -169
  42. package/dist/esm/localisedUrls/types.mjs +0 -0
  43. package/dist/esm-node/localisedUrls/config.mjs +0 -12
  44. package/dist/esm-node/localisedUrls/index.mjs +0 -9
  45. package/dist/esm-node/localisedUrls/normalise.mjs +0 -38
  46. package/dist/esm-node/localisedUrls/pathname.mjs +0 -25
  47. package/dist/esm-node/localisedUrls/patterns.mjs +0 -153
  48. package/dist/esm-node/localisedUrls/redirect.mjs +0 -54
  49. package/dist/esm-node/localisedUrls/resolve.mjs +0 -38
  50. package/dist/esm-node/localisedUrls/routeIdentity.mjs +0 -78
  51. package/dist/esm-node/localisedUrls/routes.mjs +0 -170
  52. package/dist/esm-node/localisedUrls/types.mjs +0 -1
  53. package/dist/types/localisedUrls/config.d.ts +0 -9
  54. package/dist/types/localisedUrls/index.d.ts +0 -10
  55. package/dist/types/localisedUrls/normalise.d.ts +0 -10
  56. package/dist/types/localisedUrls/pathname.d.ts +0 -4
  57. package/dist/types/localisedUrls/patterns.d.ts +0 -11
  58. package/dist/types/localisedUrls/redirect.d.ts +0 -19
  59. package/dist/types/localisedUrls/resolve.d.ts +0 -7
  60. package/dist/types/localisedUrls/routeIdentity.d.ts +0 -24
  61. package/dist/types/localisedUrls/routes.d.ts +0 -3
  62. package/dist/types/localisedUrls/types.d.ts +0 -14
  63. package/src/localisedUrls/config.ts +0 -18
  64. package/src/localisedUrls/index.ts +0 -28
  65. package/src/localisedUrls/normalise.ts +0 -62
  66. package/src/localisedUrls/pathname.ts +0 -60
  67. package/src/localisedUrls/patterns.ts +0 -283
  68. package/src/localisedUrls/redirect.ts +0 -107
  69. package/src/localisedUrls/resolve.ts +0 -106
  70. package/src/localisedUrls/routeIdentity.ts +0 -107
  71. package/src/localisedUrls/routes.ts +0 -432
  72. package/src/localisedUrls/types.ts +0 -18
  73. package/tests/localisedUrls.test.ts +0 -532
@@ -1,40 +1,15 @@
1
- import { isBrowser, RuntimeContext } from '@modern-js/runtime';
2
- import {
3
- InternalRuntimeContext,
4
- type TInternalRuntimeContext,
5
- type TRuntimeContext,
6
- } from '@modern-js/runtime/context';
1
+ import { RuntimeContext } from '@modern-js/runtime';
2
+ import { InternalRuntimeContext } from '@modern-js/runtime/context';
7
3
  import {
8
4
  getRouterRuntimeState,
5
+ type RouterLinkTarget,
6
+ type RouterNavigationCapability,
7
+ type RouterNavigationSnapshot,
9
8
  subscribeRouterRuntimeState,
10
9
  } from '@modern-js/runtime-extensions/router-state';
11
10
  import type React from 'react';
12
11
  import { useCallback, useContext, useSyncExternalStore } from 'react';
13
12
 
14
- /**
15
- * Fork-owned: the selected-router navigation adapter.
16
- *
17
- * `useNativeI18nRouterAdapter` only knows react-router's own hooks, so a bare
18
- * `appTools() + i18nPlugin()` app running the TanStack router would fall back
19
- * to plain `<a>` links and a non-reactive `window.location`. That breaks
20
- * language-invariant active state and, worse, leaves the i18n language stuck
21
- * after a client-side navigation to a localized URL.
22
- *
23
- * The subsystem lives in this fork-owned package rather than inside
24
- * upstream-owned `@modern-js/plugin-i18n`: that package only wires it in
25
- * through its existing `NavigationProvider` extension point, so the upstream
26
- * package keeps a seam instead of gaining a fork subsystem. The primitives the
27
- * adapter composes with - the navigation context provider, the native
28
- * react-router adapter and the adapter shape - are injected by that seam, so
29
- * nothing here imports `@modern-js/plugin-i18n` and no dependency cycle is
30
- * created.
31
- *
32
- * The adapter reads the actual selected router out of the fork-owned router
33
- * runtime-state slot, so it serves both routers without either plugin having
34
- * to know about i18n.
35
- */
36
-
37
- /** The primitives the host plugin injects; see `createI18nRouterNavigation`. */
38
13
  export interface I18nRouterNavigationDependencies<
39
14
  Adapter extends I18nRouterAdapterShape,
40
15
  > {
@@ -44,491 +19,100 @@ export interface I18nRouterNavigationDependencies<
44
19
  useNativeI18nRouterAdapter: () => Adapter;
45
20
  }
46
21
 
47
- export interface I18nRouterLinkTarget {
48
- pathname: string;
49
- href: string;
50
- search?: Record<string, unknown>;
51
- hash?: string;
52
- hashScrollIntoView?: boolean | ScrollIntoViewOptions;
53
- prefetch?: 'intent' | 'render' | 'viewport' | 'none';
54
- preload?: unknown;
55
- }
22
+ export type I18nRouterLinkTarget = RouterLinkTarget;
56
23
 
57
24
  export interface I18nRouterAdapterShape {
58
25
  framework?: string;
59
26
  hasRouter: boolean;
60
- location: { pathname: string; search: string; hash: string } | null;
61
- navigate:
62
- | ((
63
- href: string,
64
- options?: { replace?: boolean; state?: unknown },
65
- ) => void | Promise<void>)
66
- | null;
67
- Link: React.ComponentType<{
68
- to: string;
69
- children?: React.ReactNode;
70
- [key: string]: unknown;
71
- }> | null;
27
+ location: RouterNavigationSnapshot['location'] | null;
28
+ navigate: RouterNavigationCapability['navigate'] | null;
29
+ Link: RouterNavigationCapability['Link'] | null;
72
30
  params: Record<string, string>;
73
- createLinkProps?: (target: I18nRouterLinkTarget) => {
74
- to: string;
75
- [key: string]: unknown;
76
- };
77
- }
78
-
79
- type I18nRouterFramework = 'react-router' | 'tanstack' | string;
80
-
81
- interface I18nRouterLocation {
82
- pathname: string;
83
- search: string;
84
- hash: string;
85
- }
86
-
87
- interface I18nRouterNavigateOptions {
88
- replace?: boolean;
89
- state?: unknown;
31
+ createLinkProps?: RouterNavigationCapability['createLinkProps'];
90
32
  }
91
33
 
92
- type I18nRouterNavigate = (
93
- href: string,
94
- options?: I18nRouterNavigateOptions,
95
- ) => void | Promise<void>;
96
-
97
- type I18nRouterLink = React.ComponentType<{
98
- to: string;
99
- children?: React.ReactNode;
100
- [key: string]: unknown;
101
- }>;
102
-
103
- type RuntimeContextWithRouter = TRuntimeContext & {
104
- router?: {
105
- useInRouterContext?: () => boolean;
106
- useRouter?: (options?: { warn?: boolean }) => unknown;
107
- useLocation?: () => unknown;
108
- useNavigate?: () => I18nRouterNavigate;
109
- useParams?: () => Record<string, string>;
110
- useHref?: () => unknown;
111
- Link?: I18nRouterLink;
112
- };
113
- };
114
-
115
- type InternalRuntimeContextWithRouter = TInternalRuntimeContext & {
116
- router?: RuntimeContextWithRouter['router'];
117
- };
118
-
119
- type RouterInstance = {
120
- navigate?: (...args: any[]) => unknown;
121
- state?: {
122
- location?: unknown;
123
- matches?: Array<{ params?: Record<string, string> }>;
124
- };
125
- stores?: {
126
- location?: {
127
- get?: () => unknown;
128
- subscribe?: (listener: () => void) => () => void;
129
- };
130
- matches?: {
131
- get?: () => Array<{ params?: Record<string, string> }>;
132
- };
133
- };
134
- subscribe?:
135
- | ((listener: () => void) => () => void)
136
- | ((eventType: string, listener: () => void) => () => void);
137
- };
138
-
139
- const normalizeUrlPart = (value: unknown, prefix: '?' | '#'): string => {
140
- if (typeof value !== 'string' || !value) {
141
- return '';
142
- }
143
- return value.startsWith(prefix) ? value : `${prefix}${value}`;
144
- };
145
-
146
- const normalizeLocation = (location: unknown): I18nRouterLocation | null => {
147
- if (!location || typeof location !== 'object') {
148
- return null;
149
- }
150
-
151
- const locationValue = location as {
152
- pathname?: unknown;
153
- search?: unknown;
154
- searchStr?: unknown;
155
- hash?: unknown;
156
- };
157
-
158
- if (typeof locationValue.pathname !== 'string') {
159
- return null;
160
- }
161
-
162
- return {
163
- pathname: locationValue.pathname,
164
- search: normalizeUrlPart(
165
- typeof locationValue.search === 'string'
166
- ? locationValue.search
167
- : locationValue.searchStr,
168
- '?',
169
- ),
170
- hash: normalizeUrlPart(locationValue.hash, '#'),
171
- };
172
- };
173
-
174
- const getWindowLocation = (): I18nRouterLocation | null => {
175
- if (!isBrowser()) {
176
- return null;
177
- }
178
-
179
- return {
180
- pathname: window.location.pathname,
181
- search: window.location.search,
182
- hash: window.location.hash,
183
- };
184
- };
185
-
186
- const getRouterFramework = (
187
- runtimeContext: RuntimeContextWithRouter,
188
- internalContext: InternalRuntimeContextWithRouter,
189
- inReactRouter: boolean,
190
- ): I18nRouterFramework | undefined => {
191
- const framework =
192
- getRouterRuntimeState(internalContext)?.framework ||
193
- getRouterRuntimeState(runtimeContext)?.framework;
194
-
195
- if (framework) {
196
- return framework;
197
- }
198
-
199
- if (internalContext.router?.useRouter || runtimeContext.router?.useRouter) {
200
- return 'tanstack';
201
- }
202
-
203
- if (
204
- internalContext.router?.useLocation ||
205
- internalContext.router?.useHref ||
206
- runtimeContext.router?.useLocation ||
207
- runtimeContext.router?.useHref
208
- ) {
209
- return 'react-router';
210
- }
34
+ const subscribeNone = () => () => {};
35
+ const getEmptySnapshot = () => null;
211
36
 
212
- if (inReactRouter) {
213
- return 'react-router';
214
- }
215
-
216
- return undefined;
217
- };
218
-
219
- const getRouterInstance = (
220
- internalContext: InternalRuntimeContextWithRouter,
221
- contextRouter?: RouterInstance | null,
222
- ): RouterInstance | null => {
223
- if (contextRouter) {
224
- return contextRouter;
225
- }
226
-
227
- const router = getRouterRuntimeState(internalContext)?.instance;
228
- if (!router || typeof router !== 'object') {
229
- return null;
230
- }
231
- return router as RouterInstance;
232
- };
233
-
234
- const getRouterStateLocation = (
235
- internalContext: InternalRuntimeContextWithRouter,
236
- contextRouter?: RouterInstance | null,
237
- ): I18nRouterLocation | null => {
238
- const router = getRouterInstance(internalContext, contextRouter);
239
- return (
240
- normalizeLocation(router?.stores?.location?.get?.()) ||
241
- normalizeLocation(router?.state?.location)
242
- );
243
- };
244
-
245
- const getRouterParams = (
246
- internalContext: InternalRuntimeContextWithRouter,
247
- contextRouter?: RouterInstance | null,
248
- ): Record<string, string> => {
249
- const router = getRouterInstance(internalContext, contextRouter);
250
- const matches = router?.stores?.matches?.get?.() || router?.state?.matches;
251
- if (!Array.isArray(matches)) {
252
- return {};
253
- }
254
-
255
- return matches.reduce<Record<string, string>>((params, match) => {
256
- if (match?.params) {
257
- Object.assign(params, match.params);
258
- }
259
- return params;
260
- }, {});
261
- };
262
-
263
- const getRouterSnapshot = (
264
- internalContext: InternalRuntimeContextWithRouter,
265
- contextRouter?: RouterInstance | null,
266
- ) => {
267
- const location = getRouterStateLocation(internalContext, contextRouter);
268
- const params = getRouterParams(internalContext, contextRouter);
269
- return JSON.stringify([
270
- location?.pathname ?? '',
271
- location?.search ?? '',
272
- location?.hash ?? '',
273
- Object.entries(params).sort(([left], [right]) => left.localeCompare(right)),
274
- ]);
275
- };
276
-
277
- /**
278
- * Build the selected-router navigation hook and provider from the host
279
- * plugin's own primitives. Injecting them is what keeps this fork-owned
280
- * subsystem free of any import edge back into the plugin that installs it.
281
- */
37
+ /** The router provider owns navigation; this seam only observes its capability. */
282
38
  export const createI18nRouterNavigation = <
283
39
  Adapter extends I18nRouterAdapterShape,
284
40
  >({
285
41
  I18nNavigationProvider,
286
42
  useNativeI18nRouterAdapter,
287
43
  }: I18nRouterNavigationDependencies<Adapter>) => {
44
+ // The injected context accepts null to preserve native-router fallback.
45
+ const NavigationProvider = I18nNavigationProvider as React.ComponentType<
46
+ React.PropsWithChildren<{ value: Adapter | null }>
47
+ >;
288
48
  const useIntegratedRouterAdapter = (): Adapter => {
289
- const runtimeContext = useContext(
290
- RuntimeContext,
291
- ) as RuntimeContextWithRouter;
292
- const internalContext = useContext(
293
- InternalRuntimeContext,
294
- ) as InternalRuntimeContextWithRouter;
295
- const routerApi = internalContext.router || runtimeContext.router;
49
+ const runtimeContext = useContext(RuntimeContext);
50
+ const internalContext = useContext(InternalRuntimeContext);
296
51
  const native = useNativeI18nRouterAdapter();
297
- const inReactRouter = native.hasRouter;
298
- const reactRouterNavigate = native.navigate;
299
- const reactRouterLocation = inReactRouter ? native.location : null;
300
- const reactRouterParams = native.params;
301
- const framework = getRouterFramework(
302
- runtimeContext,
303
- internalContext,
304
- inReactRouter,
52
+ const getCapability = useCallback(
53
+ () =>
54
+ getRouterRuntimeState(internalContext)?.navigation ??
55
+ getRouterRuntimeState(runtimeContext)?.navigation,
56
+ [internalContext, runtimeContext],
305
57
  );
306
- const contextUseRouter =
307
- !inReactRouter && framework === 'tanstack'
308
- ? internalContext.router?.useRouter || runtimeContext.router?.useRouter
309
- : undefined;
310
- const contextRouter = contextUseRouter
311
- ? (contextUseRouter({ warn: false }) as RouterInstance | null)
312
- : null;
313
- // `hasRouter` means "this adapter can actually drive a router", not "a
314
- // router framework was named". Two things can drive one: a live react-router
315
- // context we are rendering inside, or an instance published in the
316
- // runtime-state slot. A framework name alone is neither.
317
- //
318
- // This matters because router providers publish their name before their
319
- // instance. `plugin-tanstack` publishes its hooks in `onBeforeRender` and
320
- // only installs the instance when `RouterWrapper` renders - below anything
321
- // wrapping the app, this provider included - so on a CSR boot the first
322
- // evaluation here sees a name and no instance. Claiming a router there hands
323
- // `changeLanguage()` a `navigate` that can only throw, instead of letting it
324
- // fall back to a full-page load.
325
- const slotRouter = getRouterInstance(internalContext, contextRouter);
326
- // A slot instance counts only when `navigate` below can drive it: a named
327
- // framework this adapter implements, with a navigate function. An unknown
328
- // provider's instance would otherwise hand `changeLanguage()` a navigate
329
- // that goes nowhere instead of the routerless full-page fallback.
330
- const drivableSlotRouter =
331
- (framework === 'tanstack' || framework === 'react-router') &&
332
- typeof slotRouter?.navigate === 'function';
333
- const hasRouter = Boolean(reactRouterNavigate) || drivableSlotRouter;
334
-
335
- const subscribeToRouter = useCallback(
58
+ const subscribeCapability = useCallback(
336
59
  (update: () => void) => {
337
- const unsubscribers: Array<() => void> = [];
338
-
339
- // The instance can arrive after this provider has already rendered, so
340
- // watch the slot itself: without this the store subscription below would
341
- // be a permanent no-op on a CSR boot and the adapter would never notice
342
- // the router it is meant to drive.
343
- const unsubscribeFromSlot = subscribeRouterRuntimeState(
60
+ const stopInternal = subscribeRouterRuntimeState(
344
61
  internalContext,
345
62
  update,
346
63
  );
347
- if (typeof unsubscribeFromSlot === 'function') {
348
- unsubscribers.push(unsubscribeFromSlot);
349
- }
350
-
351
- const router = getRouterInstance(internalContext, contextRouter);
352
- if (!router) {
353
- return () => {
354
- for (const unsubscribe of unsubscribers) {
355
- unsubscribe();
356
- }
357
- };
358
- }
359
-
360
- if (
361
- framework === 'react-router' &&
362
- !inReactRouter &&
363
- typeof router.subscribe === 'function'
364
- ) {
365
- const subscribe = router.subscribe as (
366
- this: RouterInstance,
367
- listener: () => void,
368
- ) => () => void;
369
- const unsubscribe = subscribe.call(router, update);
370
- if (typeof unsubscribe === 'function') {
371
- unsubscribers.push(unsubscribe);
372
- }
373
- }
374
-
375
- if (
376
- framework === 'tanstack' &&
377
- typeof router.stores?.location?.subscribe === 'function'
378
- ) {
379
- const unsubscribe = router.stores.location.subscribe(update);
380
- if (typeof unsubscribe === 'function') {
381
- unsubscribers.push(unsubscribe);
382
- }
383
- }
384
-
385
- if (
386
- framework === 'tanstack' &&
387
- typeof router.subscribe === 'function'
388
- ) {
389
- const subscribe = router.subscribe as (
390
- this: RouterInstance,
391
- eventType: string,
392
- listener: () => void,
393
- ) => () => void;
394
- for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
395
- const unsubscribe = subscribe.call(router, eventType, update);
396
- if (typeof unsubscribe === 'function') {
397
- unsubscribers.push(unsubscribe);
398
- }
399
- }
400
- }
401
-
64
+ const stopPublic =
65
+ internalContext === runtimeContext
66
+ ? undefined
67
+ : subscribeRouterRuntimeState(runtimeContext, update);
402
68
  return () => {
403
- for (const unsubscribe of unsubscribers) {
404
- unsubscribe();
405
- }
69
+ stopInternal();
70
+ stopPublic?.();
406
71
  };
407
72
  },
408
- [contextRouter, framework, inReactRouter, internalContext],
73
+ [internalContext, runtimeContext],
409
74
  );
410
- const getSnapshot = useCallback(
411
- () => getRouterSnapshot(internalContext, contextRouter),
412
- [contextRouter, internalContext],
75
+ // Separate subscriptions handle both delayed installation/replacement and
76
+ // navigation inside the currently selected provider, without hook guessing.
77
+ const capability = useSyncExternalStore(
78
+ subscribeCapability,
79
+ getCapability,
80
+ getCapability,
413
81
  );
414
- useSyncExternalStore(subscribeToRouter, getSnapshot, getSnapshot);
415
-
416
- const navigate = useCallback<I18nRouterNavigate>(
417
- (href, options) => {
418
- const router = getRouterInstance(internalContext, contextRouter);
419
- const activeFramework = getRouterFramework(
420
- runtimeContext,
421
- internalContext,
422
- inReactRouter,
423
- );
424
-
425
- if (activeFramework === 'tanstack') {
426
- if (typeof router?.navigate === 'function') {
427
- return router.navigate({
428
- to: href,
429
- replace: options?.replace,
430
- ...(options?.state === undefined ? {} : { state: options.state }),
431
- }) as void | Promise<void>;
432
- }
433
- throw new Error('TanStack router instance is not available.');
434
- }
435
-
436
- if (reactRouterNavigate) {
437
- return reactRouterNavigate(href, options);
438
- }
439
-
440
- if (activeFramework === 'react-router') {
441
- if (typeof router?.navigate === 'function') {
442
- return router.navigate(href, options) as void | Promise<void>;
443
- }
444
- throw new Error('React Router instance is not available.');
445
- }
446
- },
447
- [
448
- contextRouter,
449
- internalContext,
450
- inReactRouter,
451
- reactRouterNavigate,
452
- runtimeContext,
453
- ],
82
+ const snapshot = useSyncExternalStore(
83
+ capability?.subscribe ?? subscribeNone,
84
+ capability?.getSnapshot ?? getEmptySnapshot,
85
+ capability?.getSnapshot ?? getEmptySnapshot,
454
86
  );
455
-
456
- const location =
457
- (reactRouterLocation
458
- ? normalizeLocation(reactRouterLocation)
459
- : getRouterStateLocation(internalContext, contextRouter)) ||
460
- getWindowLocation();
461
- const params = inReactRouter
462
- ? (reactRouterParams as Record<string, string>)
463
- : getRouterParams(internalContext, contextRouter);
464
- const Link =
465
- framework === 'tanstack'
466
- ? internalContext.router?.Link || runtimeContext.router?.Link || null
467
- : framework === 'react-router' || inReactRouter
468
- ? (routerApi?.Link ?? null)
469
- : null;
470
-
87
+ if (native.hasRouter || !capability || !snapshot) {
88
+ return native;
89
+ }
90
+ const internalState = getRouterRuntimeState(internalContext);
471
91
  return {
472
- framework,
473
- hasRouter,
474
- location,
475
- navigate: hasRouter ? navigate : null,
476
- Link,
477
- params,
478
- ...(framework === 'tanstack'
479
- ? { createLinkProps: createTanstackLinkProps }
480
- : {}),
92
+ framework:
93
+ internalState?.navigation === capability
94
+ ? internalState.framework
95
+ : getRouterRuntimeState(runtimeContext)?.framework,
96
+ hasRouter: true,
97
+ ...snapshot,
98
+ navigate: capability.navigate,
99
+ Link: capability.Link,
100
+ createLinkProps: capability.createLinkProps,
481
101
  } as Adapter;
482
102
  };
483
103
 
484
- const createTanstackLinkProps = (target: I18nRouterLinkTarget) => {
485
- const preload =
486
- target.preload !== undefined
487
- ? target.preload
488
- : target.prefetch === 'none'
489
- ? false
490
- : target.prefetch;
491
- return {
492
- to: target.pathname,
493
- ...(target.search ? { search: target.search } : {}),
494
- ...(target.hash ? { hash: target.hash } : {}),
495
- ...(target.hashScrollIntoView === undefined
496
- ? {}
497
- : { hashScrollIntoView: target.hashScrollIntoView }),
498
- ...(preload === undefined ? {} : { preload }),
499
- };
500
- };
501
-
502
- /**
503
- * Supplies the actual selected router; native links still render its Link.
504
- *
505
- * This provider sits above the app, so it sits above the router element too.
506
- * That is fine for a router this adapter reaches through the fork-owned
507
- * runtime-state slot - TanStack hands out its instance, not a React context -
508
- * but react-router publishes its location and navigate through context that
509
- * only exists *below* the router. Reading it from up here yields an inert
510
- * adapter, and publishing that would shadow the native one a consumer
511
- * resolves at its own position, where the context is live. So only take over
512
- * for the router reached through the slot, and only once its instance is
513
- * actually installed; until then `useI18nRouterAdapter` falls back to the
514
- * native adapter and `changeLanguage()` keeps its full-page behaviour.
515
- */
516
104
  const I18nRouterNavigationProvider = ({
517
105
  children,
518
106
  }: React.PropsWithChildren) => {
519
107
  const value = useIntegratedRouterAdapter();
520
108
  const native = useNativeI18nRouterAdapter();
521
- // Publish only what a consumer could not resolve better itself. Inside a
522
- // live react-router context the native adapter - evaluated at the
523
- // consumer's own position, where that context exists - is already the
524
- // right answer, and shadowing it with one read from above the router
525
- // yields an inert adapter. With no router to drive at all, staying absent
526
- // is what preserves the full-page `changeLanguage()` fallback.
527
- if (!value.hasRouter || native.hasRouter) {
528
- return <>{children}</>;
529
- }
109
+ // Keep the router subtree mounted when a capability arrives or disappears.
530
110
  return (
531
- <I18nNavigationProvider value={value}>{children}</I18nNavigationProvider>
111
+ <NavigationProvider
112
+ value={value.hasRouter && !native.hasRouter ? value : null}
113
+ >
114
+ {children}
115
+ </NavigationProvider>
532
116
  );
533
117
  };
534
118
 
@@ -0,0 +1,80 @@
1
+ import {
2
+ InternalRuntimeContext,
3
+ RuntimeContext,
4
+ } from '@modern-js/runtime/context';
5
+ import {
6
+ applyRouterRuntimeState,
7
+ type RouterNavigationCapability,
8
+ } from '@modern-js/runtime-extensions/router-state';
9
+ import React, { act, createContext, useContext, useEffect } from 'react';
10
+ import { createRoot } from 'react-dom/client';
11
+ import {
12
+ createI18nRouterNavigation,
13
+ type I18nRouterAdapterShape,
14
+ } from '../src/router-navigation/adapter';
15
+
16
+ test('late navigation installation preserves the mounted router and native fallback', async () => {
17
+ rstest.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
18
+ const context = {} as React.ContextType<typeof InternalRuntimeContext>;
19
+ const navigationContext = createContext<I18nRouterAdapterShape | null>(null);
20
+ const native: I18nRouterAdapterShape = {
21
+ hasRouter: false,
22
+ location: null,
23
+ navigate: null,
24
+ Link: null,
25
+ params: {},
26
+ };
27
+ const { I18nRouterNavigationProvider } = createI18nRouterNavigation({
28
+ I18nNavigationProvider: navigationContext.Provider,
29
+ useNativeI18nRouterAdapter: () => native,
30
+ });
31
+ const snapshot = {
32
+ location: { pathname: '/cs/produkty/bota', search: '', hash: '' },
33
+ params: { lang: 'cs', slug: 'bota' },
34
+ };
35
+ const navigation: RouterNavigationCapability = {
36
+ getSnapshot: () => snapshot,
37
+ subscribe: () => () => {},
38
+ navigate: () => {},
39
+ Link: () => null,
40
+ };
41
+ const mounted = rstest.fn();
42
+ const unmounted = rstest.fn();
43
+ const Router = () => {
44
+ const adapter = useContext(navigationContext);
45
+ useEffect(() => {
46
+ mounted();
47
+ return unmounted;
48
+ }, []);
49
+ return <p>{adapter?.location?.pathname ?? 'native fallback'}</p>;
50
+ };
51
+ const container = document.createElement('div');
52
+ const root = createRoot(container);
53
+ try {
54
+ await act(async () => {
55
+ root.render(
56
+ <InternalRuntimeContext.Provider value={context}>
57
+ <RuntimeContext.Provider value={context}>
58
+ <I18nRouterNavigationProvider>
59
+ <Router />
60
+ </I18nRouterNavigationProvider>
61
+ </RuntimeContext.Provider>
62
+ </InternalRuntimeContext.Provider>,
63
+ );
64
+ });
65
+ expect(container.textContent).toBe('native fallback');
66
+ await act(async () => {
67
+ applyRouterRuntimeState(context, { framework: 'tanstack', navigation });
68
+ });
69
+ expect(container.textContent).toBe('/cs/produkty/bota');
70
+ await act(async () => {
71
+ applyRouterRuntimeState(context, { navigation: undefined });
72
+ });
73
+ expect(container.textContent).toBe('native fallback');
74
+ expect(mounted).toHaveBeenCalledTimes(1);
75
+ expect(unmounted).not.toHaveBeenCalled();
76
+ } finally {
77
+ await act(async () => root.unmount());
78
+ rstest.unstubAllGlobals();
79
+ }
80
+ });