@bleedingdev/modern-js-plugin-tanstack 3.5.0-ultramodern.98 → 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,6 +1,8 @@
1
- import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
2
3
  import { tmpdir } from 'node:os';
3
4
  import path from 'node:path';
5
+ import { promisify } from 'node:util';
4
6
  import { mergeConfig } from '@modern-js/plugin/cli';
5
7
  import type { Entrypoint } from '@modern-js/types';
6
8
  import { fs, NESTED_ROUTE_SPEC_FILE } from '@modern-js/utils';
@@ -12,6 +14,119 @@ import {
12
14
  writeTanstackRouterTypesForEntries,
13
15
  } from '../../src/cli';
14
16
 
17
+ const execFileAsync = promisify(execFile);
18
+ const strictestTsconfigPath = path.resolve(
19
+ __dirname,
20
+ '../../node_modules/@tsconfig/strictest/tsconfig.json',
21
+ );
22
+
23
+ async function typecheckGeneratedRegistration(options: {
24
+ entries: string[];
25
+ generatedDirName: string;
26
+ srcDirectory: string;
27
+ runtimeModule?: string;
28
+ i18nRuntimeModule?: string;
29
+ canonicalTypeChecks?: string[];
30
+ }) {
31
+ const {
32
+ entries,
33
+ generatedDirName,
34
+ srcDirectory,
35
+ runtimeModule = '@modern-js/plugin-tanstack/runtime',
36
+ i18nRuntimeModule,
37
+ canonicalTypeChecks = [],
38
+ } = options;
39
+ const projectDirectory = path.dirname(srcDirectory);
40
+
41
+ for (const entry of entries) {
42
+ const routerPath = path.join(
43
+ srcDirectory,
44
+ generatedDirName,
45
+ entry,
46
+ 'router.gen.ts',
47
+ );
48
+ if (!(await fs.pathExists(routerPath))) {
49
+ await fs.outputFile(routerPath, 'export const router = { context: {} };');
50
+ }
51
+ }
52
+
53
+ const runtimeDeclaration = [
54
+ `declare module '${runtimeModule}' {`,
55
+ ' export interface Register {}',
56
+ ' export type ModernRouterContext = { request?: Request; requestContext?: unknown };',
57
+ ' type RouteOptions = { getParentRoute?: (...args: never[]) => unknown; id?: string; loader?: (...args: never[]) => unknown; path?: string; staticData?: unknown };',
58
+ ' type Route<TOptions extends RouteOptions = RouteOptions> = { options: TOptions; addChildren<const TChildren extends readonly unknown[]>(children: TChildren): Route<TOptions> & { children: TChildren } };',
59
+ ' export function createMemoryHistory<TOptions>(options: TOptions): TOptions;',
60
+ ' export function createRootRouteWithContext<TContext extends ModernRouterContext>(): <const TOptions extends RouteOptions>(options: TOptions) => Route<TOptions>;',
61
+ ' export function createRoute<const TOptions extends RouteOptions>(options: TOptions): Route<TOptions>;',
62
+ ' export function createRouter<const TOptions extends { context: ModernRouterContext; routeTree: unknown }>(options: TOptions): TOptions;',
63
+ ' export function createRouteStaticData<const TData extends Record<string, unknown>>(data: TData): TData;',
64
+ ' export function modernLoaderToTanstack<TLoader extends (...args: never[]) => unknown>(options: { hasSplat: boolean }, loader: TLoader): (context: unknown) => Promise<Awaited<ReturnType<TLoader>>>;',
65
+ ' export const modernTanstackRouterFastDefaults: Record<string, unknown>;',
66
+ '}',
67
+ ];
68
+ if (i18nRuntimeModule) {
69
+ runtimeDeclaration.push(
70
+ `declare module '${i18nRuntimeModule}' { export interface UltramodernCanonicalRoutes {} }`,
71
+ );
72
+ }
73
+ await fs.outputFile(
74
+ path.join(srcDirectory, 'generated-runtime-shim.d.ts'),
75
+ runtimeDeclaration.join('\n'),
76
+ );
77
+
78
+ const contractLines = [
79
+ `import type { Register } from '${runtimeModule}';`,
80
+ ...entries.map(
81
+ (entry, index) =>
82
+ `import { router as router${index} } from './${generatedDirName}/${entry}/router.gen';`,
83
+ ),
84
+ ...entries.map(
85
+ (_, index) =>
86
+ `const registeredRouter${index}: Register['router'] = router${index};`,
87
+ ),
88
+ ...entries.map((_, index) => `void registeredRouter${index};`),
89
+ ...canonicalTypeChecks,
90
+ ];
91
+ await fs.outputFile(
92
+ path.join(srcDirectory, 'registration-contract.ts'),
93
+ contractLines.join('\n'),
94
+ );
95
+ await fs.outputJSON(path.join(projectDirectory, 'tsconfig.json'), {
96
+ extends: strictestTsconfigPath,
97
+ compilerOptions: {
98
+ lib: ['ESNext', 'DOM'],
99
+ module: 'Preserve',
100
+ moduleResolution: 'Bundler',
101
+ noEmit: true,
102
+ target: 'ESNext',
103
+ types: [],
104
+ },
105
+ include: ['src/**/*.ts', 'src/**/*.d.ts'],
106
+ });
107
+
108
+ try {
109
+ await execFileAsync(
110
+ process.platform === 'win32' ? 'tsgo.cmd' : 'tsgo',
111
+ ['-p', 'tsconfig.json'],
112
+ {
113
+ cwd: projectDirectory,
114
+ shell: process.platform === 'win32',
115
+ },
116
+ );
117
+ } catch (error) {
118
+ if (
119
+ error &&
120
+ typeof error === 'object' &&
121
+ 'stdout' in error &&
122
+ typeof error.stdout === 'string'
123
+ ) {
124
+ throw new Error(error.stdout, { cause: error });
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+
15
130
  const runtimeCliMocks = {
16
131
  handleFileChange: rstest.fn(),
17
132
  handleGeneratorEntryCode: rstest.fn(),
@@ -86,7 +201,7 @@ describe('tanstack router cli plugin', () => {
86
201
  }
87
202
  });
88
203
 
89
- test('writes plugin-owned router types and register metadata', async () => {
204
+ test('typechecks plugin-owned routers and register metadata under strictest', async () => {
90
205
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
91
206
  const srcDirectory = path.join(tempDir, 'src');
92
207
  await mkdir(srcDirectory, { recursive: true });
@@ -102,33 +217,33 @@ describe('tanstack router cli plugin', () => {
102
217
  } as any,
103
218
  generatedDirName: 'generated-router',
104
219
  routesByEntry: {
105
- dashboard: [],
106
- main: [],
220
+ dashboard: [
221
+ {
222
+ type: 'nested',
223
+ id: 'layout',
224
+ isRoot: true,
225
+ children: [{ type: 'nested', id: 'page', index: true }],
226
+ },
227
+ ],
228
+ main: [
229
+ {
230
+ type: 'nested',
231
+ id: 'layout',
232
+ isRoot: true,
233
+ children: [{ type: 'nested', id: 'page', index: true }],
234
+ },
235
+ ],
107
236
  },
108
237
  });
109
238
 
110
- const mainRouter = await readFile(
111
- path.join(srcDirectory, 'generated-router', 'main', 'router.gen.ts'),
112
- 'utf-8',
113
- );
114
- expect(mainRouter).toContain(
115
- "} from '@modern-js/plugin-tanstack/runtime';",
116
- );
117
-
118
- const register = await readFile(
119
- path.join(srcDirectory, 'generated-router', 'register.gen.d.ts'),
120
- 'utf-8',
121
- );
122
- expect(register).toContain("from './main/router.gen'");
123
- expect(register.indexOf("from './main/router.gen'")).toBeLessThan(
124
- register.indexOf("from './dashboard/router.gen'"),
125
- );
126
- expect(register).toContain(
127
- "declare module '@modern-js/plugin-tanstack/runtime'",
128
- );
239
+ await typecheckGeneratedRegistration({
240
+ entries: ['main', 'dashboard'],
241
+ generatedDirName: 'generated-router',
242
+ srcDirectory,
243
+ });
129
244
  });
130
245
 
131
- test('can write register metadata without routes for custom entry lists', async () => {
246
+ test('typechecks register metadata for custom entry lists without routes', async () => {
132
247
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
133
248
  const srcDirectory = path.join(tempDir, 'src');
134
249
 
@@ -138,17 +253,14 @@ describe('tanstack router cli plugin', () => {
138
253
  srcDirectory,
139
254
  });
140
255
 
141
- const register = await readFile(
142
- path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
143
- 'utf-8',
144
- );
145
- expect(register).toContain('router: typeof router0');
146
- expect(register).toContain(
147
- "declare module '@modern-js/plugin-tanstack/runtime'",
148
- );
256
+ await typecheckGeneratedRegistration({
257
+ entries: ['main'],
258
+ generatedDirName: 'tanstack',
259
+ srcDirectory,
260
+ });
149
261
  });
150
262
 
151
- test('writes plugin-i18n module augmentation when canonicalRoutes are provided', async () => {
263
+ test('exposes canonical route params through plugin-i18n types', async () => {
152
264
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
153
265
  const srcDirectory = path.join(tempDir, 'src');
154
266
 
@@ -162,20 +274,22 @@ describe('tanstack router cli plugin', () => {
162
274
  },
163
275
  });
164
276
 
165
- const register = await readFile(
166
- path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
167
- 'utf-8',
168
- );
169
-
170
- expect(register).toContain(
171
- "declare module '@modern-js/plugin-i18n/runtime'",
172
- );
173
- expect(register).toContain('interface UltramodernCanonicalRoutes');
174
- expect(register).toContain("'/': Record<string, never>;");
175
- expect(register).toContain('\'/products/$slug\': { "slug": string };');
277
+ await typecheckGeneratedRegistration({
278
+ entries: ['main'],
279
+ generatedDirName: 'tanstack',
280
+ srcDirectory,
281
+ i18nRuntimeModule: '@modern-js/plugin-i18n/runtime',
282
+ canonicalTypeChecks: [
283
+ "import type { UltramodernCanonicalRoutes } from '@modern-js/plugin-i18n/runtime';",
284
+ "const rootParams: UltramodernCanonicalRoutes['/'] = {};",
285
+ "const productParams: UltramodernCanonicalRoutes['/products/$slug'] = { slug: 'tractor' };",
286
+ 'void rootParams;',
287
+ 'void productParams;',
288
+ ],
289
+ });
176
290
  });
177
291
 
178
- test('does not emit plugin-i18n augmentation when canonicalRoutes is absent (back-compat)', async () => {
292
+ test('typechecks a plain TanStack register without plugin-i18n installed', async () => {
179
293
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
180
294
  const srcDirectory = path.join(tempDir, 'src');
181
295
 
@@ -183,23 +297,16 @@ describe('tanstack router cli plugin', () => {
183
297
  entries: ['main'],
184
298
  generatedDirName: 'tanstack',
185
299
  srcDirectory,
186
- // No canonicalRoutes provided at all — plain TanStack app
187
300
  });
188
301
 
189
- const register = await readFile(
190
- path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
191
- 'utf-8',
192
- );
193
-
194
- expect(register).not.toContain('plugin-i18n');
195
- expect(register).not.toContain('UltramodernCanonicalRoutes');
196
- // But the standard TanStack runtime augmentation must still be present.
197
- expect(register).toContain(
198
- "declare module '@modern-js/plugin-tanstack/runtime'",
199
- );
302
+ await typecheckGeneratedRegistration({
303
+ entries: ['main'],
304
+ generatedDirName: 'tanstack',
305
+ srcDirectory,
306
+ });
200
307
  });
201
308
 
202
- test('uses a custom i18nRuntimeModule when specified', async () => {
309
+ test('exposes canonical route params through a custom i18n runtime module', async () => {
203
310
  tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-'));
204
311
  const srcDirectory = path.join(tempDir, 'src');
205
312
 
@@ -213,15 +320,17 @@ describe('tanstack router cli plugin', () => {
213
320
  i18nRuntimeModule: '@my-org/i18n/runtime',
214
321
  });
215
322
 
216
- const register = await readFile(
217
- path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'),
218
- 'utf-8',
219
- );
220
-
221
- expect(register).toContain("declare module '@my-org/i18n/runtime'");
222
- expect(register).not.toContain(
223
- "declare module '@modern-js/plugin-i18n/runtime'",
224
- );
323
+ await typecheckGeneratedRegistration({
324
+ entries: ['main'],
325
+ generatedDirName: 'tanstack',
326
+ srcDirectory,
327
+ i18nRuntimeModule: '@my-org/i18n/runtime',
328
+ canonicalTypeChecks: [
329
+ "import type { UltramodernCanonicalRoutes } from '@my-org/i18n/runtime';",
330
+ "const talksParams: UltramodernCanonicalRoutes['/talks'] = {};",
331
+ 'void talksParams;',
332
+ ],
333
+ });
225
334
  });
226
335
 
227
336
  test('claims custom routes, injects runtime plugin, and merges route specs', async () => {
@@ -576,32 +685,36 @@ describe('tanstack router cli plugin', () => {
576
685
  tanstackRouterPlugin().setup!(api as any);
577
686
  await taps.generateEntryCode({ entrypoints: [entrypoint] });
578
687
 
579
- const register = await readFile(
580
- path.join(srcDirectory, 'modern-tanstack', 'register.gen.d.ts'),
581
- 'utf-8',
688
+ const hasI18n = registeredPlugins.some(
689
+ plugin => plugin.name === '@modern-js/plugin-i18n',
582
690
  );
691
+ await typecheckGeneratedRegistration({
692
+ entries: ['main'],
693
+ generatedDirName: 'modern-tanstack',
694
+ srcDirectory,
695
+ ...(hasI18n && {
696
+ i18nRuntimeModule: '@modern-js/plugin-i18n/runtime',
697
+ }),
698
+ canonicalTypeChecks: hasI18n
699
+ ? [
700
+ "import type { UltramodernCanonicalRoutes } from '@modern-js/plugin-i18n/runtime';",
701
+ "const aboutParams: UltramodernCanonicalRoutes['/about'] = {};",
702
+ 'void aboutParams;',
703
+ ]
704
+ : [],
705
+ });
583
706
  await rm(dir, { recursive: true, force: true });
584
- return register;
585
707
  };
586
708
 
587
709
  // A hand-rolled `/:lang/` app WITHOUT plugin-i18n must not get the
588
710
  // augmentation — it would reference an unresolvable module (TS2664).
589
- const withoutI18n = await runGenerate([{ name: '@modern-js/app-tools' }]);
590
- expect(withoutI18n).not.toContain('plugin-i18n');
591
- expect(withoutI18n).not.toContain('UltramodernCanonicalRoutes');
592
- expect(withoutI18n).toContain(
593
- "declare module '@modern-js/plugin-tanstack/runtime'",
594
- );
711
+ await runGenerate([{ name: '@modern-js/app-tools' }]);
595
712
 
596
713
  // With plugin-i18n registered the canonical route map is emitted.
597
- const withI18n = await runGenerate([
714
+ await runGenerate([
598
715
  { name: '@modern-js/app-tools' },
599
716
  { name: '@modern-js/plugin-i18n' },
600
717
  ]);
601
- expect(withI18n).toContain(
602
- "declare module '@modern-js/plugin-i18n/runtime'",
603
- );
604
- expect(withI18n).toContain("'/about': Record<string, never>;");
605
718
  });
606
719
 
607
720
  test('generates plugin-owned TanStack route files through core route generation', async () => {
@@ -689,23 +802,18 @@ describe('tanstack router cli plugin', () => {
689
802
  [entrypoint],
690
803
  {
691
804
  entrypointsKey: '@modern-js/plugin-tanstack',
805
+ hydrateRscClientRoutes: true,
806
+ includeRouteServerLoadersInSsrEntry: false,
807
+ isolateRouteDataInRscLayer: true,
808
+ serverRoutesFileName: 'tanstack-routes.server.js',
692
809
  },
693
810
  );
694
811
 
695
- const routerGen = await readFile(
696
- path.join(srcDirectory, 'tanstack-generated', 'main', 'router.gen.ts'),
697
- 'utf-8',
698
- );
699
- expect(routerGen).toContain("} from '@modern-js/plugin-tanstack/runtime';");
700
- expect(routerGen).toContain('modernRouteAction: action_0');
701
-
702
- const register = await readFile(
703
- path.join(srcDirectory, 'tanstack-generated', 'register.gen.d.ts'),
704
- 'utf-8',
705
- );
706
- expect(register).toContain(
707
- "declare module '@modern-js/plugin-tanstack/runtime'",
708
- );
812
+ await typecheckGeneratedRegistration({
813
+ entries: ['main'],
814
+ generatedDirName: 'tanstack-generated',
815
+ srcDirectory,
816
+ });
709
817
  });
710
818
 
711
819
  test('regenerates plugin-owned TanStack files for scoped file changes', async () => {
@@ -1,9 +1,13 @@
1
1
  import type { AnyRouter } from '@tanstack/react-router';
2
- import { render, screen } from '@testing-library/react';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
3
  import React, { Suspense } from 'react';
4
4
  import { ModernRouterClient } from '../../src/runtime/clientHydration';
5
+ import { createTreeGetterFromFlightStream } from '../../src/runtime/rsc/treeGetter';
5
6
 
6
7
  let hydrateImpl: () => Promise<unknown>;
8
+ let createFromReadableStreamImpl: () => Promise<unknown>;
9
+ let registeredCallServer: unknown;
10
+ let decoderCallServer: unknown;
7
11
 
8
12
  rstest.mock('@tanstack/react-router', () => ({
9
13
  RouterProvider: ({ router }: { router: { label: string } }) => (
@@ -15,6 +19,24 @@ rstest.mock('@tanstack/react-router/ssr/client', () => ({
15
19
  hydrate: () => hydrateImpl(),
16
20
  }));
17
21
 
22
+ rstest.mock('@modern-js/runtime/rsc/client', () => {
23
+ const callServer = () => undefined;
24
+
25
+ return {
26
+ callServer,
27
+ createFromReadableStream: (
28
+ _stream: ReadableStream<Uint8Array>,
29
+ options: { callServer: unknown },
30
+ ) => {
31
+ decoderCallServer = options.callServer;
32
+ return createFromReadableStreamImpl();
33
+ },
34
+ setServerCallback: (callback: unknown) => {
35
+ registeredCallServer = callback;
36
+ },
37
+ };
38
+ });
39
+
18
40
  function createDeferred<T>() {
19
41
  let resolve!: (value: T | PromiseLike<T>) => void;
20
42
  let reject!: (reason?: unknown) => void;
@@ -37,6 +59,41 @@ function createRouter(label: string): AnyRouter {
37
59
  } as unknown as AnyRouter;
38
60
  }
39
61
 
62
+ function FlightTree({ getTree }: { getTree: () => unknown }) {
63
+ const tree = getTree();
64
+ if (
65
+ typeof tree !== 'object' ||
66
+ tree === null ||
67
+ !('label' in tree) ||
68
+ typeof tree.label !== 'string'
69
+ ) {
70
+ throw new Error('Expected the decoded Flight tree to contain a label.');
71
+ }
72
+ return <div data-testid="flight-tree">{tree.label}</div>;
73
+ }
74
+
75
+ class FlightErrorBoundary extends React.Component<
76
+ { children: React.ReactNode; onError: (error: unknown) => void },
77
+ { error: unknown }
78
+ > {
79
+ state = { error: undefined };
80
+
81
+ static getDerivedStateFromError(error: unknown) {
82
+ return { error };
83
+ }
84
+
85
+ componentDidCatch(error: unknown) {
86
+ this.props.onError(error);
87
+ }
88
+
89
+ render() {
90
+ if (this.state.error !== undefined) {
91
+ return <div data-testid="flight-error">failed</div>;
92
+ }
93
+ return this.props.children;
94
+ }
95
+ }
96
+
40
97
  describe('TanStack SSR client hydration', () => {
41
98
  it('keeps the router behind Suspense until late SSR hydration completes', async () => {
42
99
  const hydration = createDeferred<void>();
@@ -58,4 +115,86 @@ describe('TanStack SSR client hydration', () => {
58
115
  ).toContain('inventory');
59
116
  expect(screen.queryByTestId('hydrating')).toBeNull();
60
117
  });
118
+
119
+ it('reuses one Flight decode while suspended and returns the resolved tree synchronously', async () => {
120
+ const flight = createDeferred<unknown>();
121
+ const decodeFlight = rstest.fn(() => flight.promise);
122
+ createFromReadableStreamImpl = decodeFlight;
123
+ registeredCallServer = undefined;
124
+ decoderCallServer = undefined;
125
+
126
+ const getTree = createTreeGetterFromFlightStream(
127
+ new ReadableStream<Uint8Array>(),
128
+ );
129
+ const view = (
130
+ <Suspense fallback={<div data-testid="flight-loading">loading</div>}>
131
+ <FlightTree getTree={getTree} />
132
+ </Suspense>
133
+ );
134
+ const { rerender } = render(view);
135
+
136
+ expect(screen.getByTestId('flight-loading')).toBeTruthy();
137
+ await waitFor(() => expect(decodeFlight).toHaveBeenCalledTimes(1));
138
+ expect(registeredCallServer).toBe(decoderCallServer);
139
+
140
+ await act(() => rerender(view));
141
+ expect(screen.getByTestId('flight-loading')).toBeTruthy();
142
+ expect(decodeFlight).toHaveBeenCalledTimes(1);
143
+
144
+ const tree = { label: 'decoded inventory' };
145
+ await act(async () => {
146
+ flight.resolve(tree);
147
+ await flight.promise;
148
+ });
149
+
150
+ expect((await screen.findByTestId('flight-tree')).textContent).toBe(
151
+ tree.label,
152
+ );
153
+ expect(decodeFlight).toHaveBeenCalledTimes(1);
154
+ expect(getTree()).toBe(tree);
155
+ });
156
+
157
+ it('preserves the original Flight decode rejection for the error boundary', async () => {
158
+ const flight = createDeferred<unknown>();
159
+ const failure = new Error('Flight decode failed');
160
+ const decodeFlight = rstest.fn(() => flight.promise);
161
+ createFromReadableStreamImpl = decodeFlight;
162
+ let caughtError: unknown;
163
+ const errorLog = rstest
164
+ .spyOn(console, 'error')
165
+ .mockImplementation(() => undefined);
166
+
167
+ try {
168
+ const getTree = createTreeGetterFromFlightStream(
169
+ new ReadableStream<Uint8Array>(),
170
+ );
171
+ await act(() =>
172
+ render(
173
+ <FlightErrorBoundary
174
+ onError={error => {
175
+ caughtError = error;
176
+ }}
177
+ >
178
+ <Suspense
179
+ fallback={<div data-testid="flight-loading">loading</div>}
180
+ >
181
+ <FlightTree getTree={getTree} />
182
+ </Suspense>
183
+ </FlightErrorBoundary>,
184
+ ),
185
+ );
186
+
187
+ expect(screen.getByTestId('flight-loading')).toBeTruthy();
188
+ await waitFor(() => expect(decodeFlight).toHaveBeenCalledTimes(1));
189
+ await act(async () => {
190
+ flight.reject(failure);
191
+ await flight.promise.catch(() => undefined);
192
+ });
193
+
194
+ expect(await screen.findByTestId('flight-error')).toBeTruthy();
195
+ expect(caughtError).toBe(failure);
196
+ } finally {
197
+ errorLog.mockRestore();
198
+ }
199
+ });
61
200
  });
@@ -34,15 +34,21 @@ function createRouter(handler: {
34
34
  action?: RouteHandler;
35
35
  loader?: RouteHandler;
36
36
  invalidate?: () => Promise<void>;
37
+ params?: Record<string, string>;
37
38
  }) {
38
39
  return {
39
- buildLocation: ({ to }: { to?: string }) => ({
40
- pathname: typeof to === 'string' ? to : '/',
41
- }),
42
- getParsedLocationHref: (location: { pathname: string }) =>
43
- location.pathname,
44
- getMatchedRoutes: () => ({
45
- foundRoute: {
40
+ buildLocation: ({ to }: { to?: string }) => {
41
+ const href = typeof to === 'string' ? to : '/';
42
+ const url = new URL(href, 'http://localhost');
43
+ return {
44
+ href: `${url.pathname}${url.search}${url.hash}`,
45
+ pathname: url.pathname,
46
+ };
47
+ },
48
+ getMatchedRoutes: () => [
49
+ [],
50
+ handler.params || {},
51
+ {
46
52
  options: {
47
53
  staticData: {
48
54
  modernRouteAction: handler.action,
@@ -50,8 +56,7 @@ function createRouter(handler: {
50
56
  },
51
57
  },
52
58
  },
53
- routeParams: {},
54
- }),
59
+ ],
55
60
  navigate: rstest.fn(async () => undefined),
56
61
  invalidate: rstest.fn(handler.invalidate || (async () => undefined)),
57
62
  };
@@ -199,6 +204,42 @@ describe('tanstack data mutation fetcher', () => {
199
204
  expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']);
200
205
  });
201
206
 
207
+ test('passes built href and matched route params to mutation actions', async () => {
208
+ const action = rstest.fn(
209
+ async ({ request, params }: Parameters<RouteHandler>[0]) => ({
210
+ params,
211
+ requestUrl: request.url,
212
+ }),
213
+ );
214
+ currentRouter = createRouter({
215
+ action,
216
+ params: { userId: '42' },
217
+ });
218
+
219
+ render(<FetcherHarness />);
220
+
221
+ await act(async () => {
222
+ await latestFetcher!.submit(
223
+ { amount: 2 },
224
+ {
225
+ method: 'post',
226
+ action: '/users/42?tab=edit#details',
227
+ },
228
+ );
229
+ });
230
+
231
+ expect(action).toHaveBeenCalledTimes(1);
232
+ const actionArgs = action.mock.calls[0][0];
233
+ const expectedRequestUrl = `${window.location.origin}/users/42?tab=edit#details`;
234
+ expect(actionArgs.params).toEqual({ userId: '42' });
235
+ expect(actionArgs.request.url).toBe(expectedRequestUrl);
236
+ expect(latestFetcher?.data).toEqual({
237
+ params: { userId: '42' },
238
+ requestUrl: expectedRequestUrl,
239
+ });
240
+ expect(currentRouter.invalidate).toHaveBeenCalledTimes(1);
241
+ });
242
+
202
243
  test('surfaces non-2xx action responses as fetcher errors', async () => {
203
244
  const actionResult = createDeferred<Response>();
204
245
  currentRouter = createRouter({