@bleedingdev/modern-js-plugin-tanstack 3.2.0-ultramodern.120 → 3.2.0-ultramodern.122

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 (94) hide show
  1. package/dist/cjs/cli/index.js +47 -27
  2. package/dist/cjs/cli/routeSplitting.js +0 -32
  3. package/dist/cjs/cli/tanstackTypes.js +34 -199
  4. package/dist/cjs/runtime/hooks.js +11 -14
  5. package/dist/cjs/runtime/index.js +107 -319
  6. package/dist/cjs/runtime/lifecycle.js +12 -86
  7. package/dist/cjs/runtime/loaderBridge.js +173 -0
  8. package/dist/cjs/runtime/plugin.js +6 -30
  9. package/dist/cjs/runtime/plugin.node.js +7 -29
  10. package/dist/cjs/runtime/pluginCore.js +55 -0
  11. package/dist/cjs/runtime/register.js +56 -0
  12. package/dist/cjs/runtime/routeTree.js +10 -207
  13. package/dist/cjs/runtime/{DefaultNotFound.js → router.js} +5 -15
  14. package/dist/cjs/runtime/rsc/payloadRouter.js +35 -1
  15. package/dist/cjs/runtime/state.js +45 -0
  16. package/dist/cjs/runtime/utils.js +0 -5
  17. package/dist/esm/cli/index.mjs +52 -26
  18. package/dist/esm/cli/routeSplitting.mjs +1 -30
  19. package/dist/esm/cli/tanstackTypes.mjs +32 -194
  20. package/dist/esm/runtime/hooks.mjs +1 -8
  21. package/dist/esm/runtime/index.mjs +4 -2
  22. package/dist/esm/runtime/lifecycle.mjs +1 -82
  23. package/dist/esm/runtime/loaderBridge.mjs +114 -0
  24. package/dist/esm/runtime/plugin.mjs +8 -32
  25. package/dist/esm/runtime/plugin.node.mjs +10 -32
  26. package/dist/esm/runtime/pluginCore.mjs +14 -0
  27. package/dist/esm/runtime/register.mjs +18 -0
  28. package/dist/esm/runtime/routeTree.mjs +4 -198
  29. package/dist/esm/runtime/router.mjs +2 -0
  30. package/dist/esm/runtime/rsc/payloadRouter.mjs +35 -1
  31. package/dist/esm/runtime/state.mjs +7 -0
  32. package/dist/esm/runtime/utils.mjs +0 -5
  33. package/dist/esm-node/cli/index.mjs +52 -26
  34. package/dist/esm-node/cli/routeSplitting.mjs +1 -30
  35. package/dist/esm-node/cli/tanstackTypes.mjs +32 -194
  36. package/dist/esm-node/runtime/hooks.mjs +1 -8
  37. package/dist/esm-node/runtime/index.mjs +4 -2
  38. package/dist/esm-node/runtime/lifecycle.mjs +1 -82
  39. package/dist/esm-node/runtime/loaderBridge.mjs +115 -0
  40. package/dist/esm-node/runtime/plugin.mjs +8 -32
  41. package/dist/esm-node/runtime/plugin.node.mjs +10 -32
  42. package/dist/esm-node/runtime/pluginCore.mjs +15 -0
  43. package/dist/esm-node/runtime/register.mjs +19 -0
  44. package/dist/esm-node/runtime/routeTree.mjs +4 -198
  45. package/dist/esm-node/runtime/router.mjs +3 -0
  46. package/dist/esm-node/runtime/rsc/payloadRouter.mjs +35 -1
  47. package/dist/esm-node/runtime/state.mjs +8 -0
  48. package/dist/esm-node/runtime/utils.mjs +0 -5
  49. package/dist/types/cli/index.d.ts +9 -2
  50. package/dist/types/cli/routeSplitting.d.ts +6 -15
  51. package/dist/types/cli/tanstackTypes.d.ts +13 -2
  52. package/dist/types/runtime/hooks.d.ts +8 -18
  53. package/dist/types/runtime/index.d.ts +6 -4
  54. package/dist/types/runtime/lifecycle.d.ts +7 -22
  55. package/dist/types/runtime/loaderBridge.d.ts +48 -0
  56. package/dist/types/runtime/plugin.d.ts +1 -14
  57. package/dist/types/runtime/plugin.node.d.ts +1 -14
  58. package/dist/types/runtime/pluginCore.d.ts +21 -0
  59. package/dist/types/runtime/register.d.ts +9 -0
  60. package/dist/types/runtime/routeTree.d.ts +0 -2
  61. package/dist/types/runtime/router.d.ts +14 -0
  62. package/dist/types/runtime/state.d.ts +16 -0
  63. package/dist/types/runtime/types.d.ts +7 -53
  64. package/package.json +31 -29
  65. package/rstest.config.mts +6 -0
  66. package/src/cli/index.ts +111 -29
  67. package/src/cli/routeSplitting.ts +6 -44
  68. package/src/cli/tanstackTypes.ts +78 -214
  69. package/src/runtime/hooks.ts +10 -27
  70. package/src/runtime/index.tsx +12 -107
  71. package/src/runtime/lifecycle.ts +16 -151
  72. package/src/runtime/loaderBridge.ts +257 -0
  73. package/src/runtime/plugin.node.tsx +14 -77
  74. package/src/runtime/plugin.tsx +12 -72
  75. package/src/runtime/pluginCore.ts +48 -0
  76. package/src/runtime/register.ts +58 -0
  77. package/src/runtime/routeTree.ts +8 -370
  78. package/src/runtime/router.ts +15 -0
  79. package/src/runtime/rsc/payloadRouter.ts +45 -2
  80. package/src/runtime/state.ts +29 -0
  81. package/src/runtime/types.ts +20 -67
  82. package/src/runtime/utils.tsx +3 -6
  83. package/tests/router/cli.test.ts +297 -31
  84. package/tests/router/hooks.test.ts +26 -0
  85. package/tests/router/loaderBridge.test.ts +211 -0
  86. package/tests/router/packageSurface.test.ts +24 -0
  87. package/tests/router/register.test.ts +46 -0
  88. package/tests/router/routeTree.test.ts +65 -180
  89. package/tests/router/rsc.test.tsx +70 -0
  90. package/tests/router/tanstackTypes.test.ts +164 -6
  91. package/dist/esm/runtime/DefaultNotFound.mjs +0 -13
  92. package/dist/esm-node/runtime/DefaultNotFound.mjs +0 -14
  93. package/dist/types/runtime/DefaultNotFound.d.ts +0 -2
  94. package/src/runtime/DefaultNotFound.tsx +0 -15
@@ -0,0 +1,211 @@
1
+ import { isNotFound, isRedirect } from '@tanstack/react-router';
2
+ import {
3
+ createRouteStaticData,
4
+ isAbsoluteUrl,
5
+ mapSplatParamsForModernLoader,
6
+ modernLoaderToTanstack,
7
+ throwTanstackRedirect,
8
+ } from '../../src/runtime/loaderBridge';
9
+
10
+ type RedirectLike = {
11
+ options?: {
12
+ href?: string;
13
+ to?: string;
14
+ };
15
+ };
16
+
17
+ function catchThrown(fn: () => unknown): unknown {
18
+ try {
19
+ fn();
20
+ } catch (err) {
21
+ return err;
22
+ }
23
+ throw new Error('expected the function to throw');
24
+ }
25
+
26
+ describe('throwTanstackRedirect', () => {
27
+ test('absolute URLs redirect via href (external), not via to', () => {
28
+ // The old inline codegen handler threw `redirect({ href })` INSIDE a
29
+ // try block whose catch replaced it with `redirect({ to: absoluteUrl })`,
30
+ // making TanStack treat the absolute URL as an internal path.
31
+ const thrown = catchThrown(() =>
32
+ throwTanstackRedirect('https://example.com/external'),
33
+ ) as RedirectLike;
34
+
35
+ expect(isRedirect(thrown)).toBe(true);
36
+ expect(thrown.options?.href).toBe('https://example.com/external');
37
+ expect(thrown.options?.to).toBeUndefined();
38
+ });
39
+
40
+ test('relative paths redirect via to so the basepath rewrite applies', () => {
41
+ const thrown = catchThrown(() =>
42
+ throwTanstackRedirect('/dashboard'),
43
+ ) as RedirectLike;
44
+
45
+ expect(isRedirect(thrown)).toBe(true);
46
+ expect(thrown.options?.to).toBe('/dashboard');
47
+ expect(thrown.options?.href).toBeUndefined();
48
+ });
49
+
50
+ test('empty location falls back to /', () => {
51
+ const thrown = catchThrown(() => throwTanstackRedirect('')) as RedirectLike;
52
+ expect(thrown.options?.to).toBe('/');
53
+ });
54
+ });
55
+
56
+ describe('isAbsoluteUrl', () => {
57
+ test('detects absolute and relative URLs', () => {
58
+ expect(isAbsoluteUrl('https://example.com/a')).toBe(true);
59
+ expect(isAbsoluteUrl('mailto:x@example.com')).toBe(true);
60
+ expect(isAbsoluteUrl('/internal/path')).toBe(false);
61
+ expect(isAbsoluteUrl('relative')).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('mapSplatParamsForModernLoader', () => {
66
+ test('maps TanStack _splat to React Router * only for splat routes', () => {
67
+ expect(
68
+ mapSplatParamsForModernLoader({ _splat: 'a/b', id: '1' }, true),
69
+ ).toEqual({ '*': 'a/b', id: '1' });
70
+ expect(
71
+ mapSplatParamsForModernLoader({ _splat: 'a/b', id: '1' }, false),
72
+ ).toEqual({ _splat: 'a/b', id: '1' });
73
+ expect(mapSplatParamsForModernLoader({ id: '1' }, true)).toEqual({
74
+ id: '1',
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('createRouteStaticData', () => {
80
+ test('drops empty fields', () => {
81
+ const loader = () => null;
82
+ expect(createRouteStaticData({})).toEqual({});
83
+ expect(createRouteStaticData({ modernRouteId: '' })).toEqual({});
84
+ expect(
85
+ createRouteStaticData({
86
+ modernRouteId: 'page',
87
+ modernRouteLoader: loader,
88
+ }),
89
+ ).toEqual({ modernRouteId: 'page', modernRouteLoader: loader });
90
+ });
91
+ });
92
+
93
+ describe('modernLoaderToTanstack', () => {
94
+ const baseCtx = {
95
+ location: { href: 'http://localhost/products/1' },
96
+ params: { id: '1' },
97
+ };
98
+
99
+ test('passes request/params/context through to the modern loader', async () => {
100
+ const seen: { request?: Request; params?: unknown; context?: unknown } = {};
101
+ const loader = modernLoaderToTanstack({ hasSplat: false }, (args: any) => {
102
+ seen.request = args.request;
103
+ seen.params = args.params;
104
+ seen.context = args.context;
105
+ return { ok: true };
106
+ });
107
+
108
+ await expect(
109
+ loader({
110
+ ...baseCtx,
111
+ context: { requestContext: { user: 'u1' } },
112
+ }),
113
+ ).resolves.toEqual({ ok: true });
114
+ expect(seen.request).toBeInstanceOf(Request);
115
+ expect(seen.request?.url).toBe('http://localhost/products/1');
116
+ expect(seen.params).toEqual({ id: '1' });
117
+ expect(seen.context).toEqual({ user: 'u1' });
118
+ });
119
+
120
+ test('translates an absolute-URL redirect Response into redirect({ href })', async () => {
121
+ const loader = modernLoaderToTanstack({ hasSplat: false }, () =>
122
+ Response.redirect('https://example.com/away', 302),
123
+ );
124
+
125
+ const thrown = (await loader(baseCtx).then(
126
+ () => {
127
+ throw new Error('expected redirect');
128
+ },
129
+ (err: unknown) => err,
130
+ )) as RedirectLike;
131
+
132
+ expect(isRedirect(thrown)).toBe(true);
133
+ expect(thrown.options?.href).toBe('https://example.com/away');
134
+ expect(thrown.options?.to).toBeUndefined();
135
+ });
136
+
137
+ test('translates a relative redirect Response into redirect({ to })', async () => {
138
+ const loader = modernLoaderToTanstack(
139
+ { hasSplat: false },
140
+ () =>
141
+ new Response(null, { status: 302, headers: { Location: '/login' } }),
142
+ );
143
+
144
+ const thrown = (await loader(baseCtx).then(
145
+ () => {
146
+ throw new Error('expected redirect');
147
+ },
148
+ (err: unknown) => err,
149
+ )) as RedirectLike;
150
+
151
+ expect(isRedirect(thrown)).toBe(true);
152
+ expect(thrown.options?.to).toBe('/login');
153
+ });
154
+
155
+ test('translates a 404 Response into notFound()', async () => {
156
+ const loader = modernLoaderToTanstack(
157
+ { hasSplat: false },
158
+ () => new Response(null, { status: 404 }),
159
+ );
160
+
161
+ const thrown = await loader(baseCtx).then(
162
+ () => {
163
+ throw new Error('expected notFound');
164
+ },
165
+ (err: unknown) => err,
166
+ );
167
+
168
+ expect(isNotFound(thrown)).toBe(true);
169
+ });
170
+
171
+ test('preserves returned non-404 error Responses as loader results', async () => {
172
+ const response = new Response('loader exploded', { status: 500 });
173
+ const loader = modernLoaderToTanstack({ hasSplat: false }, () => response);
174
+
175
+ await expect(loader(baseCtx)).resolves.toBe(response);
176
+ });
177
+
178
+ test('translates redirect Responses thrown synchronously by the loader', () => {
179
+ const loader = modernLoaderToTanstack({ hasSplat: false }, () => {
180
+ throw new Response(null, {
181
+ status: 301,
182
+ headers: { Location: 'https://example.com/moved' },
183
+ });
184
+ });
185
+
186
+ // A synchronous loader throw surfaces synchronously (TanStack handles
187
+ // thrown redirects from the loader call itself).
188
+ const thrown = catchThrown(() => loader(baseCtx)) as RedirectLike;
189
+
190
+ expect(isRedirect(thrown)).toBe(true);
191
+ expect(thrown.options?.href).toBe('https://example.com/moved');
192
+ });
193
+
194
+ test('re-throws TanStack redirects thrown by the loader untouched', async () => {
195
+ const loader = modernLoaderToTanstack({ hasSplat: false }, async () => {
196
+ throwTanstackRedirect('/inner');
197
+ });
198
+
199
+ const thrown = (await loader(baseCtx).then(
200
+ () => {
201
+ throw new Error('expected redirect');
202
+ },
203
+ (err: unknown) => err,
204
+ )) as RedirectLike;
205
+
206
+ // The bridge must not re-translate its own redirect (a Response without
207
+ // a Location header) — that used to collapse internal targets to '/'.
208
+ expect(isRedirect(thrown)).toBe(true);
209
+ expect(thrown.options?.to).toBe('/inner');
210
+ });
211
+ });
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ describe('tanstack package public surface', () => {
5
+ test('package manifest exposes the runtime subpath used by app fixtures', () => {
6
+ const packageJson = JSON.parse(
7
+ readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'),
8
+ ) as {
9
+ exports: Record<string, unknown>;
10
+ typesVersions?: Record<string, Record<string, string[]>>;
11
+ };
12
+
13
+ expect(packageJson.exports['./runtime']).toEqual({
14
+ types: './dist/types/runtime/index.d.ts',
15
+ node: {
16
+ module: './dist/esm/runtime/index.mjs',
17
+ },
18
+ default: './dist/esm/runtime/index.mjs',
19
+ });
20
+ expect(packageJson.typesVersions?.['*']?.runtime).toEqual([
21
+ './dist/types/runtime/index.d.ts',
22
+ ]);
23
+ });
24
+ });
@@ -0,0 +1,46 @@
1
+ import { resolveRouterProvider } from '@modern-js/runtime/context';
2
+ import {
3
+ Form,
4
+ Link,
5
+ NavLink,
6
+ Outlet,
7
+ RouteActionResponseError,
8
+ useFetcher,
9
+ } from '../../src/runtime';
10
+ import { tanstackRouterCompatBindings } from '../../src/runtime/register';
11
+
12
+ const COMPAT_BINDINGS_SLOT = Symbol.for(
13
+ '@modern-js/plugin-tanstack:runtime-compat-bindings',
14
+ );
15
+
16
+ describe("'@modern-js/plugin-tanstack/runtime' import side effects", () => {
17
+ it('registers the tanstack router provider', () => {
18
+ expect(typeof resolveRouterProvider('tanstack')).toBe('function');
19
+ });
20
+
21
+ it("publishes the compat bindings consumed by '@modern-js/runtime/tanstack-router'", () => {
22
+ const bindings = (globalThis as Record<symbol, unknown>)[
23
+ COMPAT_BINDINGS_SLOT
24
+ ] as typeof tanstackRouterCompatBindings;
25
+
26
+ expect(bindings).toBeDefined();
27
+ expect(bindings).toBe(tanstackRouterCompatBindings);
28
+ expect(bindings.Form).toBe(Form);
29
+ expect(bindings.Link).toBe(Link);
30
+ expect(bindings.NavLink).toBe(NavLink);
31
+ expect(bindings.Outlet).toBe(Outlet);
32
+ expect(bindings.RouteActionResponseError).toBe(RouteActionResponseError);
33
+ expect(bindings.useFetcher).toBe(useFetcher);
34
+ });
35
+
36
+ it('keeps the first published bindings when a duplicate module copy evaluates', () => {
37
+ // Simulates a Module Federation remote evaluating its own copy of
38
+ // register.ts: `??=` must keep the established bindings.
39
+ const host = globalThis as Record<symbol, unknown>;
40
+ const established = host[COMPAT_BINDINGS_SLOT];
41
+ expect(established).toBeDefined();
42
+
43
+ host[COMPAT_BINDINGS_SLOT] ??= { duplicate: true };
44
+ expect(host[COMPAT_BINDINGS_SLOT]).toBe(established);
45
+ });
46
+ });
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { RouteObject } from '@modern-js/runtime-utils/router';
2
4
  import type { NestedRoute } from '@modern-js/types';
3
5
  import { createMemoryHistory } from '@tanstack/history';
@@ -5,10 +7,10 @@ import { createRouter, Outlet, RouterProvider } from '@tanstack/react-router';
5
7
  import type { ComponentType } from 'react';
6
8
  import { createElement, lazy } from 'react';
7
9
  import { renderToStaticMarkup, renderToString } from 'react-dom/server';
10
+ import * as TanstackRuntime from '../../src/runtime';
8
11
  import { Outlet as PublicOutlet } from '../../src/runtime';
9
12
  import { Outlet as ModernOutlet } from '../../src/runtime/outlet';
10
13
  import {
11
- createRouteTreeFromModernRoutes,
12
14
  createRouteTreeFromRouteObjects,
13
15
  getModernRouteIdsFromMatches,
14
16
  } from '../../src/runtime/routeTree';
@@ -79,9 +81,11 @@ type TestRouter = {
79
81
  looseRoutesById: Partial<Record<string, TestRoute>>;
80
82
  state: {
81
83
  matches: Array<{
84
+ error?: unknown;
82
85
  loaderData?: unknown;
83
86
  routeId: string;
84
87
  }>;
88
+ statusCode?: number;
85
89
  };
86
90
  };
87
91
 
@@ -135,6 +139,28 @@ describe('tanstack runtime public exports', () => {
135
139
  test('exports the Modern Outlet implementation from the runtime entrypoint', () => {
136
140
  expect(PublicOutlet).toBe(ModernOutlet);
137
141
  });
142
+
143
+ test('does not expose the unowned composite RSC helper API', () => {
144
+ expect('CompositeComponent' in TanstackRuntime).toBe(false);
145
+
146
+ const packageJson = JSON.parse(
147
+ readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'),
148
+ ) as {
149
+ exports: Record<string, unknown>;
150
+ typesVersions?: Record<string, Record<string, string[]>>;
151
+ };
152
+
153
+ expect(packageJson.exports['./runtime/rsc']).toBeUndefined();
154
+ expect(packageJson.exports['./runtime/rsc/client']).toBeUndefined();
155
+ expect(packageJson.exports['./runtime/rsc/server']).toBeUndefined();
156
+ expect(packageJson.typesVersions?.['*']?.['runtime/rsc']).toBeUndefined();
157
+ expect(
158
+ packageJson.typesVersions?.['*']?.['runtime/rsc/client'],
159
+ ).toBeUndefined();
160
+ expect(
161
+ packageJson.typesVersions?.['*']?.['runtime/rsc/server'],
162
+ ).toBeUndefined();
163
+ });
138
164
  });
139
165
 
140
166
  describe('tanstack route tree from RouteObject[]', () => {
@@ -175,6 +201,28 @@ describe('tanstack route tree from RouteObject[]', () => {
175
201
  expect(userMatch?.loaderData).toEqual({ id: '123' });
176
202
  });
177
203
 
204
+ test('reports native TanStack unknown routes as HTTP 404', async () => {
205
+ const routes: RouteObject[] = [
206
+ {
207
+ id: 'root',
208
+ path: '/',
209
+ Component: () => null,
210
+ children: [
211
+ {
212
+ id: 'known',
213
+ path: 'known',
214
+ Component: () => null,
215
+ },
216
+ ],
217
+ },
218
+ ];
219
+
220
+ const routeTree = createRouteTreeFromRouteObjects(routes);
221
+ const router = await loadRouteTree(routeTree, '/missing');
222
+
223
+ expect(router.state.statusCode).toBe(404);
224
+ });
225
+
178
226
  test('does not force Suspense wrappers for ordinary generated routes', async () => {
179
227
  const routes: RouteObject[] = [
180
228
  {
@@ -682,196 +730,33 @@ describe('tanstack route tree from RouteObject[]', () => {
682
730
  await expect(loaderData?.later).resolves.toBe('done');
683
731
  });
684
732
 
685
- test('merges Modern generated route handle into TanStack static data', () => {
686
- const modernRoutes: NestedRoute[] = [
687
- {
688
- type: 'nested',
689
- origin: 'config',
690
- id: 'root',
691
- isRoot: true,
692
- config: {
693
- handle: {
694
- shell: true,
695
- },
696
- },
697
- children: [
698
- {
699
- type: 'nested',
700
- origin: 'config',
701
- id: 'dashboard',
702
- path: 'dashboard',
703
- handle: {
704
- section: 'analytics',
705
- },
706
- config: {
707
- handle: {
708
- role: 'admin',
709
- },
710
- },
711
- },
712
- ],
713
- },
714
- ];
715
- const routeTree = createRouteTreeFromModernRoutes(modernRoutes);
716
- const router = createRouter({
717
- routeTree,
718
- history: createMemoryHistory({
719
- initialEntries: ['/dashboard'],
720
- }),
721
- context: {},
722
- }) as unknown as TestRouter;
723
- const dashboardRoute = getLooseRoute(router, '/dashboard');
724
-
725
- expect(routeTree.options.staticData.modernRouteHandle).toEqual({
726
- shell: true,
727
- });
728
- expect(dashboardRoute.options.staticData.modernRouteHandle).toEqual({
729
- section: 'analytics',
730
- role: 'admin',
731
- });
732
- });
733
-
734
- test('preserves TanStack search contracts from Modern generated routes', () => {
735
- const rootValidateSearch = (search: unknown) => ({ root: search });
736
- const rootLoaderDeps = ({ search }: { search: unknown }) => ({ search });
737
- const childValidateSearch = (search: unknown) => ({ child: search });
738
- const childLoaderDeps = ({ search }: { search: unknown }) => ({ search });
739
- const modernRoutes: TestNestedRoute[] = [
740
- {
741
- type: 'nested',
742
- origin: 'config',
743
- id: 'root',
744
- isRoot: true,
745
- validateSearch: rootValidateSearch,
746
- loaderDeps: rootLoaderDeps,
747
- children: [
748
- {
749
- type: 'nested',
750
- origin: 'config',
751
- id: 'search',
752
- path: 'search',
753
- validateSearch: childValidateSearch,
754
- loaderDeps: childLoaderDeps,
755
- },
756
- ],
757
- },
758
- ];
759
- const routeTree = createRouteTreeFromModernRoutes(modernRoutes);
760
- const router = createRouter({
761
- routeTree,
762
- history: createMemoryHistory({
763
- initialEntries: ['/search'],
764
- }),
765
- context: {},
766
- }) as unknown as TestRouter;
767
- const searchRoute = getLooseRouteByModernRouteId(router, 'search');
768
-
769
- expect(routeTree.options.validateSearch).toBe(rootValidateSearch);
770
- expect(routeTree.options.loaderDeps).toBe(rootLoaderDeps);
771
- expect(searchRoute.options.validateSearch).toBe(childValidateSearch);
772
- expect(searchRoute.options.loaderDeps).toBe(childLoaderDeps);
773
- });
774
-
775
- test('does not force Suspense wrappers for ordinary Modern routes', async () => {
776
- const modernRoutes: TestNestedRoute[] = [
777
- {
778
- type: 'nested',
779
- origin: 'config',
780
- id: 'root',
781
- isRoot: true,
782
- component: () => createElement(Outlet),
783
- children: [
784
- {
785
- type: 'nested',
786
- origin: 'config',
787
- id: 'plain',
788
- path: 'plain',
789
- component: () => null,
790
- },
791
- ],
792
- },
793
- ];
794
-
795
- const routeTree = createRouteTreeFromModernRoutes(modernRoutes);
796
- const router = await loadRouteTree(routeTree, '/plain');
797
- const plain = getLooseRouteByModernRouteId(router, 'plain');
798
-
799
- expect(routeTree.options.wrapInSuspense).toBeUndefined();
800
- expect(plain.options.wrapInSuspense).toBeUndefined();
801
- });
802
-
803
- test('renders Modern generated Outlet through TanStack native outlet', async () => {
804
- const modernRoutes: TestNestedRoute[] = [
733
+ test('preserves returned non-404 Response loaders as loader data', async () => {
734
+ const response = new Response('route status payload', { status: 500 });
735
+ const routes: TestRouteObject[] = [
805
736
  {
806
- type: 'nested',
807
- origin: 'config',
808
737
  id: 'root',
809
- isRoot: true,
810
- component: () =>
811
- createElement('section', null, createElement(ModernOutlet)),
738
+ path: '/',
739
+ Component: () => null,
812
740
  children: [
813
741
  {
814
- type: 'nested',
815
- origin: 'config',
816
- id: 'plain',
817
- path: 'plain',
818
- component: () => createElement('main', null, 'Plain child route'),
742
+ id: 'broken',
743
+ path: 'broken',
744
+ loader: () => response,
745
+ Component: () => null,
819
746
  },
820
747
  ],
821
748
  },
822
749
  ];
823
750
 
824
- const routeTree = createRouteTreeFromModernRoutes(modernRoutes);
825
- const router = await loadRouteTree(routeTree, '/plain');
826
- const markup = renderToString(
827
- createElement(RouterProvider, { router } as never),
751
+ const routeTree = createRouteTreeFromRouteObjects(routes);
752
+ const router = await loadRouteTree(routeTree, '/broken');
753
+ const brokenMatch = router.state.matches.find(
754
+ match => match.routeId === '/broken',
828
755
  );
829
- const suspenseBoundaryCount = countCompletedSuspenseBoundaries(markup);
830
-
831
- expect(markup).toContain('Plain child route');
832
- expect(suspenseBoundaryCount).toBe(1);
833
- });
834
-
835
- test('preserves Modern generated client route metadata', () => {
836
- const modernRoutes: TestNestedRoute[] = [
837
- {
838
- type: 'nested',
839
- origin: 'config',
840
- id: 'root',
841
- isRoot: true,
842
- children: [
843
- {
844
- type: 'nested',
845
- origin: 'config',
846
- id: 'client',
847
- path: 'client',
848
- clientData: './client.data',
849
- hasAction: true,
850
- hasClientLoader: true,
851
- hasLoader: true,
852
- inValidSSRRoute: true,
853
- isClientComponent: true,
854
- },
855
- ],
856
- },
857
- ];
858
- const routeTree = createRouteTreeFromModernRoutes(modernRoutes);
859
- const router = createRouter({
860
- routeTree,
861
- history: createMemoryHistory({
862
- initialEntries: ['/client'],
863
- }),
864
- context: {},
865
- }) as unknown as TestRouter;
866
- const clientRoute = getLooseRouteByModernRouteId(router, 'client');
867
756
 
868
- expect(clientRoute.options.ssr).toBe(false);
869
- expect(clientRoute.options.staticData).toMatchObject({
870
- modernRouteHasAction: true,
871
- modernRouteHasClientLoader: true,
872
- modernRouteHasLoader: true,
873
- modernRouteIsClientComponent: true,
874
- });
757
+ expect(router.state.statusCode).toBe(200);
758
+ expect(brokenMatch?.loaderData).toBe(response);
759
+ expect(brokenMatch?.error).toBeUndefined();
875
760
  });
876
761
 
877
762
  test('preserves generated client metadata through RouteObject conversion', () => {
@@ -1,3 +1,4 @@
1
+ import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from '@modern-js/runtime-utils/router';
1
2
  import type React from 'react';
2
3
  import { isValidElement } from 'react';
3
4
  import { createRscProxy } from '../../src/runtime/rsc/createRscProxy';
@@ -30,6 +31,16 @@ async function readAll(stream: ReadableStream<Uint8Array>) {
30
31
  return chunks;
31
32
  }
32
33
 
34
+ function withNodeEnv<T>(value: string, callback: () => T): T {
35
+ const original = process.env.NODE_ENV;
36
+ process.env.NODE_ENV = value;
37
+ try {
38
+ return callback();
39
+ } finally {
40
+ process.env.NODE_ENV = original;
41
+ }
42
+ }
43
+
33
44
  describe('tanstack rsc runtime helpers', () => {
34
45
  afterEach(() => {
35
46
  __setTanstackRscPayloadDecoderForTests();
@@ -149,6 +160,65 @@ describe('tanstack rsc runtime helpers', () => {
149
160
  ).toBeUndefined();
150
161
  });
151
162
 
163
+ test('redacts production TanStack RSC server payload errors', () => {
164
+ const routeError = new ErrorResponseImpl(
165
+ 500,
166
+ 'secret status text',
167
+ 'route secret',
168
+ true,
169
+ );
170
+ const serverError = new Error('server secret');
171
+ serverError.stack = 'stack secret';
172
+
173
+ const payload = withNodeEnv('production', () =>
174
+ createTanstackRscServerPayload({
175
+ state: {
176
+ location: { href: '/products' },
177
+ matches: [
178
+ {
179
+ error: serverError,
180
+ params: {},
181
+ pathname: '/',
182
+ pathnameBase: '/',
183
+ route: { id: '__root__' },
184
+ routeId: '__root__',
185
+ },
186
+ {
187
+ error: routeError,
188
+ params: {},
189
+ pathname: '/products',
190
+ pathnameBase: '/products',
191
+ route: {
192
+ id: '/products',
193
+ parentRoute: { id: '__root__' },
194
+ options: { path: 'products' },
195
+ },
196
+ routeId: '/products',
197
+ },
198
+ ],
199
+ },
200
+ }),
201
+ );
202
+
203
+ expect(payload.errors).toMatchObject({
204
+ __root__: {
205
+ message: 'Unexpected Server Error',
206
+ stack: undefined,
207
+ __type: 'Error',
208
+ },
209
+ '/products': {
210
+ status: 500,
211
+ statusText: 'Internal Server Error',
212
+ data: 'Unexpected Server Error',
213
+ __type: 'RouteErrorResponse',
214
+ },
215
+ });
216
+ expect(JSON.stringify(payload.errors)).not.toContain('server secret');
217
+ expect(JSON.stringify(payload.errors)).not.toContain('route secret');
218
+ expect(JSON.stringify(payload.errors)).not.toContain('secret status text');
219
+ expect(JSON.stringify(payload.errors)).not.toContain('stack secret');
220
+ });
221
+
152
222
  test('converts TanStack RSC redirects to Modern RSC navigation headers', () => {
153
223
  const response = handleTanstackRscRedirect(
154
224
  new Headers({ Location: '/base/login' }),