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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/cjs/cli/artifacts.js +15 -1
  2. package/dist/cjs/cli/index.js +12 -0
  3. package/dist/cjs/cli/routeMetadata.js +131 -0
  4. package/dist/cjs/cli/tanstackTypes/canonicalRoutes.js +7 -7
  5. package/dist/cjs/cli/tanstackTypes/shared.js +0 -14
  6. package/dist/cjs/cli/tanstackTypes/source.js +27 -60
  7. package/dist/cjs/runtime/basepathRewrite.js +13 -6
  8. package/dist/cjs/runtime/lifecycle.js +6 -9
  9. package/dist/cjs/runtime/loaderBridge.js +10 -4
  10. package/dist/cjs/runtime/navigation.js +102 -0
  11. package/dist/cjs/runtime/plugin.js +7 -2
  12. package/dist/cjs/runtime/plugin.node.js +86 -64
  13. package/dist/cjs/runtime/prefetchLink.js +2 -7
  14. package/dist/cjs/runtime/register.js +0 -2
  15. package/dist/cjs/runtime/routeTree/construction.js +19 -59
  16. package/dist/cjs/runtime/routeTree/loaders.js +1 -1
  17. package/dist/cjs/runtime/routeTree/paths.js +0 -6
  18. package/dist/cjs/runtime/router.js +25 -9
  19. package/dist/cjs/runtime/ssrManagedTags.js +2 -12
  20. package/dist/cjs/runtime/state.js +2 -2
  21. package/dist/cjs/runtime/utils.js +3 -0
  22. package/dist/cjs/shared/routeDescriptor.js +60 -0
  23. package/dist/esm/cli/artifacts.mjs +13 -2
  24. package/dist/esm/cli/index.mjs +12 -9
  25. package/dist/esm/cli/routeMetadata.mjs +71 -0
  26. package/dist/esm/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  27. package/dist/esm/cli/tanstackTypes/shared.mjs +1 -9
  28. package/dist/esm/cli/tanstackTypes/source.mjs +28 -61
  29. package/dist/esm/runtime/basepathRewrite.mjs +10 -3
  30. package/dist/esm/runtime/lifecycle.mjs +1 -1
  31. package/dist/esm/runtime/loaderBridge.mjs +8 -5
  32. package/dist/esm/runtime/navigation.mjs +64 -0
  33. package/dist/esm/runtime/plugin.mjs +7 -2
  34. package/dist/esm/runtime/plugin.node.mjs +88 -66
  35. package/dist/esm/runtime/prefetchLink.mjs +1 -6
  36. package/dist/esm/runtime/register.mjs +0 -2
  37. package/dist/esm/runtime/routeTree/construction.mjs +19 -59
  38. package/dist/esm/runtime/routeTree/loaders.mjs +2 -2
  39. package/dist/esm/runtime/routeTree/paths.mjs +1 -4
  40. package/dist/esm/runtime/router.mjs +27 -11
  41. package/dist/esm/runtime/ssrManagedTags.mjs +1 -8
  42. package/dist/esm/runtime/state.mjs +1 -1
  43. package/dist/esm/runtime/utils.mjs +3 -0
  44. package/dist/esm/shared/routeDescriptor.mjs +22 -0
  45. package/dist/esm-node/cli/artifacts.mjs +13 -2
  46. package/dist/esm-node/cli/index.mjs +12 -9
  47. package/dist/esm-node/cli/routeMetadata.mjs +72 -0
  48. package/dist/esm-node/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  49. package/dist/esm-node/cli/tanstackTypes/shared.mjs +1 -9
  50. package/dist/esm-node/cli/tanstackTypes/source.mjs +28 -61
  51. package/dist/esm-node/runtime/basepathRewrite.mjs +10 -3
  52. package/dist/esm-node/runtime/lifecycle.mjs +1 -1
  53. package/dist/esm-node/runtime/loaderBridge.mjs +8 -5
  54. package/dist/esm-node/runtime/navigation.mjs +65 -0
  55. package/dist/esm-node/runtime/plugin.mjs +7 -2
  56. package/dist/esm-node/runtime/plugin.node.mjs +88 -66
  57. package/dist/esm-node/runtime/prefetchLink.mjs +1 -6
  58. package/dist/esm-node/runtime/register.mjs +0 -2
  59. package/dist/esm-node/runtime/routeTree/construction.mjs +19 -59
  60. package/dist/esm-node/runtime/routeTree/loaders.mjs +2 -2
  61. package/dist/esm-node/runtime/routeTree/paths.mjs +1 -4
  62. package/dist/esm-node/runtime/router.mjs +27 -11
  63. package/dist/esm-node/runtime/ssrManagedTags.mjs +1 -8
  64. package/dist/esm-node/runtime/state.mjs +1 -1
  65. package/dist/esm-node/runtime/utils.mjs +3 -0
  66. package/dist/esm-node/shared/routeDescriptor.mjs +23 -0
  67. package/dist/types/cli/artifacts.d.ts +13 -5
  68. package/dist/types/cli/index.d.ts +1 -1
  69. package/dist/types/cli/routeMetadata.d.ts +18 -0
  70. package/dist/types/cli/tanstackTypes/shared.d.ts +0 -2
  71. package/dist/types/runtime/basepathRewrite.d.ts +2 -1
  72. package/dist/types/runtime/lifecycle.d.ts +9 -2
  73. package/dist/types/runtime/loaderBridge.d.ts +1 -0
  74. package/dist/types/runtime/navigation.d.ts +4 -0
  75. package/dist/types/runtime/register.d.ts +1 -9
  76. package/dist/types/runtime/routeTree/paths.d.ts +0 -2
  77. package/dist/types/runtime/router.d.ts +2 -13
  78. package/dist/types/runtime/ssrManagedTags.d.ts +0 -1
  79. package/dist/types/runtime/ssrTypes.d.ts +0 -7
  80. package/dist/types/runtime/state.d.ts +1 -1
  81. package/dist/types/runtime/types.d.ts +2 -1
  82. package/dist/types/shared/routeDescriptor.d.ts +26 -0
  83. package/package.json +22 -19
  84. package/rstest.config.mts +2 -24
  85. package/src/cli/artifacts.ts +53 -6
  86. package/src/cli/index.ts +11 -2
  87. package/src/cli/routeMetadata.ts +121 -0
  88. package/src/cli/tanstackTypes/canonicalRoutes.ts +17 -12
  89. package/src/cli/tanstackTypes/shared.ts +0 -14
  90. package/src/cli/tanstackTypes/source.ts +42 -116
  91. package/src/runtime/basepathRewrite.ts +11 -2
  92. package/src/runtime/lifecycle.ts +14 -4
  93. package/src/runtime/loaderBridge.ts +9 -9
  94. package/src/runtime/navigation.ts +78 -0
  95. package/src/runtime/plugin.node.tsx +120 -93
  96. package/src/runtime/plugin.tsx +11 -1
  97. package/src/runtime/prefetchLink.tsx +1 -23
  98. package/src/runtime/register.ts +1 -12
  99. package/src/runtime/routeTree/construction.ts +32 -110
  100. package/src/runtime/routeTree/loaders.ts +2 -4
  101. package/src/runtime/routeTree/paths.ts +0 -6
  102. package/src/runtime/router.ts +25 -40
  103. package/src/runtime/ssrManagedTags.ts +0 -17
  104. package/src/runtime/ssrTypes.ts +0 -7
  105. package/src/runtime/state.ts +1 -1
  106. package/src/runtime/types.ts +2 -2
  107. package/src/runtime/utils.tsx +4 -1
  108. package/src/shared/routeDescriptor.ts +46 -0
  109. package/tests/router/cli.test.ts +148 -824
  110. package/tests/router/clientNavigation.ts +13 -0
  111. package/tests/router/dataMutation.test.tsx +17 -85
  112. package/tests/router/flightSerialization.roundtrip.test.tsx +9 -6
  113. package/tests/router/generateRouteArtifacts.test.ts +139 -27
  114. package/tests/router/loaderBridge.test.ts +0 -25
  115. package/tests/router/localisedIdentity.test.tsx +100 -0
  116. package/tests/router/prefetchLink.test.tsx +46 -21
  117. package/tests/router/prefetchLinkPreload.test.tsx +11 -32
  118. package/tests/router/routeHooks.test.ts +42 -105
  119. package/tests/router/routeMetadata.test.ts +233 -0
  120. package/tests/router/routeTree.test.ts +144 -836
  121. package/tests/router/rsc.test.tsx +131 -166
  122. package/tests/router/serverPlugin.test.ts +193 -1
  123. package/tests/router/tanstackTypes.test.ts +218 -963
  124. package/dist/esm/rslib-runtime~1.mjs +0 -18
  125. package/dist/esm-node/rslib-runtime~0.mjs +0 -19
  126. package/tests/realm-isolation.test.ts +0 -114
  127. package/tests/router/fastDefaults.test.ts +0 -32
  128. package/tests/router/fixtures/legacyRuntimeRouter.ts +0 -10
  129. package/tests/router/fixtures/rscDecoderSecurityChild.cjs +0 -162
  130. package/tests/router/hooks.test.ts +0 -40
  131. package/tests/router/hydrationBoundary.test.tsx +0 -23
  132. package/tests/router/packageSurface.test.ts +0 -24
  133. package/tests/router/preloadRedirect.test.ts +0 -64
  134. package/tests/router/register.test.ts +0 -8
  135. package/tests/router/routerCompatibility.test.ts +0 -239
  136. package/tests/router/rscDecoderSecurity.test.ts +0 -113
  137. package/tests/router/rscPayloadRouterMatrix.test.ts +0 -197
@@ -1,50 +1,22 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
5
6
  import { promisify } from 'node:util';
6
-
7
- const resolverDelays = new Map<string, number>();
8
-
9
- rstest.mock('../../src/cli/tanstackTypes/shared', () => {
10
- const actual = rstest.requireActual(
11
- '../../src/cli/tanstackTypes/shared',
12
- ) as typeof import('../../src/cli/tanstackTypes/shared');
13
-
14
- return {
15
- ...actual,
16
- resolveFileNoExt: async (inputNoExtPath: string) => {
17
- const normalized = inputNoExtPath.replaceAll('\\', '/');
18
- const delay =
19
- [...resolverDelays.entries()].find(([suffix]) =>
20
- normalized.endsWith(suffix),
21
- )?.[1] ?? 0;
22
-
23
- if (delay > 0) {
24
- await new Promise(resolve => setTimeout(resolve, delay));
25
- }
26
-
27
- return actual.resolveFileNoExt(inputNoExtPath);
28
- },
29
- };
30
- });
31
-
7
+ import { applyLocalisedUrlsToRoutes } from '@modern-js/i18n-runtime-extensions';
8
+ import { createMemoryHistory, createRouter } from '@tanstack/react-router';
32
9
  import { writeTanstackRegisterFile } from '../../src/cli/artifacts';
33
10
  import {
34
11
  collectCanonicalRoutesForEntry,
35
12
  generateTanstackRouterTypesSourceForEntry,
36
13
  } from '../../src/cli/tanstackTypes';
14
+ import { createModernBasepathRewrite } from '../../src/runtime/basepathRewrite';
15
+ import { createRouteTreeFromRouteObjects } from '../../src/runtime/routeTree';
16
+ import { createTanstackRouteObjectsFromConfig } from '../../src/runtime/utils';
17
+ import { navigateOnClient } from './clientNavigation';
37
18
 
38
19
  const execFileAsync = promisify(execFile);
39
- const routerGenOxlintConfigPath = path.join(
40
- __dirname,
41
- 'fixtures',
42
- 'router-gen.oxlint.json',
43
- );
44
- const oxlintCliPath = path.resolve(
45
- __dirname,
46
- '../../../../toolkit/code-tools/node_modules/oxlint/bin/oxlint',
47
- );
48
20
  const strictestTsconfigPath = path.resolve(
49
21
  __dirname,
50
22
  '../../node_modules/@tsconfig/strictest/tsconfig.json',
@@ -54,195 +26,51 @@ const installedReactRouterPath = path.resolve(
54
26
  '../../node_modules/@tanstack/react-router',
55
27
  );
56
28
 
57
- async function writeRuntimeTestPackage(projectDirectory: string) {
58
- const runtimePackageDirectory = path.join(
59
- projectDirectory,
60
- 'node_modules',
61
- '@modern-js',
62
- 'plugin-tanstack',
63
- );
64
- await mkdir(runtimePackageDirectory, { recursive: true });
65
- await writeFile(
66
- path.join(runtimePackageDirectory, 'package.json'),
67
- JSON.stringify({
68
- name: '@modern-js/plugin-tanstack',
69
- type: 'commonjs',
70
- exports: {
71
- './runtime': {
72
- types: './runtime.d.ts',
73
- default: './runtime.js',
74
- },
75
- },
76
- }),
77
- );
78
- await writeFile(
79
- path.join(runtimePackageDirectory, 'runtime.d.ts'),
80
- [
81
- 'export type ModernRouterContext = { request?: Request; requestContext?: unknown };',
82
- 'export type ModernRouteStaticData = { modernRouteId?: string; modernRouteAction?: unknown; modernRouteLoader?: unknown };',
83
- 'export type RouteOptions = {',
84
- ' component?: unknown;',
85
- ' getParentRoute?: (...args: never[]) => unknown;',
86
- ' id?: string;',
87
- ' loader?: (...args: never[]) => unknown;',
88
- ' loaderDeps?: (...args: never[]) => unknown;',
89
- ' path?: string;',
90
- ' staticData?: ModernRouteStaticData;',
91
- ' validateSearch?: (...args: never[]) => unknown;',
92
- '};',
93
- 'export type Route<TOptions extends RouteOptions = RouteOptions> = {',
94
- ' options: TOptions;',
95
- ' addChildren<const TChildren extends readonly unknown[]>(children: TChildren): Route<TOptions> & { children: TChildren };',
96
- '};',
97
- 'export const runtimeState: { adapterCalls: Array<{ hasSplat: boolean; modernLoader: unknown }> };',
98
- 'export function createMemoryHistory<TOptions>(options: TOptions): TOptions;',
99
- 'export const modernTanstackRouterFastDefaults: { defaultPreload: "intent" };',
100
- 'export function createRootRouteWithContext<TContext extends ModernRouterContext>(): <const TOptions extends RouteOptions>(options: TOptions) => Route<TOptions>;',
101
- 'export function createRoute<const TOptions extends RouteOptions>(options: TOptions): Route<TOptions>;',
102
- 'export function createRouter<const TOptions extends { context: ModernRouterContext; history: unknown; routeTree: unknown }>(options: TOptions): TOptions;',
103
- 'export function createRouteStaticData<const TData extends ModernRouteStaticData>(data: TData): TData;',
104
- 'export function modernLoaderToTanstack<TLoader extends (...args: never[]) => unknown>(options: { hasSplat: boolean }, modernLoader: TLoader): (context: unknown) => Promise<Awaited<ReturnType<TLoader>>>;',
105
- ].join('\n'),
106
- );
107
- await writeFile(
108
- path.join(runtimePackageDirectory, 'runtime.js'),
109
- [
110
- 'const runtimeState = { adapterCalls: [] };',
111
- 'const createRouteRecord = options => ({',
112
- ' options,',
113
- ' addChildren(children) {',
114
- ' this.children = children;',
115
- ' return this;',
116
- ' },',
117
- '});',
118
- 'const createMemoryHistory = options => options;',
119
- 'const modernTanstackRouterFastDefaults = { defaultPreload: "intent" };',
120
- 'const createRootRouteWithContext = () => options => createRouteRecord(options);',
121
- 'const createRoute = options => createRouteRecord(options);',
122
- 'const createRouter = options => options;',
123
- 'const createRouteStaticData = data => data;',
124
- 'const modernLoaderToTanstack = (options, modernLoader) => {',
125
- ' runtimeState.adapterCalls.push({ ...options, modernLoader });',
126
- ' return async context => modernLoader(context);',
127
- '};',
128
- 'module.exports = { createMemoryHistory, createRootRouteWithContext, createRoute, createRouter, createRouteStaticData, modernLoaderToTanstack, modernTanstackRouterFastDefaults, runtimeState };',
129
- ].join('\n'),
130
- );
131
- }
132
-
133
- async function writeInstalledRouterRuntimeTestPackage(
29
+ async function writeTsconfig(
134
30
  projectDirectory: string,
31
+ compilerOptions: Record<string, unknown>,
135
32
  ) {
136
- const runtimePackageDirectory = path.join(
137
- projectDirectory,
138
- 'node_modules',
139
- '@modern-js',
140
- 'plugin-tanstack',
141
- );
142
- await mkdir(runtimePackageDirectory, { recursive: true });
143
- await writeFile(
144
- path.join(runtimePackageDirectory, 'package.json'),
145
- JSON.stringify({
146
- name: '@modern-js/plugin-tanstack',
147
- type: 'commonjs',
148
- exports: {
149
- './runtime': {
150
- types: './runtime.d.ts',
151
- default: './runtime.js',
152
- },
153
- },
154
- }),
155
- );
156
- await writeFile(
157
- path.join(runtimePackageDirectory, 'runtime.d.ts'),
158
- [
159
- "export { createMemoryHistory, createRootRouteWithContext, createRoute, createRouter } from '@tanstack/react-router';",
160
- 'export type ModernRouterContext = { request?: Request; requestContext?: unknown };',
161
- 'export const modernTanstackRouterFastDefaults: { readonly defaultStructuralSharing: true };',
162
- 'export function createRouteStaticData<TData extends { modernRouteId?: string; modernRouteAction?: unknown; modernRouteLoader?: unknown }>(data: TData): TData;',
163
- 'export function modernLoaderToTanstack<TLoader extends (...args: any[]) => any>(options: { hasSplat: boolean }, modernLoader: TLoader): (context: unknown) => Promise<Awaited<ReturnType<TLoader>>>;',
164
- ].join('\n'),
165
- );
166
- }
167
-
168
- async function compileAndRunGeneratedRouter(options: {
169
- projectDirectory: string;
170
- routerGenTs: string;
171
- runtimeCheck: string;
172
- }) {
173
- const { projectDirectory, routerGenTs, runtimeCheck } = options;
174
- const generatedDirectory = path.join(
175
- projectDirectory,
176
- 'src',
177
- 'modern-tanstack',
178
- 'index',
179
- );
180
- await mkdir(generatedDirectory, { recursive: true });
181
- await writeFile(path.join(generatedDirectory, 'router.gen.ts'), routerGenTs);
182
- await writeFile(
183
- path.join(projectDirectory, 'src', 'runtime-check.ts'),
184
- runtimeCheck,
185
- );
186
- await writeRuntimeTestPackage(projectDirectory);
187
- await writeFile(
188
- path.join(projectDirectory, 'package.json'),
189
- JSON.stringify({ private: true, type: 'commonjs' }),
190
- );
191
33
  await writeFile(
192
34
  path.join(projectDirectory, 'tsconfig.json'),
193
35
  JSON.stringify(
194
36
  {
195
37
  extends: strictestTsconfigPath,
196
38
  compilerOptions: {
197
- lib: ['ESNext', 'DOM'],
198
39
  jsx: 'react-jsx',
199
- module: 'Node16',
200
- moduleResolution: 'Node16',
201
- outDir: 'dist',
202
- rootDir: 'src',
203
- target: 'ES2022',
40
+ lib: ['ESNext', 'DOM'],
41
+ noEmit: true,
42
+ target: 'ESNext',
204
43
  types: [],
205
- verbatimModuleSyntax: false,
44
+ ...compilerOptions,
206
45
  },
207
- include: ['src/**/*.ts', 'src/**/*.tsx'],
46
+ include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.d.ts'],
208
47
  },
209
48
  null,
210
49
  2,
211
50
  ),
212
51
  );
52
+ }
213
53
 
54
+ /** Runs the same compiler a consumer build runs; surfaces its diagnostics. */
55
+ async function runTsgo(projectDirectory: string) {
214
56
  try {
215
57
  await execFileAsync(
216
58
  process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
217
59
  ['-p', 'tsconfig.json'],
218
- {
219
- cwd: projectDirectory,
220
- shell: process.platform === 'win32',
221
- },
60
+ { cwd: projectDirectory, shell: process.platform === 'win32' },
222
61
  );
223
62
  } catch (error) {
224
- if (error && typeof error === 'object') {
225
- const stdout =
226
- 'stdout' in error && typeof error.stdout === 'string'
227
- ? error.stdout
228
- : '';
229
- const stderr =
230
- 'stderr' in error && typeof error.stderr === 'string'
231
- ? error.stderr
232
- : '';
233
- if (stdout || stderr) {
234
- throw new Error([stdout, stderr].filter(Boolean).join('\n'), {
235
- cause: error,
236
- });
237
- }
63
+ const { stdout, stderr } = (error ?? {}) as {
64
+ stdout?: string;
65
+ stderr?: string;
66
+ };
67
+ if (stdout || stderr) {
68
+ throw new Error([stdout, stderr].filter(Boolean).join('\n'), {
69
+ cause: error,
70
+ });
238
71
  }
239
72
  throw error;
240
73
  }
241
- await execFileAsync(
242
- process.execPath,
243
- [path.join(projectDirectory, 'dist', 'runtime-check.js')],
244
- { cwd: projectDirectory },
245
- );
246
74
  }
247
75
 
248
76
  async function compileGeneratedRouterAgainstInstalledDeclarations(options: {
@@ -258,63 +86,48 @@ async function compileGeneratedRouterAgainstInstalledDeclarations(options: {
258
86
  );
259
87
  await mkdir(generatedDirectory, { recursive: true });
260
88
  await writeFile(path.join(generatedDirectory, 'router.gen.ts'), routerGenTs);
261
- await writeInstalledRouterRuntimeTestPackage(projectDirectory);
262
- await writeFile(
263
- path.join(projectDirectory, 'package.json'),
264
- JSON.stringify({ private: true, type: 'commonjs' }),
89
+
90
+ // The runtime package re-exports the *installed* @tanstack/react-router
91
+ // declarations, so the generated file is checked against the real router
92
+ // types a consumer resolves — not a stand-in.
93
+ const runtimePackageDirectory = path.join(
94
+ projectDirectory,
95
+ 'node_modules',
96
+ '@modern-js',
97
+ 'plugin-tanstack',
265
98
  );
99
+ await mkdir(runtimePackageDirectory, { recursive: true });
266
100
  await writeFile(
267
- path.join(projectDirectory, 'tsconfig.json'),
268
- JSON.stringify(
269
- {
270
- extends: strictestTsconfigPath,
271
- compilerOptions: {
272
- jsx: 'react-jsx',
273
- lib: ['ESNext', 'DOM'],
274
- module: 'Node16',
275
- moduleResolution: 'Node16',
276
- noEmit: true,
277
- paths: {
278
- '@tanstack/react-router': [installedReactRouterPath],
279
- },
280
- target: 'ES2022',
281
- types: [],
282
- verbatimModuleSyntax: false,
283
- },
284
- include: ['src/**/*.ts', 'src/**/*.tsx'],
101
+ path.join(runtimePackageDirectory, 'package.json'),
102
+ JSON.stringify({
103
+ name: '@modern-js/plugin-tanstack',
104
+ type: 'commonjs',
105
+ exports: {
106
+ './runtime': { types: './runtime.d.ts', default: './runtime.js' },
285
107
  },
286
- null,
287
- 2,
288
- ),
108
+ }),
289
109
  );
290
-
291
- try {
292
- await execFileAsync(
293
- process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
294
- ['-p', 'tsconfig.json'],
295
- {
296
- cwd: projectDirectory,
297
- shell: process.platform === 'win32',
298
- },
299
- );
300
- } catch (error) {
301
- if (error && typeof error === 'object') {
302
- const stdout =
303
- 'stdout' in error && typeof error.stdout === 'string'
304
- ? error.stdout
305
- : '';
306
- const stderr =
307
- 'stderr' in error && typeof error.stderr === 'string'
308
- ? error.stderr
309
- : '';
310
- if (stdout || stderr) {
311
- throw new Error([stdout, stderr].filter(Boolean).join('\n'), {
312
- cause: error,
313
- });
314
- }
315
- }
316
- throw error;
317
- }
110
+ await writeFile(
111
+ path.join(runtimePackageDirectory, 'runtime.d.ts'),
112
+ [
113
+ "export { createMemoryHistory, createRootRouteWithContext, createRoute, createRouter } from '@tanstack/react-router';",
114
+ 'export type ModernRouterContext = { request?: Request; requestContext?: unknown };',
115
+ 'export const modernTanstackRouterFastDefaults: { readonly defaultStructuralSharing: true };',
116
+ 'export function createRouteStaticData<TData extends { modernRouteId?: string; modernRouteAction?: unknown; modernRouteLoader?: unknown }>(data: TData): TData;',
117
+ 'export function modernLoaderToTanstack<TLoader extends (...args: any[]) => any>(options: { hasSplat: boolean }, modernLoader: TLoader): (context: unknown) => Promise<Awaited<ReturnType<TLoader>>>;',
118
+ ].join('\n'),
119
+ );
120
+ await writeFile(
121
+ path.join(projectDirectory, 'package.json'),
122
+ JSON.stringify({ private: true, type: 'commonjs' }),
123
+ );
124
+ await writeTsconfig(projectDirectory, {
125
+ module: 'Node16',
126
+ moduleResolution: 'Node16',
127
+ paths: { '@tanstack/react-router': [installedReactRouterPath] },
128
+ verbatimModuleSyntax: false,
129
+ });
130
+ await runTsgo(projectDirectory);
318
131
  }
319
132
 
320
133
  async function typecheckCanonicalRegisterContract(options: {
@@ -324,11 +137,11 @@ async function typecheckCanonicalRegisterContract(options: {
324
137
  }) {
325
138
  const { canonicalRoutes, contractLines, projectDirectory } = options;
326
139
  const srcDirectory = path.join(projectDirectory, 'src');
327
- const generatedDirectory = path.join(srcDirectory, 'modern-tanstack');
328
- const routerDirectory = path.join(generatedDirectory, 'index');
329
- await mkdir(routerDirectory, { recursive: true });
140
+ await mkdir(path.join(srcDirectory, 'modern-tanstack', 'index'), {
141
+ recursive: true,
142
+ });
330
143
  await writeFile(
331
- path.join(routerDirectory, 'router.gen.ts'),
144
+ path.join(srcDirectory, 'modern-tanstack', 'index', 'router.gen.ts'),
332
145
  'export const router = { context: {} };',
333
146
  );
334
147
  await writeTanstackRegisterFile({
@@ -350,56 +163,14 @@ async function typecheckCanonicalRegisterContract(options: {
350
163
  ...contractLines,
351
164
  ].join('\n'),
352
165
  );
353
- await writeFile(
354
- path.join(projectDirectory, 'tsconfig.json'),
355
- JSON.stringify(
356
- {
357
- extends: strictestTsconfigPath,
358
- compilerOptions: {
359
- lib: ['ESNext', 'DOM'],
360
- module: 'Preserve',
361
- moduleResolution: 'Bundler',
362
- noEmit: true,
363
- target: 'ESNext',
364
- types: [],
365
- },
366
- include: ['src/**/*.ts', 'src/**/*.d.ts'],
367
- },
368
- null,
369
- 2,
370
- ),
371
- );
372
-
373
- try {
374
- await execFileAsync(
375
- process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
376
- ['-p', 'tsconfig.json'],
377
- {
378
- cwd: projectDirectory,
379
- shell: process.platform === 'win32',
380
- },
381
- );
382
- } catch (error) {
383
- if (error && typeof error === 'object') {
384
- const stdout =
385
- 'stdout' in error && typeof error.stdout === 'string'
386
- ? error.stdout
387
- : '';
388
- const stderr =
389
- 'stderr' in error && typeof error.stderr === 'string'
390
- ? error.stderr
391
- : '';
392
- if (stdout || stderr) {
393
- throw new Error([stdout, stderr].filter(Boolean).join('\n'), {
394
- cause: error,
395
- });
396
- }
397
- }
398
- throw error;
399
- }
166
+ await writeTsconfig(projectDirectory, {
167
+ module: 'Preserve',
168
+ moduleResolution: 'Bundler',
169
+ });
170
+ await runTsgo(projectDirectory);
400
171
  }
401
172
 
402
- async function writeComprehensiveRouterFixture(srcDirectory: string) {
173
+ async function generateComprehensiveRouterGen(srcDirectory: string) {
403
174
  const files = new Map([
404
175
  [
405
176
  'routes/(app)/layout.tsx',
@@ -428,22 +199,14 @@ async function writeComprehensiveRouterFixture(srcDirectory: string) {
428
199
  ].join('\n'),
429
200
  ],
430
201
  ]);
431
-
432
202
  for (const [relativePath, contents] of files) {
433
203
  const filePath = path.join(srcDirectory, relativePath);
434
204
  await mkdir(path.dirname(filePath), { recursive: true });
435
205
  await writeFile(filePath, contents);
436
206
  }
437
- }
438
-
439
- async function generateComprehensiveRouterGen(srcDirectory: string) {
440
- await writeComprehensiveRouterFixture(srcDirectory);
441
207
 
442
208
  const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
443
- appContext: {
444
- srcDirectory,
445
- internalSrcAlias: '@/_',
446
- } as any,
209
+ appContext: { srcDirectory, internalSrcAlias: '@/_' } as any,
447
210
  entryName: 'golden',
448
211
  routes: [
449
212
  {
@@ -486,402 +249,138 @@ describe('tanstack router type generation', () => {
486
249
  let tempDir: string | undefined;
487
250
 
488
251
  afterEach(async () => {
489
- resolverDelays.clear();
490
252
  if (tempDir) {
491
253
  await rm(tempDir, { recursive: true, force: true });
492
254
  tempDir = undefined;
493
255
  }
494
256
  });
495
257
 
496
- test('strictly compiles and executes a loader-free conventional route tree', async () => {
258
+ test('strictly compiles generated routes against installed router declarations', async () => {
497
259
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
498
- const srcDirectory = path.join(tempDir, 'src');
499
- const files = new Map([
500
- [
501
- 'routes/layout.tsx',
502
- 'export default function Layout() { return null; }',
503
- ],
504
- ['routes/page.tsx', 'export default function Home() { return null; }'],
505
- [
506
- 'routes/reviews/[scanId]/page.tsx',
507
- 'export default function Review() { return null; }',
508
- ],
509
- ]);
510
-
511
- for (const [relativePath, contents] of files) {
512
- const filePath = path.join(srcDirectory, relativePath);
513
- await mkdir(path.dirname(filePath), { recursive: true });
514
- await writeFile(filePath, contents);
515
- }
516
-
517
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
518
- appContext: {
519
- srcDirectory,
520
- internalSrcAlias: '@/_',
521
- } as any,
522
- entryName: 'index',
523
- routes: [
524
- {
525
- type: 'nested',
526
- id: 'layout',
527
- isRoot: true,
528
- _component: '@/_/routes/layout',
529
- children: [
530
- {
531
- type: 'nested',
532
- id: 'page',
533
- index: true,
534
- _component: '@/_/routes/page',
535
- },
536
- {
537
- type: 'nested',
538
- id: 'reviews/(scanId)/page',
539
- path: 'reviews/:scanId',
540
- _component: '@/_/routes/reviews/[scanId]/page',
541
- },
542
- ],
543
- },
544
- ] as any,
545
- });
260
+ const routerGenTs = await generateComprehensiveRouterGen(
261
+ path.join(tempDir, 'src'),
262
+ );
546
263
 
547
- await compileAndRunGeneratedRouter({
264
+ await compileGeneratedRouterAgainstInstalledDeclarations({
548
265
  projectDirectory: tempDir,
549
266
  routerGenTs,
550
- runtimeCheck: [
551
- "import { runtimeState } from '@modern-js/plugin-tanstack/runtime';",
552
- "import Layout from './routes/layout';",
553
- "import Home from './routes/page';",
554
- "import Review from './routes/reviews/[scanId]/page';",
555
- "import { rootRoute, routeTree, router } from './modern-tanstack/index/router.gen';",
556
- '',
557
- 'if (runtimeState.adapterCalls.length !== 0) throw new Error("loader adapter registered without a loader");',
558
- 'if (Object.keys(router.context).length !== 0) throw new Error("router context is not empty");',
559
- 'if (rootRoute.options.component !== Layout) throw new Error("root component was not wired");',
560
- 'const [homeRoute, reviewRoute] = routeTree.children;',
561
- 'if (homeRoute.options.component !== Home) throw new Error("index component was not wired");',
562
- 'if (reviewRoute.options.component !== Review) throw new Error("review component was not wired");',
563
- 'if (reviewRoute.options.path !== "reviews/$scanId") throw new Error("route params were not translated");',
564
- ].join('\n'),
565
267
  });
566
268
  });
567
269
 
568
- test('executes generated loader and action wiring through route static data', async () => {
569
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
570
- const srcDirectory = path.join(tempDir, 'src');
571
- const routeDir = path.join(srcDirectory, 'routes', 'mf');
572
- await mkdir(routeDir, { recursive: true });
573
- await writeFile(
574
- path.join(routeDir, 'page.data.ts'),
270
+ test('generated and runtime trees agree on localized splats, root siblings, layouts and params', async () => {
271
+ tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-parity-'));
272
+ const localized = applyLocalisedUrlsToRoutes(
575
273
  [
576
- 'export const loader = () => ({ count: 0 });',
577
- 'export const action = () => Response.json({ count: 1 });',
578
- ].join('\n'),
579
- );
580
-
581
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
582
- appContext: {
583
- srcDirectory,
584
- internalSrcAlias: '@/_',
585
- } as any,
586
- entryName: 'index',
587
- routes: [
588
274
  {
589
275
  type: 'nested',
590
- id: 'layout',
591
- isRoot: true,
276
+ id: 'lang',
277
+ path: ':lang',
592
278
  children: [
593
279
  {
594
280
  type: 'nested',
595
- id: 'mf/page',
596
- path: 'mf',
597
- data: '@/_/routes/mf/page.data',
598
- action: '@/_/routes/mf/page.data',
281
+ id: 'products',
282
+ path: 'products',
283
+ children: [{ type: 'nested', id: 'product-files', path: '*' }],
599
284
  },
285
+ { type: 'nested', id: 'fallback', path: '*' },
600
286
  ],
601
287
  },
602
- ] as any,
288
+ ],
289
+ ['en', 'cs'],
290
+ { '/products': { en: '/products', cs: '/produkty' } },
291
+ 'canonical',
292
+ );
293
+ const routes = [
294
+ {
295
+ type: 'nested',
296
+ id: 'layout',
297
+ isRoot: true,
298
+ children: [
299
+ ...localized,
300
+ {
301
+ type: 'nested',
302
+ children: [{ type: 'nested', path: 'one/:id?', index: false }],
303
+ },
304
+ { type: 'nested', children: [{ type: 'nested', path: 'two/*' }] },
305
+ { type: 'nested', id: 'index', index: true },
306
+ ],
307
+ },
308
+ { type: 'nested', id: 'sibling', path: 'outside/:lang' },
309
+ ] as any;
310
+ const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
311
+ appContext: {
312
+ srcDirectory: path.join(tempDir, 'src'),
313
+ internalSrcAlias: '@/_',
314
+ } as any,
315
+ entryName: 'parity',
316
+ routes,
603
317
  });
604
-
605
- await compileAndRunGeneratedRouter({
318
+ const generated = path.join(tempDir, 'router.gen.ts');
319
+ await writeFile(generated, routerGenTs);
320
+ const modules = path.join(tempDir, 'node_modules', '@modern-js');
321
+ await mkdir(modules, { recursive: true });
322
+ await symlink(
323
+ path.resolve(__dirname, '../..'),
324
+ path.join(modules, 'plugin-tanstack'),
325
+ 'junction',
326
+ );
327
+ const { stdout } = await execFileAsync(process.execPath, [
328
+ '--input-type=module',
329
+ '--eval',
330
+ `
331
+ import { router } from ${JSON.stringify(pathToFileURL(generated).href)};
332
+ import { createModernBasepathRewrite } from ${JSON.stringify(pathToFileURL(path.resolve(__dirname, '../../dist/esm/runtime/basepathRewrite.mjs')).href)};
333
+ router.update({ rewrite: createModernBasepathRewrite('/base', false, ${JSON.stringify(routes)}) });
334
+ router.history.push(router.buildLocation({ to: '/cs/produkty/a/b', search: { q: 'tractor' }, hash: 'files' }).publicHref); router.updateLatestLocation(); await router.load();
335
+ console.log(JSON.stringify({ ids: Object.keys(router.routesById).sort(), match: router.state.matches.at(-1).routeId, params: router.state.matches.at(-1).params, href: router.history.location.href }));
336
+ `,
337
+ ]);
338
+ const routeObjects = createTanstackRouteObjectsFromConfig({
339
+ routesConfig: { routes },
340
+ })!;
341
+ const runtime = createRouter({
342
+ routeTree: createRouteTreeFromRouteObjects(routeObjects),
343
+ history: createMemoryHistory({
344
+ initialEntries: ['/base/cs/produkty/a/b?q=tractor#files'],
345
+ }),
346
+ rewrite: createModernBasepathRewrite('/base', false, routeObjects),
347
+ });
348
+ await runtime.load();
349
+ expect(JSON.parse(stdout)).toEqual({
350
+ ids: Object.keys(runtime.routesById).sort(),
351
+ match: runtime.state.matches.at(-1)?.routeId,
352
+ params: runtime.state.matches.at(-1)?.params,
353
+ href: runtime.history.location.href,
354
+ });
355
+ expect(runtime.state.matches.at(-1)?.routeId).toBe('/$lang/products/$');
356
+ expect(runtime.state.matches.at(-1)?.params).toMatchObject({
357
+ lang: 'cs',
358
+ _splat: 'a/b',
359
+ });
360
+ await navigateOnClient(runtime, { to: '/cs/missing/path' });
361
+ expect(runtime.state.matches.at(-1)?.routeId).toBe('/$lang/$');
362
+ expect(runtime.state.matches.at(-1)?.params).toMatchObject({
363
+ lang: 'cs',
364
+ _splat: 'missing/path',
365
+ });
366
+ await navigateOnClient(runtime, { to: '/outside/cs' });
367
+ expect(runtime.state.matches.at(-1)?.params).toEqual({ lang: 'cs' });
368
+ const canonicalRoutes = collectCanonicalRoutesForEntry(localized as any)!;
369
+ expect(Object.keys(canonicalRoutes)).toEqual(['/$', '/products/$']);
370
+ await typecheckCanonicalRegisterContract({
606
371
  projectDirectory: tempDir,
607
- routerGenTs,
608
- runtimeCheck: [
609
- "import { runtimeState } from '@modern-js/plugin-tanstack/runtime';",
610
- "import { action, loader } from './routes/mf/page.data';",
611
- "import { routeTree } from './modern-tanstack/index/router.gen';",
612
- '',
613
- 'async function main() {',
614
- 'const [mfRoute] = routeTree.children;',
615
- 'if (runtimeState.adapterCalls.length !== 1) throw new Error("loader adapter was not registered exactly once");',
616
- 'if (runtimeState.adapterCalls[0]?.modernLoader !== loader) throw new Error("wrong loader was adapted");',
617
- 'const result = await mfRoute.options.loader({});',
618
- 'if (result.count !== 0) throw new Error("adapted loader returned the wrong value");',
619
- 'if (mfRoute.options.staticData.modernRouteLoader !== loader) throw new Error("loader static data was not wired");',
620
- 'if (mfRoute.options.staticData.modernRouteAction !== action) throw new Error("action static data was not wired");',
621
- '}',
622
- 'void main();',
623
- ].join('\n'),
372
+ canonicalRoutes,
373
+ contractLines: [
374
+ "const product: UltramodernCanonicalRoutes['/products/$'] = { _splat: 'a/b' };",
375
+ "const fallback: UltramodernCanonicalRoutes['/$'] = { _splat: 'unknown/path' };",
376
+ '// @ts-expect-error splat remains a string',
377
+ "const invalid: UltramodernCanonicalRoutes['/products/$'] = { _splat: 42 };",
378
+ 'void product; void fallback; void invalid;',
379
+ ],
624
380
  });
625
381
  });
626
382
 
627
- test('executes component sharing correctly when module resolution races', async () => {
628
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
629
- const srcDirectory = path.join(tempDir, 'src');
630
-
631
- for (const componentFile of [
632
- 'routes/slow-page.tsx',
633
- 'routes/fast-page.tsx',
634
- 'routes/shared-page.tsx',
635
- ]) {
636
- const componentPath = path.join(srcDirectory, componentFile);
637
- await mkdir(path.dirname(componentPath), { recursive: true });
638
- await writeFile(
639
- componentPath,
640
- 'export default function Page() { return null; }',
641
- );
642
- }
643
-
644
- resolverDelays.set('/routes/slow-page', 25);
645
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
646
- appContext: {
647
- srcDirectory,
648
- internalSrcAlias: '@/_',
649
- } as any,
650
- entryName: 'index',
651
- routes: [
652
- {
653
- type: 'nested',
654
- id: 'layout',
655
- isRoot: true,
656
- children: [
657
- {
658
- type: 'nested',
659
- id: 'slow/page',
660
- path: 'slow',
661
- _component: '@/_/routes/slow-page',
662
- },
663
- {
664
- type: 'nested',
665
- id: 'fast/page',
666
- path: 'fast',
667
- _component: '@/_/routes/fast-page',
668
- },
669
- {
670
- type: 'nested',
671
- id: 'shared-a/page',
672
- path: 'shared-a',
673
- _component: '@/_/routes/shared-page',
674
- },
675
- {
676
- type: 'nested',
677
- id: 'shared-b/page',
678
- path: 'shared-b',
679
- _component: '@/_/routes/shared-page',
680
- },
681
- {
682
- type: 'nested',
683
- id: 'data-only/page',
684
- path: 'data-only',
685
- },
686
- ],
687
- },
688
- ] as any,
689
- });
690
-
691
- await compileAndRunGeneratedRouter({
692
- projectDirectory: tempDir,
693
- routerGenTs,
694
- runtimeCheck: [
695
- "import SlowPage from './routes/slow-page';",
696
- "import FastPage from './routes/fast-page';",
697
- "import SharedPage from './routes/shared-page';",
698
- "import { routeTree } from './modern-tanstack/index/router.gen';",
699
- '',
700
- 'const [slowRoute, fastRoute, sharedA, sharedB, dataOnly] = routeTree.children;',
701
- 'if (slowRoute.options.component !== SlowPage) throw new Error("slow component was crossed during resolution");',
702
- 'if (fastRoute.options.component !== FastPage) throw new Error("fast component was crossed during resolution");',
703
- 'if (sharedA.options.component !== SharedPage || sharedB.options.component !== SharedPage) throw new Error("shared component identity was not preserved");',
704
- 'if (Reflect.has(dataOnly.options, "component")) throw new Error("data-only route gained a component");',
705
- ].join('\n'),
706
- });
707
- });
708
-
709
- test('strictly compiles and executes nested loader and search contracts', async () => {
710
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
711
- const srcDirectory = path.join(tempDir, 'src');
712
- const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
713
-
714
- await compileAndRunGeneratedRouter({
715
- projectDirectory: tempDir,
716
- routerGenTs,
717
- runtimeCheck: [
718
- "import { runtimeState } from '@modern-js/plugin-tanstack/runtime';",
719
- "import { action, loader } from './routes/(app)/users/(userId)/page.data';",
720
- "import { loaderDeps, validateSearch } from './routes/search.contract';",
721
- "import { routeTree } from './modern-tanstack/index/router.gen';",
722
- '',
723
- 'async function main() {',
724
- 'const [appRoute] = routeTree.children;',
725
- 'const [userRoute, docsRoute] = appRoute.children;',
726
- 'if (runtimeState.adapterCalls.length !== 1) throw new Error("nested loader was not adapted");',
727
- 'if (userRoute.options.validateSearch !== validateSearch) throw new Error("search validator was not wired");',
728
- 'if (userRoute.options.loaderDeps !== loaderDeps) throw new Error("loader dependencies were not wired");',
729
- 'if (userRoute.options.staticData.modernRouteLoader !== loader) throw new Error("nested static loader was not wired");',
730
- 'if (userRoute.options.staticData.modernRouteAction !== action) throw new Error("nested static action was not wired");',
731
- 'const user = await userRoute.options.loader({});',
732
- 'if (user.userId !== "42") throw new Error("nested loader result was lost");',
733
- 'if (userRoute.options.path !== "users/$userId") throw new Error("required route param was not translated");',
734
- 'if (docsRoute.options.path !== "docs/$") throw new Error("splat route was not translated");',
735
- 'const search = userRoute.options.validateSearch({});',
736
- 'if (search.tab !== "overview") throw new Error("search contract returned the wrong value");',
737
- '}',
738
- 'void main();',
739
- ].join('\n'),
740
- });
741
- });
742
-
743
- test('strictly compiles generated routes against installed router declarations', async () => {
744
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
745
- const srcDirectory = path.join(tempDir, 'src');
746
- const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
747
-
748
- await compileGeneratedRouterAgainstInstalledDeclarations({
749
- projectDirectory: tempDir,
750
- routerGenTs,
751
- });
752
- });
753
-
754
- test('passes the generated-artifact lint contract', async () => {
755
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
756
- const srcDirectory = path.join(tempDir, 'src');
757
- const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
758
- const routerGenPath = path.join(
759
- srcDirectory,
760
- 'modern-tanstack',
761
- 'golden',
762
- 'router.gen.ts',
763
- );
764
-
765
- await mkdir(path.dirname(routerGenPath), { recursive: true });
766
- await writeFile(routerGenPath, routerGenTs);
767
-
768
- const { stderr, stdout } = await execFileAsync(
769
- process.execPath,
770
- [
771
- oxlintCliPath,
772
- routerGenPath,
773
- '--config',
774
- routerGenOxlintConfigPath,
775
- '--no-ignore',
776
- '--report-unused-disable-directives-severity',
777
- 'error',
778
- '--format',
779
- 'unix',
780
- ],
781
- { cwd: tempDir },
782
- );
783
-
784
- expect(`${stdout}${stderr}`).toBe('');
785
- });
786
-
787
- test('executes localized aliases with typed children and correct parents', async () => {
788
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
789
- const srcDirectory = path.join(tempDir, 'src');
790
-
791
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
792
- appContext: {
793
- srcDirectory,
794
- internalSrcAlias: '@/_',
795
- } as any,
796
- entryName: 'index',
797
- routes: [
798
- {
799
- type: 'nested',
800
- id: 'layout',
801
- isRoot: true,
802
- children: [
803
- {
804
- type: 'nested',
805
- id: '(lang)/layout',
806
- path: ':lang',
807
- children: [
808
- {
809
- type: 'nested',
810
- id: '(lang)/products/(slug)/page',
811
- path: 'products/:slug',
812
- },
813
- {
814
- type: 'nested',
815
- id: '(lang)/products/(slug)/page__localised_produkty_slug',
816
- path: 'produkty/:slug',
817
- },
818
- {
819
- type: 'nested',
820
- id: '(lang)/optional/(slug$)/page__localised_volitelne_slug',
821
- path: 'volitelne/:slug?',
822
- },
823
- ],
824
- },
825
- ],
826
- },
827
- ] as any,
828
- });
829
-
830
- await compileAndRunGeneratedRouter({
831
- projectDirectory: tempDir,
832
- routerGenTs,
833
- runtimeCheck: [
834
- "import { routeTree } from './modern-tanstack/index/router.gen';",
835
- '',
836
- 'const [localeRoute] = routeTree.children;',
837
- 'const [productRoute, localizedProductRoute, optionalRoute] = localeRoute.children;',
838
- 'if (localeRoute.options.path !== "$lang") throw new Error("locale param was not translated");',
839
- 'if (productRoute.options.path !== "products/$slug") throw new Error("product param was not translated");',
840
- 'if (localizedProductRoute.options.path !== "produkty/$slug") throw new Error("localized param was not translated");',
841
- 'if (optionalRoute.options.path !== "volitelne/{-$slug}") throw new Error("optional param was not translated");',
842
- 'for (const child of localeRoute.children) {',
843
- ' if (child.options.getParentRoute() !== localeRoute) throw new Error("localized child points at the wrong parent");',
844
- '}',
845
- ].join('\n'),
846
- });
847
- });
848
- });
849
-
850
- describe('collectCanonicalRoutesForEntry', () => {
851
- let tempDir: string | undefined;
852
-
853
- afterEach(async () => {
854
- if (tempDir) {
855
- await rm(tempDir, { recursive: true, force: true });
856
- tempDir = undefined;
857
- }
858
- });
859
-
860
- test('returns null for a route tree with no locale param and no canonical metadata', () => {
861
- const result = collectCanonicalRoutesForEntry([
862
- {
863
- type: 'nested',
864
- id: 'layout',
865
- isRoot: true,
866
- children: [
867
- {
868
- type: 'nested',
869
- id: 'about/page',
870
- path: 'about',
871
- },
872
- {
873
- type: 'nested',
874
- id: 'contact/page',
875
- path: 'contact',
876
- },
877
- ],
878
- },
879
- ] as any);
880
-
881
- expect(result).toBeNull();
882
- });
883
-
884
- test('ignores a leading :lang param when the locale-param heuristic is disabled (no plugin-i18n)', async () => {
383
+ test('maps a locale-prefixed route tree to the canonical paths consumers navigate to', () => {
885
384
  const routes = [
886
385
  {
887
386
  type: 'nested',
@@ -893,257 +392,38 @@ describe('collectCanonicalRoutesForEntry', () => {
893
392
  id: '(lang)/layout',
894
393
  path: ':lang',
895
394
  children: [
395
+ { type: 'nested', id: '(lang)/page', index: true },
396
+ { type: 'nested', id: '(lang)/about/page', path: 'about' },
896
397
  {
897
398
  type: 'nested',
898
- id: '(lang)/about/page',
899
- path: 'about',
399
+ id: '(lang)/optional/(slug$)/page',
400
+ path: 'optional/:slug?',
900
401
  },
402
+ { type: 'nested', id: '(lang)/files/page', path: 'files/*' },
901
403
  ],
902
404
  },
903
405
  ],
904
406
  },
905
407
  ] as any;
906
408
 
907
- // Without plugin-i18n, a hand-rolled `/:lang/` param must NOT trigger the
908
- // i18n surface (the emitted module augmentation would break typechecking).
409
+ // Without plugin-i18n installed, a hand-rolled `/:lang/` param must NOT
410
+ // produce a canonical surface — the emitted module augmentation would
411
+ // break typechecking in an app that never opted into i18n.
909
412
  expect(
910
413
  collectCanonicalRoutesForEntry(routes, { localeParamHeuristic: false }),
911
414
  ).toBeNull();
912
- // With plugin-i18n installed the heuristic strips the locale prefix.
913
- const result = collectCanonicalRoutesForEntry(routes, {
914
- localeParamHeuristic: true,
915
- });
916
- expect(result).not.toBeNull();
917
- expect(Object.keys(result!)).toEqual(['/about']);
918
-
919
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
920
- await typecheckCanonicalRegisterContract({
921
- canonicalRoutes: result!,
922
- contractLines: [
923
- "const aboutParams: UltramodernCanonicalRoutes['/about'] = {};",
924
- '// @ts-expect-error canonical routes without params reject unexpected properties',
925
- "const invalidAboutParams: UltramodernCanonicalRoutes['/about'] = { slug: 'unexpected' };",
926
- 'void aboutParams;',
927
- 'void invalidAboutParams;',
928
- ],
929
- projectDirectory: tempDir,
930
- });
931
- });
932
-
933
- test('still honors modernCanonicalPath metadata when the heuristic is disabled', async () => {
934
- const result = collectCanonicalRoutesForEntry(
935
- [
936
- {
937
- type: 'nested',
938
- id: 'layout',
939
- isRoot: true,
940
- children: [
941
- {
942
- type: 'nested',
943
- id: '(lang)/products/(slug)/page',
944
- path: 'products/:slug',
945
- modernCanonicalPath: '/products/:slug',
946
- },
947
- ],
948
- },
949
- ] as any,
950
- { localeParamHeuristic: false },
951
- );
952
415
 
416
+ const result = collectCanonicalRoutesForEntry(routes);
953
417
  expect(result).not.toBeNull();
954
- expect(Object.keys(result!)).toEqual(['/products/$slug']);
955
-
956
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
957
- await typecheckCanonicalRegisterContract({
958
- canonicalRoutes: result!,
959
- contractLines: [
960
- "const productParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 'tractor' };",
961
- '// @ts-expect-error a required canonical route param cannot be omitted',
962
- "const missingProductParams: UltramodernCanonicalRoutes['/products/$slug'] = {};",
963
- '// @ts-expect-error canonical route params retain their string type',
964
- "const invalidProductParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 42 };",
965
- 'void productParams;',
966
- 'void missingProductParams;',
967
- 'void invalidProductParams;',
968
- ],
969
- projectDirectory: tempDir,
970
- });
971
- });
972
-
973
- test('strips leading :lang param and maps index under :lang to "/"', async () => {
974
- const result = collectCanonicalRoutesForEntry([
975
- {
976
- type: 'nested',
977
- id: 'layout',
978
- isRoot: true,
979
- children: [
980
- {
981
- type: 'nested',
982
- id: '(lang)/layout',
983
- path: ':lang',
984
- children: [
985
- {
986
- type: 'nested',
987
- id: '(lang)/page',
988
- index: true,
989
- },
990
- {
991
- type: 'nested',
992
- id: '(lang)/about/page',
993
- path: 'about',
994
- },
995
- ],
996
- },
997
- ],
998
- },
999
- ] as any);
1000
-
1001
- expect(result).not.toBeNull();
1002
- expect(Object.keys(result!)).toEqual(['/', '/about']);
1003
-
1004
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1005
- await typecheckCanonicalRegisterContract({
1006
- canonicalRoutes: result!,
1007
- contractLines: [
1008
- "const rootParams: UltramodernCanonicalRoutes['/'] = {};",
1009
- "const aboutParams: UltramodernCanonicalRoutes['/about'] = {};",
1010
- '// @ts-expect-error index canonical routes reject unexpected params',
1011
- "const invalidRootParams: UltramodernCanonicalRoutes['/'] = { slug: 'unexpected' };",
1012
- 'void rootParams;',
1013
- 'void aboutParams;',
1014
- 'void invalidRootParams;',
1015
- ],
1016
- projectDirectory: tempDir,
1017
- });
1018
- });
1019
-
1020
- test('converts :slug to $slug with required params type', async () => {
1021
- const result = collectCanonicalRoutesForEntry([
1022
- {
1023
- type: 'nested',
1024
- id: 'layout',
1025
- isRoot: true,
1026
- children: [
1027
- {
1028
- type: 'nested',
1029
- id: '(lang)/layout',
1030
- path: ':lang',
1031
- children: [
1032
- {
1033
- type: 'nested',
1034
- id: '(lang)/products/(slug)/page',
1035
- path: 'products/:slug',
1036
- },
1037
- ],
1038
- },
1039
- ],
1040
- },
1041
- ] as any);
1042
-
1043
- expect(result).not.toBeNull();
1044
- expect(Object.keys(result!)).toEqual(['/products/$slug']);
1045
-
1046
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1047
- await typecheckCanonicalRegisterContract({
1048
- canonicalRoutes: result!,
1049
- contractLines: [
1050
- "const productParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 'tractor' };",
1051
- '// @ts-expect-error a required slug cannot be omitted',
1052
- "const missingProductParams: UltramodernCanonicalRoutes['/products/$slug'] = {};",
1053
- 'void productParams;',
1054
- 'void missingProductParams;',
1055
- ],
1056
- projectDirectory: tempDir,
1057
- });
1058
- });
1059
-
1060
- test('converts :slug? to optional {-$slug} with optional params type', async () => {
1061
- const result = collectCanonicalRoutesForEntry([
1062
- {
1063
- type: 'nested',
1064
- id: 'layout',
1065
- isRoot: true,
1066
- children: [
1067
- {
1068
- type: 'nested',
1069
- id: '(lang)/layout',
1070
- path: ':lang',
1071
- children: [
1072
- {
1073
- type: 'nested',
1074
- id: '(lang)/optional/(slug$)/page',
1075
- path: 'optional/:slug?',
1076
- },
1077
- ],
1078
- },
1079
- ],
1080
- },
1081
- ] as any);
1082
-
1083
- expect(result).not.toBeNull();
1084
- expect(Object.keys(result!)).toEqual(['/optional/{-$slug}']);
1085
-
1086
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1087
- await typecheckCanonicalRegisterContract({
1088
- canonicalRoutes: result!,
1089
- contractLines: [
1090
- "const omittedOptionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = {};",
1091
- "const presentOptionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = { slug: 'tractor' };",
1092
- '// @ts-expect-error optional canonical params still retain their string type',
1093
- "const invalidOptionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = { slug: 42 };",
1094
- 'void omittedOptionalParams;',
1095
- 'void presentOptionalParams;',
1096
- 'void invalidOptionalParams;',
1097
- ],
1098
- projectDirectory: tempDir,
1099
- });
1100
- });
1101
-
1102
- test('converts * splat to $ with optional _splat param', async () => {
1103
- const result = collectCanonicalRoutesForEntry([
1104
- {
1105
- type: 'nested',
1106
- id: 'layout',
1107
- isRoot: true,
1108
- children: [
1109
- {
1110
- type: 'nested',
1111
- id: '(lang)/layout',
1112
- path: ':lang',
1113
- children: [
1114
- {
1115
- type: 'nested',
1116
- id: '(lang)/files/page',
1117
- path: 'files/*',
1118
- },
1119
- ],
1120
- },
1121
- ],
1122
- },
1123
- ] as any);
1124
-
1125
- expect(result).not.toBeNull();
1126
- expect(Object.keys(result!)).toEqual(['/files/$']);
1127
-
1128
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1129
- await typecheckCanonicalRegisterContract({
1130
- canonicalRoutes: result!,
1131
- contractLines: [
1132
- "const emptySplatParams: UltramodernCanonicalRoutes['/files/$'] = {};",
1133
- "const splatParams: UltramodernCanonicalRoutes['/files/$'] = { _splat: 'guides/intro' };",
1134
- '// @ts-expect-error splat params retain their string type',
1135
- "const invalidSplatParams: UltramodernCanonicalRoutes['/files/$'] = { _splat: 42 };",
1136
- 'void emptySplatParams;',
1137
- 'void splatParams;',
1138
- 'void invalidSplatParams;',
1139
- ],
1140
- projectDirectory: tempDir,
1141
- });
418
+ expect(Object.keys(result!)).toEqual([
419
+ '/',
420
+ '/about',
421
+ '/files/$',
422
+ '/optional/{-$slug}',
423
+ ]);
1142
424
  });
1143
425
 
1144
- test('collapses localized variants with shared modernCanonicalPath to one canonical key', async () => {
1145
- // This reuses the same fixture shape as the 'preserves typed child trees'
1146
- // test but adds modernCanonicalPath fields as plugin-i18n now emits.
426
+ test('collapses localized variants to one canonical key with typed params', async () => {
1147
427
  const result = collectCanonicalRoutesForEntry([
1148
428
  {
1149
429
  type: 'nested',
@@ -1180,69 +460,44 @@ describe('collectCanonicalRoutesForEntry', () => {
1180
460
  ] as any);
1181
461
 
1182
462
  expect(result).not.toBeNull();
1183
- // Two physical variants share the same canonical path — only one entry.
1184
- const keys = Object.keys(result!);
1185
- // /products/$slug and /optional/{-$slug} — exactly 2 keys with params
1186
- expect(keys.filter(k => k.startsWith('/products'))).toHaveLength(1);
1187
- expect(keys).toHaveLength(2);
1188
- expect(keys).toEqual(['/optional/{-$slug}', '/products/$slug']);
1189
- // The Czech localized path must not appear as a separate key.
463
+ expect(Object.keys(result!)).toEqual([
464
+ '/optional/{-$slug}',
465
+ '/products/$slug',
466
+ ]);
1190
467
  expect('/produkty/$slug' in result!).toBe(false);
1191
468
 
1192
469
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1193
470
  await typecheckCanonicalRegisterContract({
1194
- canonicalRoutes: result!,
471
+ canonicalRoutes: {
472
+ '/': 'Record<string, never>',
473
+ '/files/$': '{ _splat?: string }',
474
+ ...result!,
475
+ },
1195
476
  contractLines: [
477
+ "const rootParams: UltramodernCanonicalRoutes['/'] = {};",
478
+ '// @ts-expect-error index canonical routes reject unexpected params',
479
+ "const invalidRootParams: UltramodernCanonicalRoutes['/'] = { slug: 'unexpected' };",
1196
480
  "const productParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 'tractor' };",
1197
481
  "const optionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = {};",
482
+ "const presentOptionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = { slug: 'tractor' };",
483
+ '// @ts-expect-error optional canonical params still retain their string type',
484
+ "const invalidOptionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = { slug: 42 };",
485
+ "const splatParams: UltramodernCanonicalRoutes['/files/$'] = { _splat: 'guides/intro' };",
1198
486
  '// @ts-expect-error localized physical variants do not become canonical keys',
1199
487
  "declare const localizedParams: UltramodernCanonicalRoutes['/produkty/$slug'];",
1200
488
  '// @ts-expect-error the deduplicated product route still requires its param',
1201
489
  "const missingProductParams: UltramodernCanonicalRoutes['/products/$slug'] = {};",
490
+ 'void rootParams;',
1202
491
  'void productParams;',
1203
492
  'void optionalParams;',
493
+ 'void presentOptionalParams;',
494
+ 'void splatParams;',
495
+ 'void invalidRootParams;',
496
+ 'void invalidOptionalParams;',
1204
497
  'void localizedParams;',
1205
498
  'void missingProductParams;',
1206
499
  ],
1207
500
  projectDirectory: tempDir,
1208
501
  });
1209
502
  });
1210
-
1211
- test('output is sorted alphabetically by canonical key', () => {
1212
- const result = collectCanonicalRoutesForEntry([
1213
- {
1214
- type: 'nested',
1215
- id: 'layout',
1216
- isRoot: true,
1217
- children: [
1218
- {
1219
- type: 'nested',
1220
- id: '(lang)/layout',
1221
- path: ':lang',
1222
- children: [
1223
- {
1224
- type: 'nested',
1225
- id: '(lang)/products/(slug)/page',
1226
- path: 'products/:slug',
1227
- },
1228
- {
1229
- type: 'nested',
1230
- id: '(lang)/about/page',
1231
- path: 'about',
1232
- },
1233
- {
1234
- type: 'nested',
1235
- id: '(lang)/page',
1236
- index: true,
1237
- },
1238
- ],
1239
- },
1240
- ],
1241
- },
1242
- ] as any);
1243
-
1244
- expect(result).not.toBeNull();
1245
- const keys = Object.keys(result!);
1246
- expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b)));
1247
- });
1248
503
  });