@bleedingdev/modern-js-i18n-runtime-extensions 3.8.3-ultramodern.6 → 3.9.0-ultramodern.10
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/dist/cjs/index.js +9 -2
- package/dist/cjs/localeRedirectPolicy.js +96 -0
- package/dist/cjs/localisedUrls/index.js +7 -0
- package/dist/cjs/localisedUrls/redirect.js +3 -6
- package/dist/cjs/localisedUrls/routeIdentity.js +118 -0
- package/dist/cjs/localisedUrls/routes.js +8 -0
- package/dist/cjs/paths.js +56 -0
- package/dist/cjs/router-navigation/adapter.js +235 -0
- package/dist/cjs/router-navigation/index.js +40 -0
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/localeRedirectPolicy.mjs +46 -0
- package/dist/esm/localisedUrls/index.mjs +1 -0
- package/dist/esm/localisedUrls/redirect.mjs +3 -6
- package/dist/esm/localisedUrls/routeIdentity.mjs +77 -0
- package/dist/esm/localisedUrls/routes.mjs +8 -0
- package/dist/esm/paths.mjs +12 -0
- package/dist/esm/router-navigation/adapter.mjs +197 -0
- package/dist/esm/router-navigation/index.mjs +1 -0
- package/dist/esm-node/index.mjs +1 -0
- package/dist/esm-node/localeRedirectPolicy.mjs +47 -0
- package/dist/esm-node/localisedUrls/index.mjs +1 -0
- package/dist/esm-node/localisedUrls/redirect.mjs +3 -6
- package/dist/esm-node/localisedUrls/routeIdentity.mjs +78 -0
- package/dist/esm-node/localisedUrls/routes.mjs +8 -0
- package/dist/esm-node/paths.mjs +13 -0
- package/dist/esm-node/router-navigation/adapter.mjs +198 -0
- package/dist/esm-node/router-navigation/index.mjs +2 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/language-sync/react.d.ts +1 -1
- package/dist/types/localeRedirectPolicy.d.ts +22 -0
- package/dist/types/localisedUrls/config.d.ts +1 -1
- package/dist/types/localisedUrls/index.d.ts +10 -9
- package/dist/types/localisedUrls/pathname.d.ts +1 -1
- package/dist/types/localisedUrls/redirect.d.ts +8 -0
- package/dist/types/localisedUrls/resolve.d.ts +1 -1
- package/dist/types/localisedUrls/routeIdentity.d.ts +24 -0
- package/dist/types/localisedUrls/routes.d.ts +1 -1
- package/dist/types/paths.d.ts +9 -0
- package/dist/types/router-navigation/adapter.d.ts +71 -0
- package/dist/types/router-navigation/index.d.ts +1 -0
- package/package.json +30 -6
- package/src/index.ts +1 -0
- package/src/localeRedirectPolicy.ts +127 -0
- package/src/localisedUrls/index.ts +4 -0
- package/src/localisedUrls/redirect.ts +16 -2
- package/src/localisedUrls/routeIdentity.ts +107 -0
- package/src/localisedUrls/routes.ts +10 -0
- package/src/paths.ts +37 -0
- package/src/router-navigation/adapter.tsx +536 -0
- package/src/router-navigation/index.ts +6 -0
- package/tests/languageSyncController.test.ts +0 -77
- package/tests/localeRedirectPolicy.test.ts +173 -0
- package/tests/localisedUrls.test.ts +0 -141
- package/tests/paths.test.ts +24 -0
- package/tests/reactLanguageSync.test.tsx +20 -1
- package/tests/clientRedirectIntegration.test.tsx +0 -74
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { isBrowser, RuntimeContext } from '@modern-js/runtime';
|
|
2
|
+
import {
|
|
3
|
+
InternalRuntimeContext,
|
|
4
|
+
type TInternalRuntimeContext,
|
|
5
|
+
type TRuntimeContext,
|
|
6
|
+
} from '@modern-js/runtime/context';
|
|
7
|
+
import {
|
|
8
|
+
getRouterRuntimeState,
|
|
9
|
+
subscribeRouterRuntimeState,
|
|
10
|
+
} from '@modern-js/runtime-extensions/router-state';
|
|
11
|
+
import type React from 'react';
|
|
12
|
+
import { useCallback, useContext, useSyncExternalStore } from 'react';
|
|
13
|
+
|
|
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
|
+
export interface I18nRouterNavigationDependencies<
|
|
39
|
+
Adapter extends I18nRouterAdapterShape,
|
|
40
|
+
> {
|
|
41
|
+
I18nNavigationProvider: React.ComponentType<
|
|
42
|
+
React.PropsWithChildren<{ value: Adapter }>
|
|
43
|
+
>;
|
|
44
|
+
useNativeI18nRouterAdapter: () => Adapter;
|
|
45
|
+
}
|
|
46
|
+
|
|
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
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface I18nRouterAdapterShape {
|
|
58
|
+
framework?: string;
|
|
59
|
+
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;
|
|
72
|
+
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;
|
|
90
|
+
}
|
|
91
|
+
|
|
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
|
+
}
|
|
211
|
+
|
|
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
|
+
*/
|
|
282
|
+
export const createI18nRouterNavigation = <
|
|
283
|
+
Adapter extends I18nRouterAdapterShape,
|
|
284
|
+
>({
|
|
285
|
+
I18nNavigationProvider,
|
|
286
|
+
useNativeI18nRouterAdapter,
|
|
287
|
+
}: I18nRouterNavigationDependencies<Adapter>) => {
|
|
288
|
+
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;
|
|
296
|
+
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,
|
|
305
|
+
);
|
|
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(
|
|
336
|
+
(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(
|
|
344
|
+
internalContext,
|
|
345
|
+
update,
|
|
346
|
+
);
|
|
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
|
+
|
|
402
|
+
return () => {
|
|
403
|
+
for (const unsubscribe of unsubscribers) {
|
|
404
|
+
unsubscribe();
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
[contextRouter, framework, inReactRouter, internalContext],
|
|
409
|
+
);
|
|
410
|
+
const getSnapshot = useCallback(
|
|
411
|
+
() => getRouterSnapshot(internalContext, contextRouter),
|
|
412
|
+
[contextRouter, internalContext],
|
|
413
|
+
);
|
|
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
|
+
],
|
|
454
|
+
);
|
|
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
|
+
|
|
471
|
+
return {
|
|
472
|
+
framework,
|
|
473
|
+
hasRouter,
|
|
474
|
+
location,
|
|
475
|
+
navigate: hasRouter ? navigate : null,
|
|
476
|
+
Link,
|
|
477
|
+
params,
|
|
478
|
+
...(framework === 'tanstack'
|
|
479
|
+
? { createLinkProps: createTanstackLinkProps }
|
|
480
|
+
: {}),
|
|
481
|
+
} as Adapter;
|
|
482
|
+
};
|
|
483
|
+
|
|
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
|
+
const I18nRouterNavigationProvider = ({
|
|
517
|
+
children,
|
|
518
|
+
}: React.PropsWithChildren) => {
|
|
519
|
+
const value = useIntegratedRouterAdapter();
|
|
520
|
+
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
|
+
}
|
|
530
|
+
return (
|
|
531
|
+
<I18nNavigationProvider value={value}>{children}</I18nNavigationProvider>
|
|
532
|
+
);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
return { useIntegratedRouterAdapter, I18nRouterNavigationProvider };
|
|
536
|
+
};
|
|
@@ -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' };
|