@bleedingdev/modern-js-plugin-i18n 3.5.0-ultramodern.99 → 3.8.1-ultramodern.2
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/runtime/providerComposition.js +9 -10
- package/dist/cjs/runtime/routerAdapter.js +23 -10
- package/dist/esm/runtime/providerComposition.mjs +9 -10
- package/dist/esm/runtime/routerAdapter.mjs +24 -11
- package/dist/esm-node/runtime/providerComposition.mjs +9 -10
- package/dist/esm-node/runtime/routerAdapter.mjs +24 -11
- package/package.json +18 -17
- package/src/runtime/providerComposition.tsx +14 -13
- package/src/runtime/routerAdapter.tsx +81 -58
- package/tests/localisedUrls.test.ts +44 -25
- package/tests/reactI18nextRuntimeBoundary.test.ts +181 -31
- package/tests/routerAdapter.test.tsx +184 -0
- package/tests/fixtures/localised-routes.golden.json +0 -156
|
@@ -77,21 +77,20 @@ const createI18nRootWrapper = (options)=>(App)=>(props)=>{
|
|
|
77
77
|
forceUpdate,
|
|
78
78
|
synchronizeLanguage
|
|
79
79
|
]);
|
|
80
|
+
const I18nextProvider = getI18nextProvider();
|
|
81
|
+
const i18nextInstanceForProvider = (0, external_react_namespaceObject.useMemo)(()=>i18nInstance ? (0, instance_js_namespaceObject.getI18nextInstanceForProvider)(i18nInstance, lang) : void 0, [
|
|
82
|
+
i18nInstance,
|
|
83
|
+
lang
|
|
84
|
+
]);
|
|
80
85
|
const children = props.children;
|
|
81
86
|
let appContent = App ? /*#__PURE__*/ (0, jsx_runtime_namespaceObject.jsx)(App, {
|
|
82
87
|
...props,
|
|
83
88
|
children: children
|
|
84
89
|
}) : children;
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
appContent = /*#__PURE__*/ (0, jsx_runtime_namespaceObject.jsx)(I18nextProvider, {
|
|
90
|
-
i18n: i18nextInstanceForProvider,
|
|
91
|
-
children: appContent
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
90
|
+
if (I18nextProvider && i18nextInstanceForProvider) appContent = /*#__PURE__*/ (0, jsx_runtime_namespaceObject.jsx)(I18nextProvider, {
|
|
91
|
+
i18n: i18nextInstanceForProvider,
|
|
92
|
+
children: appContent
|
|
93
|
+
});
|
|
95
94
|
return /*#__PURE__*/ (0, jsx_runtime_namespaceObject.jsxs)(jsx_runtime_namespaceObject.Fragment, {
|
|
96
95
|
children: [
|
|
97
96
|
Boolean(htmlLangAttr) && /*#__PURE__*/ (0, jsx_runtime_namespaceObject.jsx)(head_namespaceObject.Helmet, {
|
|
@@ -32,7 +32,6 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
32
32
|
});
|
|
33
33
|
const runtime_namespaceObject = require("@modern-js/runtime");
|
|
34
34
|
const context_namespaceObject = require("@modern-js/runtime/context");
|
|
35
|
-
const router_namespaceObject = require("@modern-js/runtime/router");
|
|
36
35
|
const external_react_namespaceObject = require("react");
|
|
37
36
|
const normalizeUrlPart = (value, prefix)=>{
|
|
38
37
|
if ('string' != typeof value || !value) return '';
|
|
@@ -82,24 +81,33 @@ const getRouterParams = (internalContext, contextRouter)=>{
|
|
|
82
81
|
return params;
|
|
83
82
|
}, {});
|
|
84
83
|
};
|
|
84
|
+
const getRouterSnapshot = (internalContext, contextRouter)=>{
|
|
85
|
+
const location = getRouterStateLocation(internalContext, contextRouter);
|
|
86
|
+
const params = getRouterParams(internalContext, contextRouter);
|
|
87
|
+
return JSON.stringify([
|
|
88
|
+
location?.pathname ?? '',
|
|
89
|
+
location?.search ?? '',
|
|
90
|
+
location?.hash ?? '',
|
|
91
|
+
Object.entries(params).sort(([left], [right])=>left.localeCompare(right))
|
|
92
|
+
]);
|
|
93
|
+
};
|
|
85
94
|
const useI18nRouterAdapter = ()=>{
|
|
86
95
|
const runtimeContext = (0, external_react_namespaceObject.useContext)(runtime_namespaceObject.RuntimeContext);
|
|
87
96
|
const internalContext = (0, external_react_namespaceObject.useContext)(context_namespaceObject.InternalRuntimeContext);
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
97
|
+
const routerApi = internalContext.router || runtimeContext.router;
|
|
98
|
+
const inReactRouter = routerApi?.useInRouterContext?.() ?? false;
|
|
99
|
+
const reactRouterNavigate = inReactRouter ? routerApi?.useNavigate?.() ?? null : null;
|
|
100
|
+
const reactRouterLocation = inReactRouter ? routerApi?.useLocation?.() ?? null : null;
|
|
101
|
+
const reactRouterParams = inReactRouter ? routerApi?.useParams?.() ?? {} : {};
|
|
92
102
|
const framework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
93
103
|
const contextUseRouter = inReactRouter || 'tanstack' !== framework ? void 0 : internalContext.router?.useRouter || runtimeContext.router?.useRouter;
|
|
94
104
|
const contextRouter = contextUseRouter ? contextUseRouter({
|
|
95
105
|
warn: false
|
|
96
106
|
}) : null;
|
|
97
|
-
const [, setRouterVersion] = (0, external_react_namespaceObject.useState)(0);
|
|
98
107
|
const hasRouter = 'tanstack' === framework || 'react-router' === framework || Boolean(reactRouterNavigate);
|
|
99
|
-
(0, external_react_namespaceObject.
|
|
108
|
+
const subscribeToRouter = (0, external_react_namespaceObject.useCallback)((update)=>{
|
|
100
109
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
101
|
-
if (!router) return;
|
|
102
|
-
const update = ()=>setRouterVersion((version)=>version + 1);
|
|
110
|
+
if (!router) return ()=>void 0;
|
|
103
111
|
const unsubscribers = [];
|
|
104
112
|
if ('react-router' === framework && !inReactRouter && 'function' == typeof router.subscribe) {
|
|
105
113
|
const subscribe = router.subscribe;
|
|
@@ -129,6 +137,11 @@ const useI18nRouterAdapter = ()=>{
|
|
|
129
137
|
inReactRouter,
|
|
130
138
|
internalContext
|
|
131
139
|
]);
|
|
140
|
+
const getSnapshot = (0, external_react_namespaceObject.useCallback)(()=>getRouterSnapshot(internalContext, contextRouter), [
|
|
141
|
+
contextRouter,
|
|
142
|
+
internalContext
|
|
143
|
+
]);
|
|
144
|
+
(0, external_react_namespaceObject.useSyncExternalStore)(subscribeToRouter, getSnapshot, getSnapshot);
|
|
132
145
|
const navigate = (0, external_react_namespaceObject.useCallback)((href, options)=>{
|
|
133
146
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
134
147
|
const activeFramework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
@@ -156,7 +169,7 @@ const useI18nRouterAdapter = ()=>{
|
|
|
156
169
|
]);
|
|
157
170
|
const location = (reactRouterLocation ? normalizeLocation(reactRouterLocation) : getRouterStateLocation(internalContext, contextRouter)) || getWindowLocation();
|
|
158
171
|
const params = inReactRouter ? reactRouterParams : getRouterParams(internalContext, contextRouter);
|
|
159
|
-
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ?
|
|
172
|
+
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ? routerApi?.Link ?? null : null;
|
|
160
173
|
return {
|
|
161
174
|
framework,
|
|
162
175
|
hasRouter,
|
|
@@ -45,21 +45,20 @@ const createI18nRootWrapper = (options)=>(App)=>(props)=>{
|
|
|
45
45
|
forceUpdate,
|
|
46
46
|
synchronizeLanguage
|
|
47
47
|
]);
|
|
48
|
+
const I18nextProvider = getI18nextProvider();
|
|
49
|
+
const i18nextInstanceForProvider = useMemo(()=>i18nInstance ? getI18nextInstanceForProvider(i18nInstance, lang) : void 0, [
|
|
50
|
+
i18nInstance,
|
|
51
|
+
lang
|
|
52
|
+
]);
|
|
48
53
|
const children = props.children;
|
|
49
54
|
let appContent = App ? /*#__PURE__*/ jsx(App, {
|
|
50
55
|
...props,
|
|
51
56
|
children: children
|
|
52
57
|
}) : children;
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
appContent = /*#__PURE__*/ jsx(I18nextProvider, {
|
|
58
|
-
i18n: i18nextInstanceForProvider,
|
|
59
|
-
children: appContent
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
}
|
|
58
|
+
if (I18nextProvider && i18nextInstanceForProvider) appContent = /*#__PURE__*/ jsx(I18nextProvider, {
|
|
59
|
+
i18n: i18nextInstanceForProvider,
|
|
60
|
+
children: appContent
|
|
61
|
+
});
|
|
63
62
|
return /*#__PURE__*/ jsxs(Fragment, {
|
|
64
63
|
children: [
|
|
65
64
|
Boolean(htmlLangAttr) && /*#__PURE__*/ jsx(Helmet, {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { RuntimeContext, isBrowser } from "@modern-js/runtime";
|
|
2
2
|
import { InternalRuntimeContext, getRouterRuntimeState } from "@modern-js/runtime/context";
|
|
3
|
-
import {
|
|
4
|
-
import { useCallback, useContext, useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useContext, useSyncExternalStore } from "react";
|
|
5
4
|
const normalizeUrlPart = (value, prefix)=>{
|
|
6
5
|
if ('string' != typeof value || !value) return '';
|
|
7
6
|
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
@@ -50,24 +49,33 @@ const getRouterParams = (internalContext, contextRouter)=>{
|
|
|
50
49
|
return params;
|
|
51
50
|
}, {});
|
|
52
51
|
};
|
|
52
|
+
const getRouterSnapshot = (internalContext, contextRouter)=>{
|
|
53
|
+
const location = getRouterStateLocation(internalContext, contextRouter);
|
|
54
|
+
const params = getRouterParams(internalContext, contextRouter);
|
|
55
|
+
return JSON.stringify([
|
|
56
|
+
location?.pathname ?? '',
|
|
57
|
+
location?.search ?? '',
|
|
58
|
+
location?.hash ?? '',
|
|
59
|
+
Object.entries(params).sort(([left], [right])=>left.localeCompare(right))
|
|
60
|
+
]);
|
|
61
|
+
};
|
|
53
62
|
const useI18nRouterAdapter = ()=>{
|
|
54
63
|
const runtimeContext = useContext(RuntimeContext);
|
|
55
64
|
const internalContext = useContext(InternalRuntimeContext);
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
65
|
+
const routerApi = internalContext.router || runtimeContext.router;
|
|
66
|
+
const inReactRouter = routerApi?.useInRouterContext?.() ?? false;
|
|
67
|
+
const reactRouterNavigate = inReactRouter ? routerApi?.useNavigate?.() ?? null : null;
|
|
68
|
+
const reactRouterLocation = inReactRouter ? routerApi?.useLocation?.() ?? null : null;
|
|
69
|
+
const reactRouterParams = inReactRouter ? routerApi?.useParams?.() ?? {} : {};
|
|
60
70
|
const framework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
61
71
|
const contextUseRouter = inReactRouter || 'tanstack' !== framework ? void 0 : internalContext.router?.useRouter || runtimeContext.router?.useRouter;
|
|
62
72
|
const contextRouter = contextUseRouter ? contextUseRouter({
|
|
63
73
|
warn: false
|
|
64
74
|
}) : null;
|
|
65
|
-
const [, setRouterVersion] = useState(0);
|
|
66
75
|
const hasRouter = 'tanstack' === framework || 'react-router' === framework || Boolean(reactRouterNavigate);
|
|
67
|
-
|
|
76
|
+
const subscribeToRouter = useCallback((update)=>{
|
|
68
77
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
69
|
-
if (!router) return;
|
|
70
|
-
const update = ()=>setRouterVersion((version)=>version + 1);
|
|
78
|
+
if (!router) return ()=>void 0;
|
|
71
79
|
const unsubscribers = [];
|
|
72
80
|
if ('react-router' === framework && !inReactRouter && 'function' == typeof router.subscribe) {
|
|
73
81
|
const subscribe = router.subscribe;
|
|
@@ -97,6 +105,11 @@ const useI18nRouterAdapter = ()=>{
|
|
|
97
105
|
inReactRouter,
|
|
98
106
|
internalContext
|
|
99
107
|
]);
|
|
108
|
+
const getSnapshot = useCallback(()=>getRouterSnapshot(internalContext, contextRouter), [
|
|
109
|
+
contextRouter,
|
|
110
|
+
internalContext
|
|
111
|
+
]);
|
|
112
|
+
useSyncExternalStore(subscribeToRouter, getSnapshot, getSnapshot);
|
|
100
113
|
const navigate = useCallback((href, options)=>{
|
|
101
114
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
102
115
|
const activeFramework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
@@ -124,7 +137,7 @@ const useI18nRouterAdapter = ()=>{
|
|
|
124
137
|
]);
|
|
125
138
|
const location = (reactRouterLocation ? normalizeLocation(reactRouterLocation) : getRouterStateLocation(internalContext, contextRouter)) || getWindowLocation();
|
|
126
139
|
const params = inReactRouter ? reactRouterParams : getRouterParams(internalContext, contextRouter);
|
|
127
|
-
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ?
|
|
140
|
+
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ? routerApi?.Link ?? null : null;
|
|
128
141
|
return {
|
|
129
142
|
framework,
|
|
130
143
|
hasRouter,
|
|
@@ -46,21 +46,20 @@ const createI18nRootWrapper = (options)=>(App)=>(props)=>{
|
|
|
46
46
|
forceUpdate,
|
|
47
47
|
synchronizeLanguage
|
|
48
48
|
]);
|
|
49
|
+
const I18nextProvider = getI18nextProvider();
|
|
50
|
+
const i18nextInstanceForProvider = useMemo(()=>i18nInstance ? getI18nextInstanceForProvider(i18nInstance, lang) : void 0, [
|
|
51
|
+
i18nInstance,
|
|
52
|
+
lang
|
|
53
|
+
]);
|
|
49
54
|
const children = props.children;
|
|
50
55
|
let appContent = App ? /*#__PURE__*/ jsx(App, {
|
|
51
56
|
...props,
|
|
52
57
|
children: children
|
|
53
58
|
}) : children;
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
appContent = /*#__PURE__*/ jsx(I18nextProvider, {
|
|
59
|
-
i18n: i18nextInstanceForProvider,
|
|
60
|
-
children: appContent
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
59
|
+
if (I18nextProvider && i18nextInstanceForProvider) appContent = /*#__PURE__*/ jsx(I18nextProvider, {
|
|
60
|
+
i18n: i18nextInstanceForProvider,
|
|
61
|
+
children: appContent
|
|
62
|
+
});
|
|
64
63
|
return /*#__PURE__*/ jsxs(Fragment, {
|
|
65
64
|
children: [
|
|
66
65
|
Boolean(htmlLangAttr) && /*#__PURE__*/ jsx(Helmet, {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import "node:module";
|
|
2
2
|
import { RuntimeContext, isBrowser } from "@modern-js/runtime";
|
|
3
3
|
import { InternalRuntimeContext, getRouterRuntimeState } from "@modern-js/runtime/context";
|
|
4
|
-
import {
|
|
5
|
-
import { useCallback, useContext, useEffect, useState } from "react";
|
|
4
|
+
import { useCallback, useContext, useSyncExternalStore } from "react";
|
|
6
5
|
const normalizeUrlPart = (value, prefix)=>{
|
|
7
6
|
if ('string' != typeof value || !value) return '';
|
|
8
7
|
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
@@ -51,24 +50,33 @@ const getRouterParams = (internalContext, contextRouter)=>{
|
|
|
51
50
|
return params;
|
|
52
51
|
}, {});
|
|
53
52
|
};
|
|
53
|
+
const getRouterSnapshot = (internalContext, contextRouter)=>{
|
|
54
|
+
const location = getRouterStateLocation(internalContext, contextRouter);
|
|
55
|
+
const params = getRouterParams(internalContext, contextRouter);
|
|
56
|
+
return JSON.stringify([
|
|
57
|
+
location?.pathname ?? '',
|
|
58
|
+
location?.search ?? '',
|
|
59
|
+
location?.hash ?? '',
|
|
60
|
+
Object.entries(params).sort(([left], [right])=>left.localeCompare(right))
|
|
61
|
+
]);
|
|
62
|
+
};
|
|
54
63
|
const useI18nRouterAdapter = ()=>{
|
|
55
64
|
const runtimeContext = useContext(RuntimeContext);
|
|
56
65
|
const internalContext = useContext(InternalRuntimeContext);
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
66
|
+
const routerApi = internalContext.router || runtimeContext.router;
|
|
67
|
+
const inReactRouter = routerApi?.useInRouterContext?.() ?? false;
|
|
68
|
+
const reactRouterNavigate = inReactRouter ? routerApi?.useNavigate?.() ?? null : null;
|
|
69
|
+
const reactRouterLocation = inReactRouter ? routerApi?.useLocation?.() ?? null : null;
|
|
70
|
+
const reactRouterParams = inReactRouter ? routerApi?.useParams?.() ?? {} : {};
|
|
61
71
|
const framework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
62
72
|
const contextUseRouter = inReactRouter || 'tanstack' !== framework ? void 0 : internalContext.router?.useRouter || runtimeContext.router?.useRouter;
|
|
63
73
|
const contextRouter = contextUseRouter ? contextUseRouter({
|
|
64
74
|
warn: false
|
|
65
75
|
}) : null;
|
|
66
|
-
const [, setRouterVersion] = useState(0);
|
|
67
76
|
const hasRouter = 'tanstack' === framework || 'react-router' === framework || Boolean(reactRouterNavigate);
|
|
68
|
-
|
|
77
|
+
const subscribeToRouter = useCallback((update)=>{
|
|
69
78
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
70
|
-
if (!router) return;
|
|
71
|
-
const update = ()=>setRouterVersion((version)=>version + 1);
|
|
79
|
+
if (!router) return ()=>void 0;
|
|
72
80
|
const unsubscribers = [];
|
|
73
81
|
if ('react-router' === framework && !inReactRouter && 'function' == typeof router.subscribe) {
|
|
74
82
|
const subscribe = router.subscribe;
|
|
@@ -98,6 +106,11 @@ const useI18nRouterAdapter = ()=>{
|
|
|
98
106
|
inReactRouter,
|
|
99
107
|
internalContext
|
|
100
108
|
]);
|
|
109
|
+
const getSnapshot = useCallback(()=>getRouterSnapshot(internalContext, contextRouter), [
|
|
110
|
+
contextRouter,
|
|
111
|
+
internalContext
|
|
112
|
+
]);
|
|
113
|
+
useSyncExternalStore(subscribeToRouter, getSnapshot, getSnapshot);
|
|
101
114
|
const navigate = useCallback((href, options)=>{
|
|
102
115
|
const router = getRouterInstance(internalContext, contextRouter);
|
|
103
116
|
const activeFramework = getRouterFramework(runtimeContext, internalContext, inReactRouter);
|
|
@@ -125,7 +138,7 @@ const useI18nRouterAdapter = ()=>{
|
|
|
125
138
|
]);
|
|
126
139
|
const location = (reactRouterLocation ? normalizeLocation(reactRouterLocation) : getRouterStateLocation(internalContext, contextRouter)) || getWindowLocation();
|
|
127
140
|
const params = inReactRouter ? reactRouterParams : getRouterParams(internalContext, contextRouter);
|
|
128
|
-
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ?
|
|
141
|
+
const Link = 'tanstack' === framework ? internalContext.router?.Link || runtimeContext.router?.Link || null : 'react-router' === framework || inReactRouter ? routerApi?.Link ?? null : null;
|
|
129
142
|
return {
|
|
130
143
|
framework,
|
|
131
144
|
hasRouter,
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"modern",
|
|
18
18
|
"modern.js"
|
|
19
19
|
],
|
|
20
|
-
"version": "3.
|
|
20
|
+
"version": "3.8.1-ultramodern.2",
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
@@ -91,12 +91,12 @@
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.
|
|
95
|
-
"@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.
|
|
96
|
-
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.
|
|
97
|
-
"@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.
|
|
98
|
-
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.
|
|
99
|
-
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.
|
|
94
|
+
"@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.8.1-ultramodern.2",
|
|
95
|
+
"@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.8.1-ultramodern.2",
|
|
96
|
+
"@modern-js/server-core": "npm:@bleedingdev/modern-js-server-core@3.8.1-ultramodern.2",
|
|
97
|
+
"@modern-js/server-runtime": "npm:@bleedingdev/modern-js-server-runtime@3.8.1-ultramodern.2",
|
|
98
|
+
"@modern-js/types": "npm:@bleedingdev/modern-js-types@3.8.1-ultramodern.2",
|
|
99
|
+
"@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.8.1-ultramodern.2",
|
|
100
100
|
"@swc/helpers": "^0.5.23",
|
|
101
101
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
102
102
|
"i18next-chained-backend": "^5.0.5",
|
|
@@ -105,11 +105,11 @@
|
|
|
105
105
|
"i18next-http-middleware": "^3.9.7"
|
|
106
106
|
},
|
|
107
107
|
"peerDependencies": {
|
|
108
|
-
"@modern-js/runtime": "3.
|
|
109
|
-
"i18next": ">=
|
|
110
|
-
"react": "
|
|
111
|
-
"react-dom": "
|
|
112
|
-
"react-i18next": "
|
|
108
|
+
"@modern-js/runtime": "3.8.1-ultramodern.2",
|
|
109
|
+
"i18next": ">=25.7.4",
|
|
110
|
+
"react": ">=18",
|
|
111
|
+
"react-dom": ">=18",
|
|
112
|
+
"react-i18next": ">=15.7.4"
|
|
113
113
|
},
|
|
114
114
|
"peerDependenciesMeta": {
|
|
115
115
|
"i18next": {
|
|
@@ -120,14 +120,15 @@
|
|
|
120
120
|
}
|
|
121
121
|
},
|
|
122
122
|
"devDependencies": {
|
|
123
|
-
"@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.
|
|
124
|
-
"@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.
|
|
123
|
+
"@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.8.1-ultramodern.2",
|
|
124
|
+
"@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.8.1-ultramodern.2",
|
|
125
125
|
"@rslib/core": "0.23.2",
|
|
126
|
-
"@types/node": "^26.
|
|
126
|
+
"@types/node": "^26.2.0",
|
|
127
127
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
128
|
+
"esbuild": "^0.28.1",
|
|
128
129
|
"i18next": "26.3.6",
|
|
129
|
-
"react": "^19.2.
|
|
130
|
-
"react-dom": "^19.2.
|
|
130
|
+
"react": "^19.2.8",
|
|
131
|
+
"react-dom": "^19.2.8",
|
|
131
132
|
"react-i18next": "17.0.9",
|
|
132
133
|
"ts-node": "^10.9.2",
|
|
133
134
|
"typescript": "^7.0.2"
|
|
@@ -113,6 +113,14 @@ export const createI18nRootWrapper =
|
|
|
113
113
|
synchronizeLanguage,
|
|
114
114
|
],
|
|
115
115
|
);
|
|
116
|
+
const I18nextProvider = getI18nextProvider();
|
|
117
|
+
const i18nextInstanceForProvider = useMemo(
|
|
118
|
+
() =>
|
|
119
|
+
i18nInstance
|
|
120
|
+
? getI18nextInstanceForProvider(i18nInstance, lang)
|
|
121
|
+
: undefined,
|
|
122
|
+
[i18nInstance, lang],
|
|
123
|
+
);
|
|
116
124
|
|
|
117
125
|
const children = (props as React.PropsWithChildren).children;
|
|
118
126
|
let appContent: React.ReactNode = App ? (
|
|
@@ -121,19 +129,12 @@ export const createI18nRootWrapper =
|
|
|
121
129
|
children
|
|
122
130
|
);
|
|
123
131
|
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
131
|
-
appContent = (
|
|
132
|
-
<I18nextProvider i18n={i18nextInstanceForProvider}>
|
|
133
|
-
{appContent}
|
|
134
|
-
</I18nextProvider>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
132
|
+
if (I18nextProvider && i18nextInstanceForProvider) {
|
|
133
|
+
appContent = (
|
|
134
|
+
<I18nextProvider i18n={i18nextInstanceForProvider}>
|
|
135
|
+
{appContent}
|
|
136
|
+
</I18nextProvider>
|
|
137
|
+
);
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
return (
|
|
@@ -5,15 +5,8 @@ import {
|
|
|
5
5
|
type TInternalRuntimeContext,
|
|
6
6
|
type TRuntimeContext,
|
|
7
7
|
} from '@modern-js/runtime/context';
|
|
8
|
-
import {
|
|
9
|
-
Link as ReactRouterLink,
|
|
10
|
-
useInRouterContext,
|
|
11
|
-
useLocation as useReactRouterLocation,
|
|
12
|
-
useNavigate as useReactRouterNavigate,
|
|
13
|
-
useParams as useReactRouterParams,
|
|
14
|
-
} from '@modern-js/runtime/router';
|
|
15
8
|
import type React from 'react';
|
|
16
|
-
import { useCallback, useContext,
|
|
9
|
+
import { useCallback, useContext, useSyncExternalStore } from 'react';
|
|
17
10
|
|
|
18
11
|
type I18nRouterFramework = 'react-router' | 'tanstack' | string;
|
|
19
12
|
|
|
@@ -50,8 +43,11 @@ interface I18nRouterAdapter {
|
|
|
50
43
|
|
|
51
44
|
type RuntimeContextWithRouter = TRuntimeContext & {
|
|
52
45
|
router?: {
|
|
46
|
+
useInRouterContext?: () => boolean;
|
|
53
47
|
useRouter?: (options?: { warn?: boolean }) => unknown;
|
|
54
48
|
useLocation?: () => unknown;
|
|
49
|
+
useNavigate?: () => I18nRouterNavigate;
|
|
50
|
+
useParams?: () => Record<string, string>;
|
|
55
51
|
useHref?: () => unknown;
|
|
56
52
|
Link?: I18nRouterLink;
|
|
57
53
|
};
|
|
@@ -205,15 +201,36 @@ const getRouterParams = (
|
|
|
205
201
|
}, {});
|
|
206
202
|
};
|
|
207
203
|
|
|
204
|
+
const getRouterSnapshot = (
|
|
205
|
+
internalContext: InternalRuntimeContextWithRouter,
|
|
206
|
+
contextRouter?: RouterInstance | null,
|
|
207
|
+
) => {
|
|
208
|
+
const location = getRouterStateLocation(internalContext, contextRouter);
|
|
209
|
+
const params = getRouterParams(internalContext, contextRouter);
|
|
210
|
+
return JSON.stringify([
|
|
211
|
+
location?.pathname ?? '',
|
|
212
|
+
location?.search ?? '',
|
|
213
|
+
location?.hash ?? '',
|
|
214
|
+
Object.entries(params).sort(([left], [right]) => left.localeCompare(right)),
|
|
215
|
+
]);
|
|
216
|
+
};
|
|
217
|
+
|
|
208
218
|
export const useI18nRouterAdapter = (): I18nRouterAdapter => {
|
|
209
219
|
const runtimeContext = useContext(RuntimeContext) as RuntimeContextWithRouter;
|
|
210
220
|
const internalContext = useContext(
|
|
211
221
|
InternalRuntimeContext,
|
|
212
222
|
) as InternalRuntimeContextWithRouter;
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
223
|
+
const routerApi = internalContext.router || runtimeContext.router;
|
|
224
|
+
const inReactRouter = routerApi?.useInRouterContext?.() ?? false;
|
|
225
|
+
const reactRouterNavigate = inReactRouter
|
|
226
|
+
? (routerApi?.useNavigate?.() ?? null)
|
|
227
|
+
: null;
|
|
228
|
+
const reactRouterLocation = inReactRouter
|
|
229
|
+
? (routerApi?.useLocation?.() ?? null)
|
|
230
|
+
: null;
|
|
231
|
+
const reactRouterParams = inReactRouter
|
|
232
|
+
? (routerApi?.useParams?.() ?? {})
|
|
233
|
+
: {};
|
|
217
234
|
const framework = getRouterFramework(
|
|
218
235
|
runtimeContext,
|
|
219
236
|
internalContext,
|
|
@@ -226,66 +243,72 @@ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
|
|
|
226
243
|
const contextRouter = contextUseRouter
|
|
227
244
|
? (contextUseRouter({ warn: false }) as RouterInstance | null)
|
|
228
245
|
: null;
|
|
229
|
-
const [, setRouterVersion] = useState(0);
|
|
230
246
|
const hasRouter =
|
|
231
247
|
framework === 'tanstack' ||
|
|
232
248
|
framework === 'react-router' ||
|
|
233
249
|
Boolean(reactRouterNavigate);
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const update = () => setRouterVersion(version => version + 1);
|
|
242
|
-
const unsubscribers: Array<() => void> = [];
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
framework === 'react-router' &&
|
|
246
|
-
!inReactRouter &&
|
|
247
|
-
typeof router.subscribe === 'function'
|
|
248
|
-
) {
|
|
249
|
-
const subscribe = router.subscribe as (
|
|
250
|
-
this: RouterInstance,
|
|
251
|
-
listener: () => void,
|
|
252
|
-
) => () => void;
|
|
253
|
-
const unsubscribe = subscribe.call(router, update);
|
|
254
|
-
if (typeof unsubscribe === 'function') {
|
|
255
|
-
unsubscribers.push(unsubscribe);
|
|
251
|
+
const subscribeToRouter = useCallback(
|
|
252
|
+
(update: () => void) => {
|
|
253
|
+
const router = getRouterInstance(internalContext, contextRouter);
|
|
254
|
+
if (!router) {
|
|
255
|
+
return () => undefined;
|
|
256
256
|
}
|
|
257
|
-
}
|
|
258
257
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
258
|
+
const unsubscribers: Array<() => void> = [];
|
|
259
|
+
|
|
260
|
+
if (
|
|
261
|
+
framework === 'react-router' &&
|
|
262
|
+
!inReactRouter &&
|
|
263
|
+
typeof router.subscribe === 'function'
|
|
264
|
+
) {
|
|
265
|
+
const subscribe = router.subscribe as (
|
|
266
|
+
this: RouterInstance,
|
|
267
|
+
listener: () => void,
|
|
268
|
+
) => () => void;
|
|
269
|
+
const unsubscribe = subscribe.call(router, update);
|
|
270
|
+
if (typeof unsubscribe === 'function') {
|
|
271
|
+
unsubscribers.push(unsubscribe);
|
|
272
|
+
}
|
|
266
273
|
}
|
|
267
|
-
}
|
|
268
274
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
) => () => void;
|
|
275
|
-
for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
|
|
276
|
-
const unsubscribe = subscribe.call(router, eventType, update);
|
|
275
|
+
if (
|
|
276
|
+
framework === 'tanstack' &&
|
|
277
|
+
typeof router.stores?.location?.subscribe === 'function'
|
|
278
|
+
) {
|
|
279
|
+
const unsubscribe = router.stores.location.subscribe(update);
|
|
277
280
|
if (typeof unsubscribe === 'function') {
|
|
278
281
|
unsubscribers.push(unsubscribe);
|
|
279
282
|
}
|
|
280
283
|
}
|
|
281
|
-
}
|
|
282
284
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
285
|
+
if (framework === 'tanstack' && typeof router.subscribe === 'function') {
|
|
286
|
+
const subscribe = router.subscribe as (
|
|
287
|
+
this: RouterInstance,
|
|
288
|
+
eventType: string,
|
|
289
|
+
listener: () => void,
|
|
290
|
+
) => () => void;
|
|
291
|
+
for (const eventType of ['onBeforeNavigate', 'onBeforeLoad']) {
|
|
292
|
+
const unsubscribe = subscribe.call(router, eventType, update);
|
|
293
|
+
if (typeof unsubscribe === 'function') {
|
|
294
|
+
unsubscribers.push(unsubscribe);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
286
297
|
}
|
|
287
|
-
|
|
288
|
-
|
|
298
|
+
|
|
299
|
+
return () => {
|
|
300
|
+
for (const unsubscribe of unsubscribers) {
|
|
301
|
+
unsubscribe();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
[contextRouter, framework, inReactRouter, internalContext],
|
|
306
|
+
);
|
|
307
|
+
const getSnapshot = useCallback(
|
|
308
|
+
() => getRouterSnapshot(internalContext, contextRouter),
|
|
309
|
+
[contextRouter, internalContext],
|
|
310
|
+
);
|
|
311
|
+
useSyncExternalStore(subscribeToRouter, getSnapshot, getSnapshot);
|
|
289
312
|
|
|
290
313
|
const navigate = useCallback<I18nRouterNavigate>(
|
|
291
314
|
(href, options) => {
|
|
@@ -339,7 +362,7 @@ export const useI18nRouterAdapter = (): I18nRouterAdapter => {
|
|
|
339
362
|
framework === 'tanstack'
|
|
340
363
|
? internalContext.router?.Link || runtimeContext.router?.Link || null
|
|
341
364
|
: framework === 'react-router' || inReactRouter
|
|
342
|
-
? (
|
|
365
|
+
? (routerApi?.Link ?? null)
|
|
343
366
|
: null;
|
|
344
367
|
|
|
345
368
|
return {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import type { NestedRouteForCli } from '@modern-js/types';
|
|
4
2
|
import { describe, expect, test } from '@rstest/core';
|
|
5
3
|
import { i18nPlugin as i18nCliPlugin } from '../src/cli';
|
|
@@ -19,12 +17,6 @@ import {
|
|
|
19
17
|
validateLocalisedUrls,
|
|
20
18
|
} from '../src/shared/localisedUrls';
|
|
21
19
|
|
|
22
|
-
const localisedRoutesGoldenPath = path.join(
|
|
23
|
-
__dirname,
|
|
24
|
-
'fixtures',
|
|
25
|
-
'localised-routes.golden.json',
|
|
26
|
-
);
|
|
27
|
-
|
|
28
20
|
const createRoute = (
|
|
29
21
|
path: string,
|
|
30
22
|
children?: NestedRouteForCli[],
|
|
@@ -45,9 +37,6 @@ const createRequestContext = (pathname: string) =>
|
|
|
45
37
|
},
|
|
46
38
|
}) as any;
|
|
47
39
|
|
|
48
|
-
const serializeRoutesForGolden = (routes: unknown): string =>
|
|
49
|
-
`${JSON.stringify(routes, null, 2)}\n`;
|
|
50
|
-
|
|
51
40
|
describe('resolveLocalisedUrlsConfig', () => {
|
|
52
41
|
test('is opt-in: only a non-empty map enables the feature', () => {
|
|
53
42
|
const map = { '/about': { en: '/about', cs: '/o-nas' } };
|
|
@@ -119,7 +108,7 @@ describe('cli modifyFileSystemRoutes', () => {
|
|
|
119
108
|
return modifyRoutes!;
|
|
120
109
|
};
|
|
121
110
|
|
|
122
|
-
const
|
|
111
|
+
const generateLocalisedRoutes = () => {
|
|
123
112
|
const modifyRoutes = setupModifyRoutes({
|
|
124
113
|
localePathRedirect: true,
|
|
125
114
|
languages: ['en', 'cs', 'de'],
|
|
@@ -164,23 +153,53 @@ describe('cli modifyFileSystemRoutes', () => {
|
|
|
164
153
|
routes,
|
|
165
154
|
});
|
|
166
155
|
|
|
167
|
-
return
|
|
156
|
+
return result.routes;
|
|
168
157
|
};
|
|
169
158
|
|
|
170
|
-
test('
|
|
171
|
-
const localisedRoutes =
|
|
172
|
-
|
|
159
|
+
test('creates deterministic localized aliases with canonical route identity', () => {
|
|
160
|
+
const localisedRoutes = generateLocalisedRoutes();
|
|
161
|
+
expect(generateLocalisedRoutes()).toEqual(localisedRoutes);
|
|
173
162
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
const [localeLayout] = localisedRoutes;
|
|
164
|
+
expect(localeLayout.children?.map(route => route.path)).toEqual([
|
|
165
|
+
'about',
|
|
166
|
+
'o-nas',
|
|
167
|
+
'ueber-uns',
|
|
168
|
+
'products',
|
|
169
|
+
'produkty',
|
|
170
|
+
'produkte',
|
|
171
|
+
'docs',
|
|
172
|
+
'dokumenty',
|
|
173
|
+
'dokumente',
|
|
174
|
+
]);
|
|
175
|
+
expect(localeLayout.children?.slice(0, 3)).toMatchObject([
|
|
176
|
+
{
|
|
177
|
+
_component: 'about.tsx',
|
|
178
|
+
modernCanonicalPath: '/about',
|
|
179
|
+
path: 'about',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
_component: 'about.tsx',
|
|
183
|
+
modernCanonicalPath: '/about',
|
|
184
|
+
path: 'o-nas',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
_component: 'about.tsx',
|
|
188
|
+
modernCanonicalPath: '/about',
|
|
189
|
+
path: 'ueber-uns',
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
for (const productRoute of localeLayout.children?.slice(3, 6) ?? []) {
|
|
193
|
+
expect(productRoute).toMatchObject({
|
|
194
|
+
_component: 'products.tsx',
|
|
195
|
+
modernCanonicalPath: '/products',
|
|
196
|
+
});
|
|
197
|
+
expect(productRoute.children?.[0]).toMatchObject({
|
|
198
|
+
_component: ':slug.tsx',
|
|
199
|
+
modernCanonicalPath: '/products/:slug',
|
|
200
|
+
path: ':slug',
|
|
201
|
+
});
|
|
179
202
|
}
|
|
180
|
-
|
|
181
|
-
await expect(readFile(localisedRoutesGoldenPath, 'utf8')).resolves.toBe(
|
|
182
|
-
localisedRoutes,
|
|
183
|
-
);
|
|
184
203
|
});
|
|
185
204
|
|
|
186
205
|
test('upstream-style configs without a map keep routes untouched', () => {
|
|
@@ -1,48 +1,198 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import { describe, expect, test } from '@rstest/core';
|
|
6
|
+
import { build } from 'esbuild';
|
|
7
|
+
import i18next from 'i18next';
|
|
8
|
+
import { createElement } from 'react';
|
|
9
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
10
|
+
import { i18nPlugin as noReactI18nextPlugin } from '../src/runtime/no-react-i18next';
|
|
5
11
|
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
12
|
const runtimeRoot = resolve(__dirname, '../src/runtime');
|
|
8
13
|
|
|
9
|
-
const readRuntimeSource = (file: string) =>
|
|
10
|
-
readFileSync(resolve(runtimeRoot, file), 'utf8').replace(/\r\n/gu, '\n');
|
|
11
|
-
|
|
12
14
|
describe('react-i18next runtime boundary', () => {
|
|
13
|
-
test('
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
test('bundles the disabled runtime entry when react-i18next is unavailable', async () => {
|
|
16
|
+
await expect(
|
|
17
|
+
build({
|
|
18
|
+
bundle: true,
|
|
19
|
+
entryPoints: [resolve(runtimeRoot, 'no-react-i18next.tsx')],
|
|
20
|
+
format: 'esm',
|
|
21
|
+
packages: 'external',
|
|
22
|
+
platform: 'node',
|
|
23
|
+
plugins: [
|
|
24
|
+
{
|
|
25
|
+
name: 'reject-react-i18next',
|
|
26
|
+
setup(buildApi) {
|
|
27
|
+
buildApi.onResolve({ filter: /^react-i18next$/ }, () => {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'disabled runtime entry reached optional react-i18next',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
write: false,
|
|
36
|
+
}),
|
|
37
|
+
).resolves.toBeDefined();
|
|
20
38
|
});
|
|
21
39
|
|
|
22
|
-
test('
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
});
|
|
25
64
|
|
|
26
|
-
|
|
27
|
-
expect(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
65
|
+
test('gets router capabilities from the selected runtime provider', async () => {
|
|
66
|
+
await expect(
|
|
67
|
+
build({
|
|
68
|
+
bundle: true,
|
|
69
|
+
entryPoints: [resolve(runtimeRoot, 'routerAdapter.tsx')],
|
|
70
|
+
format: 'esm',
|
|
71
|
+
packages: 'external',
|
|
72
|
+
platform: 'neutral',
|
|
73
|
+
plugins: [
|
|
74
|
+
{
|
|
75
|
+
name: 'reject-direct-router-provider',
|
|
76
|
+
setup(buildApi) {
|
|
77
|
+
buildApi.onResolve(
|
|
78
|
+
{ filter: /^@modern-js\/runtime\/router$/ },
|
|
79
|
+
() => {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'The i18n adapter loaded the React Router provider directly.',
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
write: false,
|
|
89
|
+
}),
|
|
90
|
+
).resolves.toBeDefined();
|
|
32
91
|
});
|
|
33
92
|
|
|
34
|
-
test('keeps
|
|
35
|
-
const
|
|
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
|
+
);
|
|
36
123
|
|
|
37
|
-
expect(
|
|
38
|
-
|
|
124
|
+
expect(dynamicEntries).toEqual(
|
|
125
|
+
expect.arrayContaining(['middleware.ts', 'utils.ts']),
|
|
39
126
|
);
|
|
40
|
-
expect(context).toContain('globalStore[modernI18nContextKey] ??=');
|
|
41
127
|
});
|
|
42
128
|
|
|
43
|
-
test('
|
|
44
|
-
|
|
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 () => {
|
|
149
|
+
const tempDir = await mkdtemp(
|
|
150
|
+
resolve(__dirname, '.modern-i18n-runtime-boundary-'),
|
|
151
|
+
);
|
|
152
|
+
try {
|
|
153
|
+
const contextEntry = resolve(runtimeRoot, 'context.tsx');
|
|
154
|
+
const copies = await Promise.all(
|
|
155
|
+
['copy-a.mjs', 'copy-b.mjs'].map(async filename => {
|
|
156
|
+
const outfile = resolve(tempDir, filename);
|
|
157
|
+
await build({
|
|
158
|
+
bundle: true,
|
|
159
|
+
entryPoints: [contextEntry],
|
|
160
|
+
format: 'esm',
|
|
161
|
+
jsx: 'automatic',
|
|
162
|
+
outfile,
|
|
163
|
+
packages: 'external',
|
|
164
|
+
platform: 'node',
|
|
165
|
+
});
|
|
166
|
+
return import(pathToFileURL(outfile).href);
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
const [copyA, copyB] = copies;
|
|
170
|
+
const instance = i18next.createInstance();
|
|
171
|
+
await instance.init({
|
|
172
|
+
initImmediate: false,
|
|
173
|
+
lng: 'cs',
|
|
174
|
+
resources: { cs: { translation: { language: 'Jazyk' } } },
|
|
175
|
+
});
|
|
176
|
+
const Consumer = () =>
|
|
177
|
+
createElement('span', null, copyB.useModernI18n().language);
|
|
178
|
+
|
|
179
|
+
const html = renderToStaticMarkup(
|
|
180
|
+
createElement(
|
|
181
|
+
copyA.ModernI18nProvider,
|
|
182
|
+
{
|
|
183
|
+
value: {
|
|
184
|
+
i18nInstance: instance,
|
|
185
|
+
language: 'cs',
|
|
186
|
+
languages: ['en', 'cs'],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
createElement(Consumer),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
45
192
|
|
|
46
|
-
|
|
193
|
+
expect(html).toBe('<span>cs</span>');
|
|
194
|
+
} finally {
|
|
195
|
+
await rm(tempDir, { force: true, recursive: true });
|
|
196
|
+
}
|
|
47
197
|
});
|
|
48
198
|
});
|
|
@@ -20,6 +20,7 @@ import { I18nLink } from '../src/runtime/I18nLink';
|
|
|
20
20
|
import type { I18nInstance } from '../src/runtime/i18n';
|
|
21
21
|
import { getReactI18nextIntegration } from '../src/runtime/i18n/react-i18next';
|
|
22
22
|
import { createI18nRootWrapper } from '../src/runtime/providerComposition';
|
|
23
|
+
import { useI18nRouterAdapter } from '../src/runtime/routerAdapter';
|
|
23
24
|
|
|
24
25
|
(
|
|
25
26
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
@@ -244,9 +245,68 @@ function createMutableTanstackRouter(pathname = '/en') {
|
|
|
244
245
|
listener();
|
|
245
246
|
}
|
|
246
247
|
},
|
|
248
|
+
publishStateUpdate() {
|
|
249
|
+
for (const listener of listeners) {
|
|
250
|
+
listener();
|
|
251
|
+
}
|
|
252
|
+
},
|
|
247
253
|
};
|
|
248
254
|
}
|
|
249
255
|
|
|
256
|
+
function createMutableReactRouter(
|
|
257
|
+
pathname = '/en',
|
|
258
|
+
params: Record<string, string> = { lang: pathname.slice(1) },
|
|
259
|
+
) {
|
|
260
|
+
const listeners = new Set<() => void>();
|
|
261
|
+
const router = {
|
|
262
|
+
state: {
|
|
263
|
+
location: {
|
|
264
|
+
pathname,
|
|
265
|
+
search: '',
|
|
266
|
+
hash: '',
|
|
267
|
+
},
|
|
268
|
+
matches: [{ params }],
|
|
269
|
+
fetchers: new Map<string, unknown>(),
|
|
270
|
+
},
|
|
271
|
+
subscribe(listener: () => void) {
|
|
272
|
+
listeners.add(listener);
|
|
273
|
+
return () => listeners.delete(listener);
|
|
274
|
+
},
|
|
275
|
+
publishFetcherUpdate() {
|
|
276
|
+
router.state = {
|
|
277
|
+
...router.state,
|
|
278
|
+
fetchers: new Map([['fetcher', { state: 'idle' }]]),
|
|
279
|
+
};
|
|
280
|
+
for (const listener of listeners) {
|
|
281
|
+
listener();
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
publishPathname(nextPathname: string) {
|
|
285
|
+
router.state = {
|
|
286
|
+
...router.state,
|
|
287
|
+
location: {
|
|
288
|
+
...router.state.location,
|
|
289
|
+
pathname: nextPathname,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
for (const listener of listeners) {
|
|
293
|
+
listener();
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
publishParams(nextParams: Record<string, string>) {
|
|
297
|
+
router.state = {
|
|
298
|
+
...router.state,
|
|
299
|
+
matches: [{ params: nextParams }],
|
|
300
|
+
};
|
|
301
|
+
for (const listener of listeners) {
|
|
302
|
+
listener();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return router;
|
|
308
|
+
}
|
|
309
|
+
|
|
250
310
|
function createDeferredI18nInstance() {
|
|
251
311
|
const resources = {
|
|
252
312
|
en: { languageSwitcher: 'Language' },
|
|
@@ -459,6 +519,53 @@ describe('i18n runtime wrapRoot', () => {
|
|
|
459
519
|
expect(rendered.container.textContent).toContain('router content');
|
|
460
520
|
});
|
|
461
521
|
|
|
522
|
+
test('keeps the i18next provider instance stable across unrelated parent renders', async () => {
|
|
523
|
+
const i18nInstance = createI18nInstance('cs');
|
|
524
|
+
const providerInstances: I18nInstance[] = [];
|
|
525
|
+
const I18nextProvider = ({
|
|
526
|
+
children,
|
|
527
|
+
i18n,
|
|
528
|
+
}: PropsWithChildren<{ i18n: I18nInstance }>) => {
|
|
529
|
+
providerInstances.push(i18n);
|
|
530
|
+
return <>{children}</>;
|
|
531
|
+
};
|
|
532
|
+
const App = () => <main>router content</main>;
|
|
533
|
+
const I18nRoot = createI18nRootWrapper({
|
|
534
|
+
htmlLangAttr: false,
|
|
535
|
+
localePathRedirect: false,
|
|
536
|
+
languages: ['en', 'cs'],
|
|
537
|
+
fallbackLanguage: 'en',
|
|
538
|
+
getLatestI18nInstance: () => i18nInstance,
|
|
539
|
+
getI18nextProvider: () => I18nextProvider,
|
|
540
|
+
})(App);
|
|
541
|
+
const Parent = () => {
|
|
542
|
+
const [renderVersion, setRenderVersion] = useState(0);
|
|
543
|
+
return (
|
|
544
|
+
<>
|
|
545
|
+
<button
|
|
546
|
+
type="button"
|
|
547
|
+
onClick={() => setRenderVersion(version => version + 1)}
|
|
548
|
+
>
|
|
549
|
+
Render {renderVersion}
|
|
550
|
+
</button>
|
|
551
|
+
<I18nRoot renderVersion={renderVersion} />
|
|
552
|
+
</>
|
|
553
|
+
);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
rendered = await renderI18nRoot(<Parent />);
|
|
557
|
+
const initialProviderInstance = providerInstances.at(-1);
|
|
558
|
+
|
|
559
|
+
await act(async () => {
|
|
560
|
+
rendered?.container
|
|
561
|
+
.querySelector('button')
|
|
562
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(providerInstances.length).toBeGreaterThan(1);
|
|
566
|
+
expect(providerInstances.at(-1)).toBe(initialProviderInstance);
|
|
567
|
+
});
|
|
568
|
+
|
|
462
569
|
test('commits route language and translated copy after the instance changes', async () => {
|
|
463
570
|
window.history.replaceState(null, '', '/en');
|
|
464
571
|
const router = createMutableTanstackRouter();
|
|
@@ -762,6 +869,83 @@ describe('i18n router adapter', () => {
|
|
|
762
869
|
expect(link?.getAttribute('data-router-link')).toBe('tanstack');
|
|
763
870
|
});
|
|
764
871
|
|
|
872
|
+
test('ignores router state updates that do not change location or params', async () => {
|
|
873
|
+
const router = createMutableTanstackRouter('/en');
|
|
874
|
+
let renders = 0;
|
|
875
|
+
const LocationProbe = () => {
|
|
876
|
+
renders += 1;
|
|
877
|
+
const { location } = useI18nRouterAdapter();
|
|
878
|
+
return <output>{location?.pathname}</output>;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
rendered = await renderWithRuntime(
|
|
882
|
+
<LocationProbe />,
|
|
883
|
+
createTanstackRuntimeContext(router),
|
|
884
|
+
);
|
|
885
|
+
const initialRenders = renders;
|
|
886
|
+
|
|
887
|
+
await act(async () => {
|
|
888
|
+
router.publishStateUpdate();
|
|
889
|
+
});
|
|
890
|
+
expect(renders).toBe(initialRenders);
|
|
891
|
+
expect(rendered.container.textContent).toBe('/en');
|
|
892
|
+
|
|
893
|
+
await act(async () => {
|
|
894
|
+
router.publishPathname('/cs');
|
|
895
|
+
});
|
|
896
|
+
expect(renders).toBe(initialRenders + 1);
|
|
897
|
+
expect(rendered.container.textContent).toBe('/cs');
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('ignores React Router fetcher updates while observing location changes', async () => {
|
|
901
|
+
const router = createMutableReactRouter('/en');
|
|
902
|
+
let renders = 0;
|
|
903
|
+
const LocationProbe = () => {
|
|
904
|
+
renders += 1;
|
|
905
|
+
const { location } = useI18nRouterAdapter();
|
|
906
|
+
return <output>{location?.pathname}</output>;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
rendered = await renderWithRuntime(
|
|
910
|
+
<LocationProbe />,
|
|
911
|
+
createReactRouterRuntimeContext(router),
|
|
912
|
+
);
|
|
913
|
+
const initialRenders = renders;
|
|
914
|
+
|
|
915
|
+
await act(async () => {
|
|
916
|
+
router.publishFetcherUpdate();
|
|
917
|
+
});
|
|
918
|
+
expect(renders).toBe(initialRenders);
|
|
919
|
+
expect(rendered.container.textContent).toBe('/en');
|
|
920
|
+
|
|
921
|
+
await act(async () => {
|
|
922
|
+
router.publishPathname('/cs');
|
|
923
|
+
});
|
|
924
|
+
expect(renders).toBe(initialRenders + 1);
|
|
925
|
+
expect(rendered.container.textContent).toBe('/cs');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test('observes distinct route params without serialized snapshot collisions', async () => {
|
|
929
|
+
const router = createMutableReactRouter('/products', {
|
|
930
|
+
a: '1&b=2',
|
|
931
|
+
});
|
|
932
|
+
const ParamsProbe = () => {
|
|
933
|
+
const { params } = useI18nRouterAdapter();
|
|
934
|
+
return <output>{JSON.stringify(params)}</output>;
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
rendered = await renderWithRuntime(
|
|
938
|
+
<ParamsProbe />,
|
|
939
|
+
createReactRouterRuntimeContext(router),
|
|
940
|
+
);
|
|
941
|
+
expect(rendered.container.textContent).toBe('{"a":"1&b=2"}');
|
|
942
|
+
|
|
943
|
+
await act(async () => {
|
|
944
|
+
router.publishParams({ a: '1', b: '2' });
|
|
945
|
+
});
|
|
946
|
+
expect(rendered.container.textContent).toBe('{"a":"1","b":"2"}');
|
|
947
|
+
});
|
|
948
|
+
|
|
765
949
|
test('forwards warmup props through I18nLink with a localized string target', async () => {
|
|
766
950
|
const router = createTanstackRouter('/cs/podminky-pouzivani', 'cs');
|
|
767
951
|
rendered = await renderWithRuntime(
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"id": ":lang",
|
|
4
|
-
"path": ":lang",
|
|
5
|
-
"type": "nested",
|
|
6
|
-
"origin": "file-system",
|
|
7
|
-
"routeType": "layout",
|
|
8
|
-
"_component": ":lang.tsx",
|
|
9
|
-
"children": [
|
|
10
|
-
{
|
|
11
|
-
"id": "about",
|
|
12
|
-
"path": "about",
|
|
13
|
-
"type": "nested",
|
|
14
|
-
"origin": "file-system",
|
|
15
|
-
"routeType": "page",
|
|
16
|
-
"_component": "about.tsx",
|
|
17
|
-
"modernCanonicalPath": "/about"
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"id": "about__localised_o-nas",
|
|
21
|
-
"path": "o-nas",
|
|
22
|
-
"type": "nested",
|
|
23
|
-
"origin": "file-system",
|
|
24
|
-
"routeType": "page",
|
|
25
|
-
"_component": "about.tsx",
|
|
26
|
-
"modernCanonicalPath": "/about"
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
"id": "about__localised_ueber-uns",
|
|
30
|
-
"path": "ueber-uns",
|
|
31
|
-
"type": "nested",
|
|
32
|
-
"origin": "file-system",
|
|
33
|
-
"routeType": "page",
|
|
34
|
-
"_component": "about.tsx",
|
|
35
|
-
"modernCanonicalPath": "/about"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"id": "products",
|
|
39
|
-
"path": "products",
|
|
40
|
-
"type": "nested",
|
|
41
|
-
"origin": "file-system",
|
|
42
|
-
"routeType": "layout",
|
|
43
|
-
"_component": "products.tsx",
|
|
44
|
-
"children": [
|
|
45
|
-
{
|
|
46
|
-
"id": ":slug",
|
|
47
|
-
"path": ":slug",
|
|
48
|
-
"type": "nested",
|
|
49
|
-
"origin": "file-system",
|
|
50
|
-
"routeType": "page",
|
|
51
|
-
"_component": ":slug.tsx",
|
|
52
|
-
"modernCanonicalPath": "/products/:slug"
|
|
53
|
-
}
|
|
54
|
-
],
|
|
55
|
-
"modernCanonicalPath": "/products"
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
"id": "products__localised_produkty",
|
|
59
|
-
"path": "produkty",
|
|
60
|
-
"type": "nested",
|
|
61
|
-
"origin": "file-system",
|
|
62
|
-
"routeType": "layout",
|
|
63
|
-
"_component": "products.tsx",
|
|
64
|
-
"children": [
|
|
65
|
-
{
|
|
66
|
-
"id": ":slug__localised_produkty",
|
|
67
|
-
"path": ":slug",
|
|
68
|
-
"type": "nested",
|
|
69
|
-
"origin": "file-system",
|
|
70
|
-
"routeType": "page",
|
|
71
|
-
"_component": ":slug.tsx",
|
|
72
|
-
"modernCanonicalPath": "/products/:slug"
|
|
73
|
-
}
|
|
74
|
-
],
|
|
75
|
-
"modernCanonicalPath": "/products"
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
"id": "products__localised_produkte",
|
|
79
|
-
"path": "produkte",
|
|
80
|
-
"type": "nested",
|
|
81
|
-
"origin": "file-system",
|
|
82
|
-
"routeType": "layout",
|
|
83
|
-
"_component": "products.tsx",
|
|
84
|
-
"children": [
|
|
85
|
-
{
|
|
86
|
-
"id": ":slug__localised_produkte",
|
|
87
|
-
"path": ":slug",
|
|
88
|
-
"type": "nested",
|
|
89
|
-
"origin": "file-system",
|
|
90
|
-
"routeType": "page",
|
|
91
|
-
"_component": ":slug.tsx",
|
|
92
|
-
"modernCanonicalPath": "/products/:slug"
|
|
93
|
-
}
|
|
94
|
-
],
|
|
95
|
-
"modernCanonicalPath": "/products"
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
"id": "docs",
|
|
99
|
-
"path": "docs",
|
|
100
|
-
"type": "nested",
|
|
101
|
-
"origin": "file-system",
|
|
102
|
-
"routeType": "layout",
|
|
103
|
-
"_component": "docs.tsx",
|
|
104
|
-
"children": [
|
|
105
|
-
{
|
|
106
|
-
"id": "*",
|
|
107
|
-
"path": "*",
|
|
108
|
-
"type": "nested",
|
|
109
|
-
"origin": "file-system",
|
|
110
|
-
"routeType": "page",
|
|
111
|
-
"_component": "*.tsx"
|
|
112
|
-
}
|
|
113
|
-
],
|
|
114
|
-
"modernCanonicalPath": "/docs"
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
"id": "docs__localised_dokumenty",
|
|
118
|
-
"path": "dokumenty",
|
|
119
|
-
"type": "nested",
|
|
120
|
-
"origin": "file-system",
|
|
121
|
-
"routeType": "layout",
|
|
122
|
-
"_component": "docs.tsx",
|
|
123
|
-
"children": [
|
|
124
|
-
{
|
|
125
|
-
"id": "*__localised_dokumenty",
|
|
126
|
-
"path": "*",
|
|
127
|
-
"type": "nested",
|
|
128
|
-
"origin": "file-system",
|
|
129
|
-
"routeType": "page",
|
|
130
|
-
"_component": "*.tsx"
|
|
131
|
-
}
|
|
132
|
-
],
|
|
133
|
-
"modernCanonicalPath": "/docs"
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
"id": "docs__localised_dokumente",
|
|
137
|
-
"path": "dokumente",
|
|
138
|
-
"type": "nested",
|
|
139
|
-
"origin": "file-system",
|
|
140
|
-
"routeType": "layout",
|
|
141
|
-
"_component": "docs.tsx",
|
|
142
|
-
"children": [
|
|
143
|
-
{
|
|
144
|
-
"id": "*__localised_dokumente",
|
|
145
|
-
"path": "*",
|
|
146
|
-
"type": "nested",
|
|
147
|
-
"origin": "file-system",
|
|
148
|
-
"routeType": "page",
|
|
149
|
-
"_component": "*.tsx"
|
|
150
|
-
}
|
|
151
|
-
],
|
|
152
|
-
"modernCanonicalPath": "/docs"
|
|
153
|
-
}
|
|
154
|
-
]
|
|
155
|
-
}
|
|
156
|
-
]
|