@bleedingdev/modern-js-plugin-tanstack 3.5.0-ultramodern.99 → 3.8.1-ultramodern.1

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 (55) hide show
  1. package/dist/cjs/cli/index.js +10 -2
  2. package/dist/cjs/cli/tanstackTypes/source.js +9 -20
  3. package/dist/cjs/runtime/plugin.node.js +12 -5
  4. package/dist/cjs/runtime/pluginShared.js +3 -3
  5. package/dist/cjs/runtime/rsc/payloadErrors.js +2 -2
  6. package/dist/cjs/runtime/rsc/shared.js +1 -1
  7. package/dist/cjs/runtime/rsc/treeGetter.js +5 -9
  8. package/dist/cjs/runtime/submitAction.js +4 -5
  9. package/dist/esm/cli/index.mjs +10 -2
  10. package/dist/esm/cli/tanstackTypes/source.mjs +9 -10
  11. package/dist/esm/runtime/plugin.node.mjs +12 -5
  12. package/dist/esm/runtime/pluginShared.mjs +3 -3
  13. package/dist/esm/runtime/rsc/payloadErrors.mjs +1 -1
  14. package/dist/esm/runtime/rsc/shared.mjs +1 -1
  15. package/dist/esm/runtime/rsc/treeGetter.mjs +5 -9
  16. package/dist/esm/runtime/submitAction.mjs +4 -5
  17. package/dist/esm-node/cli/index.mjs +10 -2
  18. package/dist/esm-node/cli/tanstackTypes/source.mjs +9 -10
  19. package/dist/esm-node/runtime/plugin.node.mjs +12 -5
  20. package/dist/esm-node/runtime/pluginShared.mjs +3 -3
  21. package/dist/esm-node/runtime/rsc/payloadErrors.mjs +1 -1
  22. package/dist/esm-node/runtime/rsc/shared.mjs +1 -1
  23. package/dist/esm-node/runtime/rsc/treeGetter.mjs +5 -9
  24. package/dist/esm-node/runtime/submitAction.mjs +4 -5
  25. package/dist/types/runtime/clientHydration.d.ts +1 -1
  26. package/dist/types/runtime/ssrTypes.d.ts +0 -2
  27. package/package.json +27 -20
  28. package/rstest.config.mts +2 -0
  29. package/src/cli/index.ts +12 -0
  30. package/src/cli/tanstackTypes/canonicalRoutes.ts +0 -1
  31. package/src/cli/tanstackTypes/shared.ts +0 -1
  32. package/src/cli/tanstackTypes/source.ts +3 -4
  33. package/src/runtime/plugin.node.tsx +17 -11
  34. package/src/runtime/plugin.tsx +0 -2
  35. package/src/runtime/pluginShared.ts +8 -7
  36. package/src/runtime/rsc/payloadErrors.ts +1 -1
  37. package/src/runtime/rsc/shared.ts +1 -1
  38. package/src/runtime/rsc/treeGetter.tsx +9 -8
  39. package/src/runtime/ssrManagedTags.ts +0 -2
  40. package/src/runtime/ssrTypes.ts +0 -4
  41. package/src/runtime/submitAction.ts +6 -11
  42. package/src/types/react-server-dom-rspack.d.ts +4 -52
  43. package/tests/router/cli.test.ts +204 -96
  44. package/tests/router/clientHydration.test.tsx +140 -1
  45. package/tests/router/dataMutation.test.tsx +50 -9
  46. package/tests/router/fixtures/rscDecoderSecurityChild.cjs +162 -0
  47. package/tests/router/preloadRedirect.test.ts +3 -8
  48. package/tests/router/routeTree.test.ts +73 -19
  49. package/tests/router/routerCompatibility.test.ts +0 -6
  50. package/tests/router/rscDecoderSecurity.test.ts +113 -0
  51. package/tests/router/serverPlugin.test.ts +115 -0
  52. package/tests/router/tanstackTypes.test.ts +668 -356
  53. package/src/runtime/ssr-shim.d.ts +0 -10
  54. package/tests/castRatchet.test.ts +0 -85
  55. package/tests/router/fixtures/router-gen.golden.txt +0 -71
@@ -1,9 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { isRedirect } from '@tanstack/react-router';
7
6
 
8
7
  const resolverDelays = new Map<string, number>();
9
8
 
@@ -30,18 +29,13 @@ rstest.mock('../../src/cli/tanstackTypes/shared', () => {
30
29
  };
31
30
  });
32
31
 
32
+ import { writeTanstackRegisterFile } from '../../src/cli/artifacts';
33
33
  import {
34
34
  collectCanonicalRoutesForEntry,
35
35
  generateTanstackRouterTypesSourceForEntry,
36
36
  } from '../../src/cli/tanstackTypes';
37
- import { throwTanstackRedirect } from '../../src/runtime/loaderBridge';
38
37
 
39
38
  const execFileAsync = promisify(execFile);
40
- const routerGenGoldenPath = path.join(
41
- __dirname,
42
- 'fixtures',
43
- 'router-gen.golden.txt',
44
- );
45
39
  const routerGenOxlintConfigPath = path.join(
46
40
  __dirname,
47
41
  'fixtures',
@@ -51,35 +45,361 @@ const oxlintCliPath = path.resolve(
51
45
  __dirname,
52
46
  '../../../../toolkit/code-tools/node_modules/oxlint/bin/oxlint',
53
47
  );
48
+ const strictestTsconfigPath = path.resolve(
49
+ __dirname,
50
+ '../../node_modules/@tsconfig/strictest/tsconfig.json',
51
+ );
52
+ const installedReactRouterPath = path.resolve(
53
+ __dirname,
54
+ '../../node_modules/@tanstack/react-router',
55
+ );
54
56
 
55
- type RedirectLike = {
56
- options?: {
57
- href?: string;
58
- to?: string;
59
- };
60
- };
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(
134
+ projectDirectory: string,
135
+ ) {
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
+ await writeFile(
192
+ path.join(projectDirectory, 'tsconfig.json'),
193
+ JSON.stringify(
194
+ {
195
+ extends: strictestTsconfigPath,
196
+ compilerOptions: {
197
+ lib: ['ESNext', 'DOM'],
198
+ jsx: 'react-jsx',
199
+ module: 'Node16',
200
+ moduleResolution: 'Node16',
201
+ outDir: 'dist',
202
+ rootDir: 'src',
203
+ target: 'ES2022',
204
+ types: [],
205
+ verbatimModuleSyntax: false,
206
+ },
207
+ include: ['src/**/*.ts', 'src/**/*.tsx'],
208
+ },
209
+ null,
210
+ 2,
211
+ ),
212
+ );
61
213
 
62
- function catchThrown(fn: () => unknown): unknown {
63
214
  try {
64
- fn();
65
- } catch (err) {
66
- return err;
215
+ await execFileAsync(
216
+ process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
217
+ ['-p', 'tsconfig.json'],
218
+ {
219
+ cwd: projectDirectory,
220
+ shell: process.platform === 'win32',
221
+ },
222
+ );
223
+ } 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
+ }
238
+ }
239
+ throw error;
67
240
  }
68
- throw new Error('expected function to throw');
241
+ await execFileAsync(
242
+ process.execPath,
243
+ [path.join(projectDirectory, 'dist', 'runtime-check.js')],
244
+ { cwd: projectDirectory },
245
+ );
69
246
  }
70
247
 
71
- function extractRouteGenerationOrder(source: string) {
72
- return source.split('\n').filter(line => {
73
- return (
74
- line.startsWith('import component_') ||
75
- line.startsWith('const route_') ||
76
- line.includes('component: component_') ||
77
- line.startsWith('export const routeTree')
248
+ async function compileGeneratedRouterAgainstInstalledDeclarations(options: {
249
+ projectDirectory: string;
250
+ routerGenTs: string;
251
+ }) {
252
+ const { projectDirectory, routerGenTs } = options;
253
+ const generatedDirectory = path.join(
254
+ projectDirectory,
255
+ 'src',
256
+ 'modern-tanstack',
257
+ 'golden',
258
+ );
259
+ await mkdir(generatedDirectory, { recursive: true });
260
+ 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' }),
265
+ );
266
+ 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'],
285
+ },
286
+ null,
287
+ 2,
288
+ ),
289
+ );
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
+ },
78
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
+ }
318
+ }
319
+
320
+ async function typecheckCanonicalRegisterContract(options: {
321
+ canonicalRoutes: Record<string, string>;
322
+ contractLines: string[];
323
+ projectDirectory: string;
324
+ }) {
325
+ const { canonicalRoutes, contractLines, projectDirectory } = options;
326
+ 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 });
330
+ await writeFile(
331
+ path.join(routerDirectory, 'router.gen.ts'),
332
+ 'export const router = { context: {} };',
333
+ );
334
+ await writeTanstackRegisterFile({
335
+ canonicalRoutes,
336
+ entries: ['index'],
337
+ srcDirectory,
79
338
  });
339
+ await writeFile(
340
+ path.join(srcDirectory, 'runtime-shims.d.ts'),
341
+ [
342
+ "declare module '@modern-js/plugin-tanstack/runtime' { export interface Register {} }",
343
+ "declare module '@modern-js/plugin-i18n/runtime' { export interface UltramodernCanonicalRoutes {} }",
344
+ ].join('\n'),
345
+ );
346
+ await writeFile(
347
+ path.join(srcDirectory, 'canonical-routes-contract.ts'),
348
+ [
349
+ "import type { UltramodernCanonicalRoutes } from '@modern-js/plugin-i18n/runtime';",
350
+ ...contractLines,
351
+ ].join('\n'),
352
+ );
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
+ }
80
400
  }
81
401
 
82
- async function writeGoldenRouterFixture(srcDirectory: string) {
402
+ async function writeComprehensiveRouterFixture(srcDirectory: string) {
83
403
  const files = new Map([
84
404
  [
85
405
  'routes/(app)/layout.tsx',
@@ -116,8 +436,8 @@ async function writeGoldenRouterFixture(srcDirectory: string) {
116
436
  }
117
437
  }
118
438
 
119
- async function generateGoldenRouterGen(srcDirectory: string) {
120
- await writeGoldenRouterFixture(srcDirectory);
439
+ async function generateComprehensiveRouterGen(srcDirectory: string) {
440
+ await writeComprehensiveRouterFixture(srcDirectory);
121
441
 
122
442
  const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
123
443
  appContext: {
@@ -173,7 +493,79 @@ describe('tanstack router type generation', () => {
173
493
  }
174
494
  });
175
495
 
176
- test('emits inline data actions into route static data', async () => {
496
+ test('strictly compiles and executes a loader-free conventional route tree', async () => {
497
+ 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
+ });
546
+
547
+ await compileAndRunGeneratedRouter({
548
+ projectDirectory: tempDir,
549
+ 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
+ });
566
+ });
567
+
568
+ test('executes generated loader and action wiring through route static data', async () => {
177
569
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
178
570
  const srcDirectory = path.join(tempDir, 'src');
179
571
  const routeDir = path.join(srcDirectory, 'routes', 'mf');
@@ -210,50 +602,46 @@ describe('tanstack router type generation', () => {
210
602
  ] as any,
211
603
  });
212
604
 
213
- expect(routerGenTs).toContain(
214
- 'import { loader as loader_0, action as action_0 } from "../../routes/mf/page.data";',
215
- );
216
- expect(routerGenTs).toContain('modernRouteLoader: loader_0');
217
- expect(routerGenTs).toContain('modernRouteAction: action_0');
218
- expect(routerGenTs).toContain(
219
- "} from '@modern-js/plugin-tanstack/runtime';",
220
- );
221
- expect(routerGenTs).toContain('modernTanstackRouterFastDefaults,');
222
- expect(routerGenTs).toContain('...modernTanstackRouterFastDefaults,');
223
-
224
- // The loader-bridge helpers are imported from the package runtime instead
225
- // of being inlined into every generated file (bugfixes ship via package
226
- // update, and the broken inline absolute-redirect handler is gone).
227
- expect(routerGenTs).toContain('createRouteStaticData,');
228
- expect(routerGenTs).toContain('modernLoaderToTanstack,');
229
- expect(routerGenTs).toContain('type ModernRouterContext,');
230
- expect(routerGenTs).not.toContain('function modernLoaderToTanstack');
231
- expect(routerGenTs).not.toContain('function createRouteStaticData');
232
- expect(routerGenTs).not.toContain('function throwTanstackRedirect');
233
- expect(routerGenTs).not.toContain('new URL(target)');
234
- expect(routerGenTs).not.toContain('redirect({ to: target })');
235
-
236
- const redirectError = catchThrown(() =>
237
- throwTanstackRedirect('https://example.com/absolute'),
238
- ) as RedirectLike;
239
- expect(isRedirect(redirectError)).toBe(true);
240
- expect(redirectError.options?.href).toBe('https://example.com/absolute');
241
- expect(redirectError.options?.to).toBeUndefined();
605
+ await compileAndRunGeneratedRouter({
606
+ 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'),
624
+ });
242
625
  });
243
626
 
244
- test('emits resolvable relative component imports for routes carrying _component', async () => {
627
+ test('executes component sharing correctly when module resolution races', async () => {
245
628
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
246
629
  const srcDirectory = path.join(tempDir, 'src');
630
+
247
631
  for (const componentFile of [
248
- 'routes/layout.tsx',
249
- 'routes/page.tsx',
250
- 'routes/about/page.tsx',
632
+ 'routes/slow-page.tsx',
633
+ 'routes/fast-page.tsx',
634
+ 'routes/shared-page.tsx',
251
635
  ]) {
252
636
  const componentPath = path.join(srcDirectory, componentFile);
253
637
  await mkdir(path.dirname(componentPath), { recursive: true });
254
- await writeFile(componentPath, 'export default () => null;');
638
+ await writeFile(
639
+ componentPath,
640
+ 'export default function Page() { return null; }',
641
+ );
255
642
  }
256
643
 
644
+ resolverDelays.set('/routes/slow-page', 25);
257
645
  const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
258
646
  appContext: {
259
647
  srcDirectory,
@@ -265,29 +653,32 @@ describe('tanstack router type generation', () => {
265
653
  type: 'nested',
266
654
  id: 'layout',
267
655
  isRoot: true,
268
- _component: '@/_/routes/layout',
269
656
  children: [
270
657
  {
271
658
  type: 'nested',
272
- id: 'page',
273
- index: true,
274
- _component: '@/_/routes/page',
659
+ id: 'slow/page',
660
+ path: 'slow',
661
+ _component: '@/_/routes/slow-page',
275
662
  },
276
663
  {
277
664
  type: 'nested',
278
- id: 'about/page',
279
- path: 'about',
280
- _component: '@/_/routes/about/page',
665
+ id: 'fast/page',
666
+ path: 'fast',
667
+ _component: '@/_/routes/fast-page',
281
668
  },
282
669
  {
283
- // Shares the page component module: the import must be reused.
284
670
  type: 'nested',
285
- id: 'about-alias/page',
286
- path: 'about-alias',
287
- _component: '@/_/routes/about/page',
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',
288
680
  },
289
681
  {
290
- // No _component: no component option may be emitted.
291
682
  type: 'nested',
292
683
  id: 'data-only/page',
293
684
  path: 'data-only',
@@ -297,142 +688,73 @@ describe('tanstack router type generation', () => {
297
688
  ] as any,
298
689
  });
299
690
 
300
- // Children are emitted first, the root route component import last.
301
- // Imports are relative (resolved like loader modules) — the raw
302
- // `@/_` internal alias is not mapped by app tsconfigs.
303
- expect(routerGenTs).toContain(
304
- 'import component_0 from "../../routes/page";',
305
- );
306
- expect(routerGenTs).toContain(
307
- 'import component_1 from "../../routes/about/page";',
308
- );
309
- expect(routerGenTs).toContain(
310
- 'import component_2 from "../../routes/layout";',
311
- );
312
- expect(routerGenTs).not.toContain('@/_/routes');
313
- // The shared module is imported exactly once.
314
- expect(routerGenTs).not.toContain('component_3');
315
- expect(
316
- routerGenTs.match(/from "\.\.\/\.\.\/routes\/about\/page";/g),
317
- ).toHaveLength(1);
318
-
319
- expect(routerGenTs).toContain('component: component_0,');
320
- // The shared component import is referenced by both aliased routes.
321
- expect(routerGenTs.match(/component: component_1,/g)).toHaveLength(2);
322
- // The root route gets its component option.
323
- expect(routerGenTs).toContain('component: component_2,');
324
-
325
- const dataOnlyRoute = routerGenTs
326
- .split('const ')
327
- .find(block => block.includes('path: "data-only",'));
328
- expect(dataOnlyRoute).toBeDefined();
329
- expect(dataOnlyRoute).not.toContain('component:');
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
+ });
330
707
  });
331
708
 
332
- test('keeps generated source stable when route module resolution races', async () => {
709
+ test('strictly compiles and executes nested loader and search contracts', async () => {
333
710
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
334
711
  const srcDirectory = path.join(tempDir, 'src');
712
+ const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
335
713
 
336
- for (const componentFile of [
337
- 'routes/slow-page.tsx',
338
- 'routes/fast-page.tsx',
339
- 'routes/shared-page.tsx',
340
- ]) {
341
- const componentPath = path.join(srcDirectory, componentFile);
342
- await mkdir(path.dirname(componentPath), { recursive: true });
343
- await writeFile(
344
- componentPath,
345
- 'export default function Page() { return null; }',
346
- );
347
- }
348
-
349
- const routes = [
350
- {
351
- type: 'nested',
352
- id: 'layout',
353
- isRoot: true,
354
- children: [
355
- {
356
- type: 'nested',
357
- id: 'slow/page',
358
- path: 'slow',
359
- _component: '@/_/routes/slow-page',
360
- },
361
- {
362
- type: 'nested',
363
- id: 'fast/page',
364
- path: 'fast',
365
- _component: '@/_/routes/fast-page',
366
- },
367
- {
368
- type: 'nested',
369
- id: 'shared-a/page',
370
- path: 'shared-a',
371
- _component: '@/_/routes/shared-page',
372
- },
373
- {
374
- type: 'nested',
375
- id: 'shared-b/page',
376
- path: 'shared-b',
377
- _component: '@/_/routes/shared-page',
378
- },
379
- ],
380
- },
381
- ] as any;
382
-
383
- const generateWithResolverDelays = async (
384
- delays: Record<string, number>,
385
- ) => {
386
- resolverDelays.clear();
387
- for (const [suffix, delay] of Object.entries(delays)) {
388
- resolverDelays.set(suffix, delay);
389
- }
390
-
391
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
392
- appContext: {
393
- srcDirectory,
394
- internalSrcAlias: '@/_',
395
- } as any,
396
- entryName: 'index',
397
- routes,
398
- });
399
-
400
- return routerGenTs;
401
- };
402
-
403
- const sequentialOutput = await generateWithResolverDelays({});
404
- const racedOutput = await generateWithResolverDelays({
405
- '/routes/slow-page': 25,
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'),
406
740
  });
407
-
408
- expect(
409
- racedOutput.match(/from "\.\.\/\.\.\/routes\/shared-page";/g),
410
- ).toHaveLength(1);
411
- expect(extractRouteGenerationOrder(racedOutput)).toEqual(
412
- extractRouteGenerationOrder(sequentialOutput),
413
- );
414
- expect(racedOutput).toBe(sequentialOutput);
415
741
  });
416
742
 
417
- test('matches checked-in router.gen golden output', async () => {
743
+ test('strictly compiles generated routes against installed router declarations', async () => {
418
744
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
419
745
  const srcDirectory = path.join(tempDir, 'src');
420
- const routerGenTs = await generateGoldenRouterGen(srcDirectory);
421
-
422
- if (process.env.UPDATE_TANSTACK_ROUTER_GOLDEN === '1') {
423
- await mkdir(path.dirname(routerGenGoldenPath), { recursive: true });
424
- await writeFile(routerGenGoldenPath, routerGenTs);
425
- }
746
+ const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
426
747
 
427
- await expect(readFile(routerGenGoldenPath, 'utf8')).resolves.toBe(
748
+ await compileGeneratedRouterAgainstInstalledDeclarations({
749
+ projectDirectory: tempDir,
428
750
  routerGenTs,
429
- );
751
+ });
430
752
  });
431
753
 
432
- test('emits suppression-free router source that passes the generated-artifact lint fixture', async () => {
754
+ test('passes the generated-artifact lint contract', async () => {
433
755
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
434
756
  const srcDirectory = path.join(tempDir, 'src');
435
- const routerGenTs = await generateGoldenRouterGen(srcDirectory);
757
+ const routerGenTs = await generateComprehensiveRouterGen(srcDirectory);
436
758
  const routerGenPath = path.join(
437
759
  srcDirectory,
438
760
  'modern-tanstack',
@@ -443,8 +765,6 @@ describe('tanstack router type generation', () => {
443
765
  await mkdir(path.dirname(routerGenPath), { recursive: true });
444
766
  await writeFile(routerGenPath, routerGenTs);
445
767
 
446
- expect(routerGenTs).not.toContain('eslint-disable');
447
-
448
768
  const { stderr, stdout } = await execFileAsync(
449
769
  process.execPath,
450
770
  [
@@ -464,130 +784,7 @@ describe('tanstack router type generation', () => {
464
784
  expect(`${stdout}${stderr}`).toBe('');
465
785
  });
466
786
 
467
- test('typechecks generated TanStack search contracts', async () => {
468
- tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
469
- const srcDirectory = path.join(tempDir, 'src');
470
- const routeDir = path.join(srcDirectory, 'routes');
471
- const generatedDir = path.join(srcDirectory, 'modern-tanstack', 'index');
472
- await mkdir(routeDir, { recursive: true });
473
- await mkdir(generatedDir, { recursive: true });
474
- await writeFile(
475
- path.join(routeDir, 'search.contract.ts'),
476
- [
477
- 'export const validateSearch = (search: { q?: string }) => ({ q: search.q ?? "" });',
478
- 'export const loaderDeps = ({ search }: { search: { q: string } }) => ({ q: search.q });',
479
- ].join('\n'),
480
- );
481
-
482
- const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({
483
- appContext: {
484
- srcDirectory,
485
- internalSrcAlias: '@/_',
486
- } as any,
487
- entryName: 'index',
488
- routes: [
489
- {
490
- type: 'nested',
491
- id: 'layout',
492
- isRoot: true,
493
- validateSearch: '@/_/routes/search.contract',
494
- loaderDeps: '@/_/routes/search.contract',
495
- children: [
496
- {
497
- type: 'nested',
498
- id: 'search/page',
499
- path: 'search',
500
- validateSearch: '@/_/routes/search.contract',
501
- loaderDeps: '@/_/routes/search.contract',
502
- },
503
- ],
504
- },
505
- ] as any,
506
- });
507
-
508
- await writeFile(path.join(generatedDir, 'router.gen.ts'), routerGenTs);
509
- await writeFile(
510
- path.join(srcDirectory, 'runtime-shim.d.ts'),
511
- [
512
- "declare module '@modern-js/plugin-tanstack/runtime' {",
513
- ' type RouteOptions = {',
514
- ' getParentRoute?: () => unknown;',
515
- ' id?: string;',
516
- ' loader?: unknown;',
517
- ' loaderDeps?: unknown;',
518
- ' path?: string;',
519
- ' staticData?: unknown;',
520
- ' validateSearch?: unknown;',
521
- ' };',
522
- ' type Route<TOptions extends RouteOptions> = {',
523
- ' options: TOptions;',
524
- ' addChildren<TChildren extends readonly unknown[]>(children: TChildren): Route<TOptions> & { children: TChildren };',
525
- ' };',
526
- ' export type ModernRouterContext = {',
527
- ' request?: Request;',
528
- ' requestContext?: unknown;',
529
- ' };',
530
- ' export function createMemoryHistory(options: unknown): unknown;',
531
- ' export const modernTanstackRouterFastDefaults: Record<string, unknown>;',
532
- ' export function createRootRouteWithContext<TContext>(): <TOptions extends RouteOptions>(options: TOptions) => Route<TOptions>;',
533
- ' export function createRoute<TOptions extends RouteOptions>(options: TOptions): Route<TOptions>;',
534
- ' export function createRouter<TOptions extends Record<string, unknown>>(options: TOptions): TOptions;',
535
- ' export function createRouteStaticData(opts: { modernRouteId?: string; modernRouteAction?: unknown; modernRouteLoader?: unknown }): Record<string, unknown>;',
536
- ' export function modernLoaderToTanstack<TLoader extends (args: never) => unknown>(opts: { hasSplat: boolean }, modernLoader: TLoader): (ctx: unknown) => Promise<Awaited<ReturnType<TLoader>>>;',
537
- '}',
538
- ].join('\n'),
539
- );
540
- await writeFile(
541
- path.join(srcDirectory, 'assert-search-contracts.ts'),
542
- [
543
- "import { rootRoute, routeTree } from './modern-tanstack/index/router.gen';",
544
- "import { loaderDeps, validateSearch } from './routes/search.contract';",
545
- '',
546
- 'const rootValidateSearch: typeof validateSearch = rootRoute.options.validateSearch;',
547
- 'const rootLoaderDeps: typeof loaderDeps = rootRoute.options.loaderDeps;',
548
- 'const childValidateSearch: typeof validateSearch = routeTree.children[0].options.validateSearch;',
549
- 'const childLoaderDeps: typeof loaderDeps = routeTree.children[0].options.loaderDeps;',
550
- '',
551
- "rootValidateSearch({ q: 'root' });",
552
- "rootLoaderDeps({ search: { q: 'root' } });",
553
- "childValidateSearch({ q: 'child' });",
554
- "childLoaderDeps({ search: { q: 'child' } });",
555
- ].join('\n'),
556
- );
557
- await writeFile(
558
- path.join(tempDir, 'tsconfig.json'),
559
- JSON.stringify(
560
- {
561
- compilerOptions: {
562
- lib: ['ESNext', 'DOM'],
563
- module: 'Preserve',
564
- moduleResolution: 'Bundler',
565
- noEmit: true,
566
- skipLibCheck: true,
567
- strict: true,
568
- target: 'ESNext',
569
- types: [],
570
- },
571
- include: ['src/**/*.ts', 'src/**/*.d.ts'],
572
- },
573
- null,
574
- 2,
575
- ),
576
- );
577
-
578
- await expect(
579
- execFileAsync(
580
- process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
581
- ['--noEmit', '-p', 'tsconfig.json'],
582
- {
583
- cwd: tempDir,
584
- shell: process.platform === 'win32',
585
- },
586
- ),
587
- ).resolves.toBeDefined();
588
- });
589
-
590
- test('preserves typed child trees for localized nested route aliases', async () => {
787
+ test('executes localized aliases with typed children and correct parents', async () => {
591
788
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-'));
592
789
  const srcDirectory = path.join(tempDir, 'src');
593
790
 
@@ -630,25 +827,36 @@ describe('tanstack router type generation', () => {
630
827
  ] as any,
631
828
  });
632
829
 
633
- expect(routerGenTs).toContain(
634
- 'const route__lang__layout__base = createRoute({',
635
- );
636
- expect(routerGenTs).toContain(
637
- 'getParentRoute: () => route__lang__layout__base,',
638
- );
639
- expect(routerGenTs).toContain('path: "produkty/$slug",');
640
- expect(routerGenTs).toContain('path: "volitelne/{-$slug}",');
641
- expect(routerGenTs).toContain(
642
- 'const route__lang__layout = route__lang__layout__base.addChildren([route__lang__products__slug__page, route__lang__products__slug__page__localised_produkty_slug, route__lang__optional__slug$__page__localised_volitelne_slug]);',
643
- );
644
- expect(routerGenTs).toContain(
645
- 'export const routeTree = rootRoute.addChildren([route__lang__layout]);',
646
- );
647
- expect(routerGenTs).not.toContain('route__lang__layout.addChildren([');
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
+ });
648
847
  });
649
848
  });
650
849
 
651
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
+
652
860
  test('returns null for a route tree with no locale param and no canonical metadata', () => {
653
861
  const result = collectCanonicalRoutesForEntry([
654
862
  {
@@ -673,7 +881,7 @@ describe('collectCanonicalRoutesForEntry', () => {
673
881
  expect(result).toBeNull();
674
882
  });
675
883
 
676
- test('ignores a leading :lang param when the locale-param heuristic is disabled (no plugin-i18n)', () => {
884
+ test('ignores a leading :lang param when the locale-param heuristic is disabled (no plugin-i18n)', async () => {
677
885
  const routes = [
678
886
  {
679
887
  type: 'nested',
@@ -702,14 +910,27 @@ describe('collectCanonicalRoutesForEntry', () => {
702
910
  collectCanonicalRoutesForEntry(routes, { localeParamHeuristic: false }),
703
911
  ).toBeNull();
704
912
  // With plugin-i18n installed the heuristic strips the locale prefix.
705
- expect(
706
- collectCanonicalRoutesForEntry(routes, { localeParamHeuristic: true }),
707
- ).toEqual({
708
- '/about': 'Record<string, never>',
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,
709
930
  });
710
931
  });
711
932
 
712
- test('still honors modernCanonicalPath metadata when the heuristic is disabled', () => {
933
+ test('still honors modernCanonicalPath metadata when the heuristic is disabled', async () => {
713
934
  const result = collectCanonicalRoutesForEntry(
714
935
  [
715
936
  {
@@ -730,10 +951,26 @@ describe('collectCanonicalRoutesForEntry', () => {
730
951
  );
731
952
 
732
953
  expect(result).not.toBeNull();
733
- expect(result!['/products/$slug']).toBe('{ "slug": string }');
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
+ });
734
971
  });
735
972
 
736
- test('strips leading :lang param and maps index under :lang to "/"', () => {
973
+ test('strips leading :lang param and maps index under :lang to "/"', async () => {
737
974
  const result = collectCanonicalRoutesForEntry([
738
975
  {
739
976
  type: 'nested',
@@ -762,11 +999,25 @@ describe('collectCanonicalRoutesForEntry', () => {
762
999
  ] as any);
763
1000
 
764
1001
  expect(result).not.toBeNull();
765
- expect(result!['/']).toBe('Record<string, never>');
766
- expect(result!['/about']).toBe('Record<string, never>');
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
+ });
767
1018
  });
768
1019
 
769
- test('converts :slug to $slug with required params type', () => {
1020
+ test('converts :slug to $slug with required params type', async () => {
770
1021
  const result = collectCanonicalRoutesForEntry([
771
1022
  {
772
1023
  type: 'nested',
@@ -790,10 +1041,23 @@ describe('collectCanonicalRoutesForEntry', () => {
790
1041
  ] as any);
791
1042
 
792
1043
  expect(result).not.toBeNull();
793
- expect(result!['/products/$slug']).toBe('{ "slug": string }');
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
+ });
794
1058
  });
795
1059
 
796
- test('converts :slug? to optional {-$slug} with optional params type', () => {
1060
+ test('converts :slug? to optional {-$slug} with optional params type', async () => {
797
1061
  const result = collectCanonicalRoutesForEntry([
798
1062
  {
799
1063
  type: 'nested',
@@ -817,10 +1081,25 @@ describe('collectCanonicalRoutesForEntry', () => {
817
1081
  ] as any);
818
1082
 
819
1083
  expect(result).not.toBeNull();
820
- expect(result!['/optional/{-$slug}']).toBe('{ "slug"?: string }');
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
+ });
821
1100
  });
822
1101
 
823
- test('converts * splat to $ with optional _splat param', () => {
1102
+ test('converts * splat to $ with optional _splat param', async () => {
824
1103
  const result = collectCanonicalRoutesForEntry([
825
1104
  {
826
1105
  type: 'nested',
@@ -844,10 +1123,25 @@ describe('collectCanonicalRoutesForEntry', () => {
844
1123
  ] as any);
845
1124
 
846
1125
  expect(result).not.toBeNull();
847
- expect(result!['/files/$']).toBe("{ '_splat'?: string }");
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
+ });
848
1142
  });
849
1143
 
850
- test('collapses localized variants with shared modernCanonicalPath to one canonical key', () => {
1144
+ test('collapses localized variants with shared modernCanonicalPath to one canonical key', async () => {
851
1145
  // This reuses the same fixture shape as the 'preserves typed child trees'
852
1146
  // test but adds modernCanonicalPath fields as plugin-i18n now emits.
853
1147
  const result = collectCanonicalRoutesForEntry([
@@ -890,10 +1184,28 @@ describe('collectCanonicalRoutesForEntry', () => {
890
1184
  const keys = Object.keys(result!);
891
1185
  // /products/$slug and /optional/{-$slug} — exactly 2 keys with params
892
1186
  expect(keys.filter(k => k.startsWith('/products'))).toHaveLength(1);
893
- expect(result!['/products/$slug']).toBe('{ "slug": string }');
894
- expect(result!['/optional/{-$slug}']).toBe('{ "slug"?: string }');
1187
+ expect(keys).toHaveLength(2);
1188
+ expect(keys).toEqual(['/optional/{-$slug}', '/products/$slug']);
895
1189
  // The Czech localized path must not appear as a separate key.
896
1190
  expect('/produkty/$slug' in result!).toBe(false);
1191
+
1192
+ tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-canonical-'));
1193
+ await typecheckCanonicalRegisterContract({
1194
+ canonicalRoutes: result!,
1195
+ contractLines: [
1196
+ "const productParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 'tractor' };",
1197
+ "const optionalParams: UltramodernCanonicalRoutes['/optional/{-$slug}'] = {};",
1198
+ '// @ts-expect-error localized physical variants do not become canonical keys',
1199
+ "declare const localizedParams: UltramodernCanonicalRoutes['/produkty/$slug'];",
1200
+ '// @ts-expect-error the deduplicated product route still requires its param',
1201
+ "const missingProductParams: UltramodernCanonicalRoutes['/products/$slug'] = {};",
1202
+ 'void productParams;',
1203
+ 'void optionalParams;',
1204
+ 'void localizedParams;',
1205
+ 'void missingProductParams;',
1206
+ ],
1207
+ projectDirectory: tempDir,
1208
+ });
897
1209
  });
898
1210
 
899
1211
  test('output is sorted alphabetically by canonical key', () => {