@bleedingdev/modern-js-plugin-tanstack 3.5.0-ultramodern.102 → 3.5.0-ultramodern.103

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 (52) hide show
  1. package/dist/cjs/cli/index.js +4 -0
  2. package/dist/cjs/cli/tanstackTypes/source.js +7 -17
  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 +4 -0
  10. package/dist/esm/cli/tanstackTypes/source.mjs +7 -7
  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 +4 -0
  18. package/dist/esm-node/cli/tanstackTypes/source.mjs +7 -7
  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 +26 -20
  28. package/rstest.config.mts +2 -0
  29. package/src/cli/index.ts +6 -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 +1 -1
  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 +2 -0
  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/rscDecoderSecurity.test.ts +113 -0
  50. package/tests/router/serverPlugin.test.ts +115 -0
  51. package/tests/router/tanstackTypes.test.ts +351 -26
  52. package/src/runtime/ssr-shim.d.ts +0 -10
@@ -27,7 +27,6 @@ type RouterRouteWithOptions = {
27
27
  options?: RouterRouteOptions;
28
28
  };
29
29
  export type TanstackRouterWithServerSsr = AnyRouter & {
30
- resolveRedirect?: (redirect: Response) => Response;
31
30
  routesById?: Record<string, RouterRouteWithOptions>;
32
31
  serverSsr?: {
33
32
  cleanup?: () => void;
@@ -38,7 +37,6 @@ export type TanstackRouterWithServerSsr = AnyRouter & {
38
37
  };
39
38
  state: AnyRouter['state'] & {
40
39
  matches?: unknown;
41
- redirect?: Response;
42
40
  };
43
41
  };
44
42
  export {};
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "modern.js",
19
19
  "tanstack-router"
20
20
  ],
21
- "version": "3.5.0-ultramodern.102",
21
+ "version": "3.5.0-ultramodern.103",
22
22
  "engines": {
23
23
  "node": ">=20"
24
24
  },
@@ -94,35 +94,41 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.5.0-ultramodern.102",
98
- "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.5.0-ultramodern.102",
99
- "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.5.0-ultramodern.102",
100
- "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.5.0-ultramodern.102",
97
+ "@modern-js/plugin": "npm:@bleedingdev/modern-js-plugin@3.5.0-ultramodern.103",
98
+ "@modern-js/runtime-utils": "npm:@bleedingdev/modern-js-runtime-utils@3.5.0-ultramodern.103",
99
+ "@modern-js/types": "npm:@bleedingdev/modern-js-types@3.5.0-ultramodern.103",
100
+ "@modern-js/utils": "npm:@bleedingdev/modern-js-utils@3.5.0-ultramodern.103",
101
101
  "@swc/helpers": "^0.5.23",
102
- "@tanstack/react-router": "1.170.17",
103
- "@tanstack/router-core": "1.171.14",
104
- "react-server-dom-rspack": "0.0.2"
102
+ "@tanstack/react-router": "1.170.25",
103
+ "@tanstack/router-core": "1.171.21"
105
104
  },
106
105
  "peerDependencies": {
107
- "@modern-js/app-tools": "3.5.0-ultramodern.102",
108
- "@modern-js/runtime": "3.5.0-ultramodern.102",
109
- "react": "^19.2.7",
110
- "react-dom": "^19.2.7"
106
+ "@modern-js/app-tools": "3.5.0-ultramodern.103",
107
+ "@modern-js/runtime": "3.5.0-ultramodern.103",
108
+ "react": "^19.2.8",
109
+ "react-dom": "^19.2.8",
110
+ "react-server-dom-rspack": "0.0.3"
111
+ },
112
+ "peerDependenciesMeta": {
113
+ "react-server-dom-rspack": {
114
+ "optional": true
115
+ }
111
116
  },
112
117
  "devDependencies": {
113
- "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.5.0-ultramodern.102",
114
- "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.5.0-ultramodern.102",
118
+ "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.5.0-ultramodern.103",
119
+ "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.5.0-ultramodern.103",
115
120
  "@rslib/core": "0.23.2",
116
121
  "@scripts/rstest-config": "2.66.0",
117
- "@tanstack/history": "1.162.0",
122
+ "@tanstack/history": "1.162.1",
118
123
  "@testing-library/react": "^16.3.2",
119
124
  "@tsconfig/strictest": "2.0.8",
120
- "@types/node": "^26.1.1",
121
- "@types/react": "^19.2.17",
122
- "@types/react-dom": "^19.2.3",
125
+ "@types/node": "^26.2.0",
126
+ "@types/react": "^19.2.18",
127
+ "@types/react-dom": "^19.2.4",
123
128
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
124
- "react": "^19.2.7",
125
- "react-dom": "^19.2.7"
129
+ "react": "^19.2.8",
130
+ "react-dom": "^19.2.8",
131
+ "react-server-dom-rspack": "0.0.3"
126
132
  },
127
133
  "sideEffects": [
128
134
  "./src/runtime/register.ts",
package/rstest.config.mts CHANGED
@@ -35,6 +35,8 @@ export default {
35
35
  'tests/router/preloadRedirect.test.ts',
36
36
  'tests/router/register.test.ts',
37
37
  'tests/router/routeHooks.test.ts',
38
+ 'tests/router/serverPlugin.test.ts',
39
+ 'tests/router/rscDecoderSecurity.test.ts',
38
40
  'tests/router/rscPayloadRouterMatrix.test.ts',
39
41
  'tests/router/rsc.test.tsx',
40
42
  'tests/router/slotUsageSanitizer.test.ts',
package/src/cli/index.ts CHANGED
@@ -76,6 +76,8 @@ type RuntimeRouterCliHelpers = {
76
76
  options?: {
77
77
  entrypointsKey?: string;
78
78
  hydrateRscClientRoutes?: boolean;
79
+ includeRouteServerLoadersInSsrEntry?: boolean;
80
+ isolateRouteDataInRscLayer?: boolean;
79
81
  serverRoutesFileName?: string;
80
82
  },
81
83
  ) => Promise<Record<string, (NestedRouteForCli | PageRoute)[]>>;
@@ -268,6 +270,8 @@ export function tanstackRouterPlugin(
268
270
  {
269
271
  entrypointsKey: ENTRYPOINTS_KEY,
270
272
  hydrateRscClientRoutes: true,
273
+ includeRouteServerLoadersInSsrEntry: false,
274
+ isolateRouteDataInRscLayer: true,
271
275
  serverRoutesFileName: 'tanstack-routes.server.js',
272
276
  },
273
277
  );
@@ -296,6 +300,8 @@ export function tanstackRouterPlugin(
296
300
  {
297
301
  entrypointsKey: ENTRYPOINTS_KEY,
298
302
  hydrateRscClientRoutes: true,
303
+ includeRouteServerLoadersInSsrEntry: false,
304
+ isolateRouteDataInRscLayer: true,
299
305
  serverRoutesFileName: 'tanstack-routes.server.js',
300
306
  },
301
307
  );
@@ -1,6 +1,5 @@
1
1
  // @effect-diagnostics asyncFunction:off strictBooleanExpressions:off
2
2
 
3
- import path from 'node:path';
4
3
  import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
5
4
  import { MAIN_ENTRY_NAME } from '@modern-js/utils';
6
5
 
@@ -1,6 +1,5 @@
1
1
  // @effect-diagnostics asyncFunction:off strictBooleanExpressions:off
2
2
 
3
- import path from 'node:path';
4
3
  import { getPathWithoutExt } from '@modern-js/runtime/cli';
5
4
  import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
6
5
  import { findExists, formatImportPath, slash } from '@modern-js/utils';
@@ -1,6 +1,5 @@
1
1
  // @effect-diagnostics asyncFunction:off strictBooleanExpressions:off
2
2
 
3
- import path from 'node:path';
4
3
  import type {
5
4
  AppNormalizedConfig,
6
5
  AppTools,
@@ -8,6 +7,7 @@ import type {
8
7
  } from '@modern-js/app-tools';
9
8
  import { makeLegalIdentifier } from '@modern-js/runtime/cli';
10
9
  import type { NestedRouteForCli, PageRoute } from '@modern-js/types';
10
+ import { upath as path } from '@modern-js/utils';
11
11
 
12
12
  import {
13
13
  createRouteStaticDataSnippet,
@@ -1,6 +1,4 @@
1
1
  // @effect-diagnostics asyncFunction:off newPromise:off strictBooleanExpressions:off unnecessaryArrowBlock:off
2
- /// <reference path="./ssr-shim.d.ts" />
3
-
4
2
  import {
5
3
  getGlobalEnableRsc,
6
4
  type InternalRouterServerSnapshot,
@@ -175,10 +173,18 @@ export const tanstackRouterPlugin = (
175
173
  context.ssrContext?.onTiming?.(LOADER_REPORTER_NAME, cost);
176
174
  }
177
175
 
178
- if (serverRouter.state.redirect) {
179
- const resolved = serverRouter.resolveRedirect
180
- ? serverRouter.resolveRedirect(serverRouter.state.redirect)
181
- : serverRouter.state.redirect;
176
+ const serverLoadResult = serverRouter._serverResult;
177
+ if (!serverLoadResult) {
178
+ try {
179
+ serverRouter.serverSsr?.cleanup?.();
180
+ } catch {}
181
+ throw new Error(
182
+ 'TanStack Router completed an SSR load without a server result.',
183
+ );
184
+ }
185
+
186
+ if (serverLoadResult.type === 'redirect') {
187
+ const { redirect } = serverLoadResult;
182
188
 
183
189
  try {
184
190
  serverRouter.serverSsr?.cleanup?.();
@@ -187,11 +193,11 @@ export const tanstackRouterPlugin = (
187
193
  return interrupt(
188
194
  isRSCNavigation
189
195
  ? handleTanstackRscRedirect(
190
- resolved.headers,
196
+ redirect.headers,
191
197
  _basename,
192
- resolved.status,
198
+ redirect.status,
193
199
  )
194
- : resolved,
200
+ : redirect,
195
201
  );
196
202
  }
197
203
 
@@ -214,7 +220,7 @@ export const tanstackRouterPlugin = (
214
220
  context.ssrContext?.response as
215
221
  | { status: (code: number) => void }
216
222
  | undefined
217
- )?.status(tanstackRouter.state.statusCode);
223
+ )?.status(serverLoadResult.status);
218
224
 
219
225
  await serverRouter.serverSsr?.dehydrate?.();
220
226
 
@@ -240,7 +246,7 @@ export const tanstackRouterPlugin = (
240
246
  createRouterServerSnapshot({
241
247
  framework: 'tanstack',
242
248
  basename: _basename,
243
- statusCode: tanstackRouter.state.statusCode,
249
+ statusCode: serverLoadResult.status,
244
250
  errors: routerErrors,
245
251
  matchedRouteIds,
246
252
  hydrationScripts,
@@ -1,6 +1,4 @@
1
1
  // @effect-diagnostics globalConsole:off strictBooleanExpressions:off
2
- /// <reference path="./ssr-shim.d.ts" />
3
-
4
2
  import {
5
3
  getGlobalEnableRsc,
6
4
  InternalRuntimeContext,
@@ -87,12 +87,13 @@ export function createTanstackRouteObjects({
87
87
  ssrMode?: SSRMode;
88
88
  }): RouteObject[] {
89
89
  const { mergedConfig, finalRouteConfig } = routeConfig;
90
- const routeObjects = mergedConfig.createRoutes
91
- ? mergedConfig.createRoutes()
92
- : createTanstackRouteObjectsFromConfig({
93
- routesConfig: finalRouteConfig,
94
- ssrMode,
95
- }) || [];
90
+ const routeObjects =
91
+ mergedConfig.createRoutes !== undefined
92
+ ? mergedConfig.createRoutes()
93
+ : (createTanstackRouteObjectsFromConfig({
94
+ routesConfig: finalRouteConfig,
95
+ ssrMode,
96
+ }) ?? []);
96
97
 
97
98
  return hooks.modifyRoutes.call(routeObjects) as RouteObject[];
98
99
  }
@@ -105,7 +106,7 @@ export function joinBasename(baseUrl: string, basename: string): string {
105
106
  ? normalizedBase.replace(/\/+$/g, '')
106
107
  : normalizedBase;
107
108
  const trimmedBasename = basename.replace(/^\/+|\/+$/g, '');
108
- if (!trimmedBasename) {
109
+ if (trimmedBasename.length === 0) {
109
110
  return trimmedBase || '/';
110
111
  }
111
112
  return `${trimmedBase}/${trimmedBasename}`.replace(/\/{2,}/g, '/');
@@ -1,6 +1,6 @@
1
1
  // @effect-diagnostics asyncFunction:off globalFetch:off processEnv:off strictBooleanExpressions:off
2
2
  import type { ServerPayload } from '@modern-js/runtime/context';
3
- import { isRouteErrorResponse } from '@modern-js/runtime-utils/router';
3
+ import { isRouteErrorResponse } from '@modern-js/runtime/routerHelper';
4
4
 
5
5
  export function shouldRedactServerError(status = 500) {
6
6
  return (
@@ -62,7 +62,7 @@ export function renderSelectedTree(
62
62
  missingTreeError,
63
63
  }: RenderSelectedTreeOptions,
64
64
  ) {
65
- if (!getTree) {
65
+ if (getTree === undefined) {
66
66
  throw new Error(missingTreeError);
67
67
  }
68
68
 
@@ -18,18 +18,19 @@ function loadModernRscClientRuntime() {
18
18
  return modernRscClientRuntimePromise;
19
19
  }
20
20
 
21
- async function createFromFlightStream(stream: ReadableStream<Uint8Array>) {
21
+ function createFromFlightStream(stream: ReadableStream<Uint8Array>) {
22
22
  if (typeof window === 'undefined') {
23
- const runtime: ServerRscClientRuntime = await import(
24
- 'react-server-dom-rspack/client.edge'
23
+ return import('react-server-dom-rspack/client.edge').then(
24
+ (runtime: ServerRscClientRuntime) =>
25
+ runtime.createFromReadableStream(stream),
25
26
  );
26
- return runtime.createFromReadableStream(stream);
27
27
  }
28
28
 
29
- const runtime = await loadModernRscClientRuntime();
30
- return runtime.createFromReadableStream(stream, {
31
- callServer: runtime.callServer,
32
- });
29
+ return loadModernRscClientRuntime().then(runtime =>
30
+ runtime.createFromReadableStream(stream, {
31
+ callServer: runtime.callServer,
32
+ }),
33
+ );
33
34
  }
34
35
 
35
36
  export function createTreeGetterFromFlightStream(
@@ -1,6 +1,4 @@
1
1
  // @effect-diagnostics asyncFunction:off newPromise:off strictBooleanExpressions:off
2
- /// <reference path="./ssr-shim.d.ts" />
3
-
4
2
  import type { AnyRouter } from '@tanstack/react-router';
5
3
  import type {
6
4
  RouterManagedTag,
@@ -1,5 +1,3 @@
1
- /// <reference path="./ssr-shim.d.ts" />
2
-
3
1
  import type { RequestContext } from '@modern-js/runtime-utils/node';
4
2
  import type { AnyRouter } from '@tanstack/react-router';
5
3
 
@@ -35,7 +33,6 @@ type RouterRouteWithOptions = {
35
33
  };
36
34
 
37
35
  export type TanstackRouterWithServerSsr = AnyRouter & {
38
- resolveRedirect?: (redirect: Response) => Response;
39
36
  routesById?: Record<string, RouterRouteWithOptions>;
40
37
  serverSsr?: {
41
38
  cleanup?: () => void;
@@ -46,6 +43,5 @@ export type TanstackRouterWithServerSsr = AnyRouter & {
46
43
  };
47
44
  state: AnyRouter['state'] & {
48
45
  matches?: unknown;
49
- redirect?: Response;
50
46
  };
51
47
  };
@@ -32,21 +32,16 @@ type RouteLoader = (args: {
32
32
  }) => Promise<unknown> | unknown;
33
33
 
34
34
  type RouterBuildLocationOptions = Parameters<AnyRouter['buildLocation']>[0];
35
- type RouterBuiltLocation = ReturnType<AnyRouter['buildLocation']> & {
36
- pathname: string;
37
- };
38
- type RouterParsedLocation = Parameters<AnyRouter['getParsedLocationHref']>[0];
39
35
  type RouterNavigateOptions = Parameters<AnyRouter['navigate']>[0];
40
36
 
41
37
  function resolveRouteHandlers(router: AnyRouter, actionTo: string) {
42
38
  const builtLocation = router.buildLocation({
43
39
  to: actionTo,
44
- } as RouterBuildLocationOptions) as RouterBuiltLocation;
45
- const href = router.getParsedLocationHref(
46
- builtLocation as RouterParsedLocation,
40
+ } as RouterBuildLocationOptions);
41
+ const [, routeParams, foundRoute] = router.getMatchedRoutes(
42
+ builtLocation.pathname,
47
43
  );
48
- const matchedRoutes = router.getMatchedRoutes(builtLocation.pathname);
49
- const routeStaticData = matchedRoutes.foundRoute?.options?.staticData as
44
+ const routeStaticData = foundRoute?.options?.staticData as
50
45
  | Record<string, unknown>
51
46
  | undefined;
52
47
  const action = routeStaticData?.modernRouteAction as RouteAction | undefined;
@@ -55,8 +50,8 @@ function resolveRouteHandlers(router: AnyRouter, actionTo: string) {
55
50
  return {
56
51
  action,
57
52
  loader,
58
- href,
59
- params: (matchedRoutes.routeParams || {}) as Record<string, string>,
53
+ href: builtLocation.href,
54
+ params: routeParams,
60
55
  };
61
56
  }
62
57
 
@@ -1,56 +1,8 @@
1
+ // react-server-dom-rspack does not publish TypeScript declarations. This is the
2
+ // smallest ambient contract used by the TanStack RSC stream bridge; executable
3
+ // decoder tests cover the exact patched upstream development dependency.
1
4
  declare module 'react-server-dom-rspack/client.edge' {
2
- import type { TemporaryReferenceSet } from 'react-server-dom-rspack';
3
-
4
- export type { TemporaryReferenceSet };
5
-
6
- export const createTemporaryReferenceSet: (
7
- ...args: unknown[]
8
- ) => TemporaryReferenceSet;
9
-
10
- export function createServerReference(
11
- id: string,
12
- ): (...args: unknown[]) => Promise<unknown>;
13
-
14
- export type EncodeFormActionCallback = <A>(
15
- id: unknown,
16
- args: Promise<A>,
17
- ) => ReactCustomFormAction;
18
-
19
- export type ReactCustomFormAction = {
20
- name?: string;
21
- action?: string;
22
- encType?: string;
23
- method?: string;
24
- target?: string;
25
- data?: null | FormData;
26
- };
27
-
28
- export interface Options {
29
- nonce?: string;
30
- encodeFormAction?: EncodeFormActionCallback;
31
- temporaryReferences?: TemporaryReferenceSet;
32
- replayConsoleLogs?: boolean;
33
- environmentName?: string;
34
- debugChannel?: {
35
- readable?: ReadableStream;
36
- };
37
- }
38
-
39
- export function createFromFetch<T>(
40
- promiseForResponse: Promise<Response>,
41
- options?: Options,
42
- ): Promise<T>;
43
-
44
5
  export function createFromReadableStream<T>(
45
- stream: ReadableStream,
46
- options?: Options,
6
+ stream: ReadableStream<Uint8Array>,
47
7
  ): Promise<T>;
48
-
49
- export const encodeReply: (
50
- value: unknown,
51
- options?: {
52
- temporaryReferences?: TemporaryReferenceSet;
53
- signal?: AbortSignal;
54
- },
55
- ) => Promise<string | FormData>;
56
8
  }
@@ -803,6 +803,8 @@ describe('tanstack router cli plugin', () => {
803
803
  {
804
804
  entrypointsKey: '@modern-js/plugin-tanstack',
805
805
  hydrateRscClientRoutes: true,
806
+ includeRouteServerLoadersInSsrEntry: false,
807
+ isolateRouteDataInRscLayer: true,
806
808
  serverRoutesFileName: 'tanstack-routes.server.js',
807
809
  },
808
810
  );
@@ -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
  });