@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
@@ -0,0 +1,162 @@
1
+ const path = require('node:path');
2
+
3
+ const scenario = process.argv[2];
4
+ const serverPath = process.argv[3];
5
+ const clientPath = process.argv[4];
6
+ let moduleLoads = 0;
7
+
8
+ global.__webpack_chunk_load__ = async () => {};
9
+ global.__webpack_require__ = () => {
10
+ moduleLoads += 1;
11
+ return {
12
+ action(formData) {
13
+ return formData;
14
+ },
15
+ };
16
+ };
17
+ global.__rspack_rsc_hot_reloader__ = { on: () => () => {} };
18
+ global.__rspack_rsc_manifest__ = {
19
+ clientManifest: {},
20
+ entryCssFiles: {},
21
+ entryJsFiles: [],
22
+ moduleLoading: { prefix: '', crossOrigin: '' },
23
+ serverConsumerModuleMap: {},
24
+ serverManifest: Object.fromEntries(
25
+ Array.from({ length: 8 }, (_, index) => [
26
+ `action-${index}`,
27
+ { id: 'server-module', chunks: [], name: 'action' },
28
+ ]),
29
+ ),
30
+ };
31
+
32
+ let mapArrayConstructions = 0;
33
+ if (scenario === 'cycle') {
34
+ const NativeMap = Map;
35
+ global.Map = class CountedMap extends NativeMap {
36
+ constructor(iterable) {
37
+ if (Array.isArray(iterable)) {
38
+ mapArrayConstructions += 1;
39
+ }
40
+ super(iterable);
41
+ }
42
+ };
43
+ }
44
+
45
+ const server = require(path.resolve(serverPath));
46
+
47
+ function output(value) {
48
+ process.stdout.write(JSON.stringify(value), () => process.exit(0));
49
+ }
50
+
51
+ async function main() {
52
+ switch (scenario) {
53
+ case 'cycle': {
54
+ const body = new FormData();
55
+ body.set(
56
+ '0',
57
+ JSON.stringify([
58
+ ...Array.from({ length: 8 }, (_, index) => [`key-${index}`, index]),
59
+ ...Array.from({ length: 8 }, () => '$Q0'),
60
+ ]),
61
+ );
62
+ let error;
63
+ try {
64
+ await server.decodeReply(body);
65
+ } catch (caught) {
66
+ error = caught;
67
+ }
68
+ output({ mapArrayConstructions, rejected: error instanceof Error });
69
+ return;
70
+ }
71
+ case 'referenced-form-data': {
72
+ const body = new FormData();
73
+ body.append('_1_item', 'first');
74
+ body.append('_2_item', 'second');
75
+ body.append('0', JSON.stringify(['$K1', '$K2']));
76
+ let keyIterations = 0;
77
+ const originalKeys = body.keys.bind(body);
78
+ body.keys = () => {
79
+ keyIterations += 1;
80
+ return originalKeys();
81
+ };
82
+ const decoded = await server.decodeReply(body);
83
+ output({
84
+ keyIterations,
85
+ values: decoded.map(value => value.get('item')),
86
+ });
87
+ return;
88
+ }
89
+ case 'blob-type': {
90
+ const body = new FormData();
91
+ body.set('1', 'not-a-blob');
92
+ body.set('0', JSON.stringify('$B1'));
93
+ let error;
94
+ try {
95
+ await server.decodeReply(body);
96
+ } catch (caught) {
97
+ error = caught;
98
+ }
99
+ output({
100
+ rejected: error instanceof Error,
101
+ returnedString: error === undefined,
102
+ });
103
+ return;
104
+ }
105
+ case 'async-iterator': {
106
+ let throwCalls = 0;
107
+ const reason = new Error('iterator failed');
108
+ const iterator = {
109
+ next: () => Promise.reject(reason),
110
+ throw: () => {
111
+ throwCalls += 1;
112
+ return throwCalls < 6
113
+ ? Promise.reject(reason)
114
+ : {
115
+ // biome-ignore lint/suspicious/noThenProperty: The regression requires a deliberately pending thenable to bound the vulnerable recursion.
116
+ then: () => {},
117
+ };
118
+ },
119
+ };
120
+ const iterable = { [Symbol.asyncIterator]: () => iterator };
121
+ try {
122
+ await server.decodeReplyFromAsyncIterable(iterable);
123
+ } catch {}
124
+ await new Promise(resolve => setImmediate(resolve));
125
+ output({ throwCalls });
126
+ return;
127
+ }
128
+ case 'action-load': {
129
+ const body = new FormData();
130
+ for (let index = 0; index < 8; index += 1) {
131
+ body.append(`$ACTION_ID_action-${index}`, '');
132
+ }
133
+ const action = await server.decodeAction(body);
134
+ if (action === null) {
135
+ throw new Error('Expected an action');
136
+ }
137
+ const submitted = await action();
138
+ output({
139
+ moduleLoads,
140
+ submittedEntries: Array.from(submitted.entries()),
141
+ });
142
+ return;
143
+ }
144
+ case 'form-data-roundtrip': {
145
+ const client = require(path.resolve(clientPath));
146
+ const nested = new FormData();
147
+ nested.append('item', 'first');
148
+ nested.append('item', 'second');
149
+ const encoded = await client.encodeReply({ nested });
150
+ const decoded = await server.decodeReply(encoded);
151
+ output({ values: decoded.nested.getAll('item') });
152
+ return;
153
+ }
154
+ default:
155
+ throw new Error(`Unknown scenario: ${scenario}`);
156
+ }
157
+ }
158
+
159
+ main().catch(error => {
160
+ console.error(error);
161
+ process.exitCode = 1;
162
+ });
@@ -11,7 +11,7 @@ describe('tanstack router-core preload redirects', () => {
11
11
  rstest.restoreAllMocks();
12
12
  });
13
13
 
14
- test('keeps concurrent cached preload redirects from reading deleted matches', async () => {
14
+ test('resolves concurrent preload redirects without stale loader work', async () => {
15
15
  const consoleErrorSpy = rstest
16
16
  .spyOn(console, 'error')
17
17
  .mockImplementation(() => {});
@@ -58,12 +58,7 @@ describe('tanstack router-core preload redirects', () => {
58
58
 
59
59
  expect(consoleErrorSpy).not.toHaveBeenCalled();
60
60
  expect(router.options.defaultStructuralSharing).toBe(true);
61
- expect(redirectLoaderCalls).toBeGreaterThan(0);
62
- expect(targetLoader).toHaveBeenCalled();
63
- expect(
64
- router.stores.cachedMatches
65
- .get()
66
- .some(match => match.status === 'redirected'),
67
- ).toBe(false);
61
+ expect(redirectLoaderCalls).toBe(1);
62
+ expect(targetLoader).toHaveBeenCalledTimes(1);
68
63
  });
69
64
  });
@@ -3,7 +3,12 @@ import path from 'node:path';
3
3
  import type { RouteObject } from '@modern-js/runtime-utils/router';
4
4
  import type { NestedRoute } from '@modern-js/types';
5
5
  import { createMemoryHistory } from '@tanstack/history';
6
- import { createRouter, Outlet, RouterProvider } from '@tanstack/react-router';
6
+ import {
7
+ createRouter,
8
+ Outlet,
9
+ RouterProvider,
10
+ redirect,
11
+ } from '@tanstack/react-router';
7
12
  import type { ComponentType } from 'react';
8
13
  import { createElement, lazy } from 'react';
9
14
  import { renderToStaticMarkup, renderToString } from 'react-dom/server';
@@ -77,15 +82,23 @@ type PreloadableTestComponent = {
77
82
  };
78
83
 
79
84
  type TestRouter = {
85
+ _serverResult?:
86
+ | {
87
+ type: 'redirect';
88
+ redirect: Response;
89
+ }
90
+ | {
91
+ type: 'render';
92
+ status: 200 | 404 | 500;
93
+ };
80
94
  load: () => Promise<void>;
81
- looseRoutesById: Partial<Record<string, TestRoute>>;
95
+ routesById: Partial<Record<string, TestRoute>>;
82
96
  state: {
83
97
  matches: Array<{
84
98
  error?: unknown;
85
99
  loaderData?: unknown;
86
100
  routeId: string;
87
101
  }>;
88
- statusCode?: number;
89
102
  };
90
103
  };
91
104
 
@@ -110,19 +123,19 @@ async function loadRouteTree(
110
123
  return router as unknown as TestRouter;
111
124
  }
112
125
 
113
- function getLooseRoute(router: TestRouter, id: string): TestRoute {
114
- const route = router.looseRoutesById[id];
126
+ function getRoute(router: TestRouter, id: string): TestRoute {
127
+ const route = router.routesById[id];
115
128
  if (!route) {
116
129
  throw new Error(`Expected TanStack route ${id} to exist`);
117
130
  }
118
131
  return route;
119
132
  }
120
133
 
121
- function getLooseRouteByModernRouteId(
134
+ function getRouteByModernRouteId(
122
135
  router: TestRouter,
123
136
  modernRouteId: string,
124
137
  ): TestRoute {
125
- const route = Object.values(router.looseRoutesById).find(
138
+ const route = Object.values(router.routesById).find(
126
139
  route => route?.options.staticData?.modernRouteId === modernRouteId,
127
140
  );
128
141
  if (!route) {
@@ -255,7 +268,47 @@ describe('tanstack route tree from RouteObject[]', () => {
255
268
  const routeTree = createRouteTreeFromRouteObjects(routes);
256
269
  const router = await loadRouteTree(routeTree, '/missing');
257
270
 
258
- expect(router.state.statusCode).toBe(404);
271
+ expect(router._serverResult).toMatchObject({
272
+ type: 'render',
273
+ status: 404,
274
+ });
275
+ });
276
+
277
+ test('reports native TanStack loader redirects as server responses', async () => {
278
+ const routes: RouteObject[] = [
279
+ {
280
+ id: 'root',
281
+ path: '/',
282
+ Component: () => null,
283
+ children: [
284
+ {
285
+ id: 'redirect',
286
+ path: 'redirect',
287
+ loader: () => {
288
+ throw redirect({ to: '/target' });
289
+ },
290
+ Component: () => null,
291
+ },
292
+ {
293
+ id: 'target',
294
+ path: 'target',
295
+ Component: () => null,
296
+ },
297
+ ],
298
+ },
299
+ ];
300
+
301
+ const routeTree = createRouteTreeFromRouteObjects(routes);
302
+ const router = await loadRouteTree(routeTree, '/redirect');
303
+
304
+ expect(router._serverResult?.type).toBe('redirect');
305
+ if (router._serverResult?.type !== 'redirect') {
306
+ throw new Error('Expected a TanStack redirect server result');
307
+ }
308
+ expect(router._serverResult.redirect.status).toBe(307);
309
+ expect(router._serverResult.redirect.headers.get('Location')).toBe(
310
+ '/target',
311
+ );
259
312
  });
260
313
 
261
314
  test('does not force Suspense wrappers for ordinary generated routes', async () => {
@@ -278,9 +331,7 @@ describe('tanstack route tree from RouteObject[]', () => {
278
331
  const router = await loadRouteTree(routeTree, '/plain');
279
332
 
280
333
  expect(routeTree.options.wrapInSuspense).toBeUndefined();
281
- expect(
282
- getLooseRoute(router, '/plain').options.wrapInSuspense,
283
- ).toBeUndefined();
334
+ expect(getRoute(router, '/plain').options.wrapInSuspense).toBeUndefined();
284
335
  });
285
336
 
286
337
  test('renders Modern Outlet through TanStack native outlet', async () => {
@@ -395,7 +446,7 @@ describe('tanstack route tree from RouteObject[]', () => {
395
446
  }),
396
447
  context: {},
397
448
  }) as unknown as TestRouter;
398
- const searchRoute = getLooseRoute(router, '/search');
449
+ const searchRoute = getRoute(router, '/search');
399
450
 
400
451
  expect(routeTree.options.validateSearch).toBe(rootValidateSearch);
401
452
  expect(routeTree.options.loaderDeps).toBe(rootLoaderDeps);
@@ -516,7 +567,7 @@ describe('tanstack route tree from RouteObject[]', () => {
516
567
 
517
568
  const routeTree = createRouteTreeFromRouteObjects(routes);
518
569
  const router = await loadRouteTree(routeTree, '/lazy');
519
- const lazyRoute = getLooseRoute(router, '/lazy');
570
+ const lazyRoute = getRoute(router, '/lazy');
520
571
  const component = lazyRoute.options.component as ComponentType & {
521
572
  preload?: () => Promise<unknown>;
522
573
  };
@@ -550,7 +601,7 @@ describe('tanstack route tree from RouteObject[]', () => {
550
601
 
551
602
  const routeTree = createRouteTreeFromRouteObjects(routes);
552
603
  const router = await loadRouteTree(routeTree, '/lazy');
553
- const lazyRoute = getLooseRoute(router, '/lazy');
604
+ const lazyRoute = getRoute(router, '/lazy');
554
605
  const lazyComponent = lazyRoute.options
555
606
  .component as PreloadableTestComponent;
556
607
 
@@ -586,7 +637,7 @@ describe('tanstack route tree from RouteObject[]', () => {
586
637
 
587
638
  const routeTree = createRouteTreeFromRouteObjects(routes);
588
639
  const router = await loadRouteTree(routeTree, '/load-only');
589
- const loadOnlyRoute = getLooseRoute(router, '/load-only');
640
+ const loadOnlyRoute = getRoute(router, '/load-only');
590
641
  const loadOnlyComponent = loadOnlyRoute.options
591
642
  .component as PreloadableTestComponent;
592
643
 
@@ -665,7 +716,7 @@ describe('tanstack route tree from RouteObject[]', () => {
665
716
 
666
717
  const routeTree = createRouteTreeFromRouteObjects(routes);
667
718
  const router = await loadRouteTree(routeTree, '/user/123');
668
- const userRoute = getLooseRoute(router, '/user/$id');
719
+ const userRoute = getRoute(router, '/user/$id');
669
720
 
670
721
  expect(routeTree.options.staticData.modernRouteHandle).toEqual({
671
722
  shell: true,
@@ -718,7 +769,7 @@ describe('tanstack route tree from RouteObject[]', () => {
718
769
 
719
770
  const routeTree = createRouteTreeFromRouteObjects(routes);
720
771
  const router = await loadRouteTree(routeTree, '/client');
721
- const clientRoute = getLooseRoute(router, '/client');
772
+ const clientRoute = getRoute(router, '/client');
722
773
 
723
774
  expect(clientRoute.options.ssr).toBe(false);
724
775
  expect(clientRoute.options.staticData).toMatchObject({
@@ -789,7 +840,10 @@ describe('tanstack route tree from RouteObject[]', () => {
789
840
  match => match.routeId === '/broken',
790
841
  );
791
842
 
792
- expect(router.state.statusCode).toBe(200);
843
+ expect(router._serverResult).toMatchObject({
844
+ type: 'render',
845
+ status: 200,
846
+ });
793
847
  expect(brokenMatch?.loaderData).toBe(response);
794
848
  expect(brokenMatch?.error).toBeUndefined();
795
849
  });
@@ -829,7 +883,7 @@ describe('tanstack route tree from RouteObject[]', () => {
829
883
  }),
830
884
  context: {},
831
885
  }) as unknown as TestRouter;
832
- const clientRoute = getLooseRouteByModernRouteId(router, 'client');
886
+ const clientRoute = getRouteByModernRouteId(router, 'client');
833
887
 
834
888
  expect(clientRoute.options.ssr).toBe(false);
835
889
  expect(clientRoute.options.staticData).toMatchObject({
@@ -199,12 +199,6 @@ describe('runtime router compatibility', () => {
199
199
  runTsgo(tsgoBin, path.join(tempRoot, 'emit.json'));
200
200
 
201
201
  const declarationPath = path.join(emittedRoot, 'router.d.ts');
202
- const declaration = readFileSync(declarationPath, 'utf8');
203
- expect(declaration).not.toMatch(
204
- /\b(?:createRouterPlugin|RouterPluginFactory)\b/u,
205
- );
206
- expect(declaration).toContain("['routerPlugin']");
207
-
208
202
  writeFileSync(
209
203
  path.join(tempRoot, 'legacyRuntimeRouter.ts'),
210
204
  readFileSync(legacyFixturePath, 'utf8'),
@@ -0,0 +1,113 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ type DecoderScenario =
6
+ | 'action-load'
7
+ | 'async-iterator'
8
+ | 'blob-type'
9
+ | 'cycle'
10
+ | 'form-data-roundtrip'
11
+ | 'referenced-form-data';
12
+
13
+ const resolvePackage = createRequire(
14
+ path.join(__dirname, 'rsc-decoder-security-resolver.cjs'),
15
+ ).resolve;
16
+ const childPath = path.join(
17
+ __dirname,
18
+ 'fixtures',
19
+ 'rscDecoderSecurityChild.cjs',
20
+ );
21
+
22
+ function runDecoderScenario(options: {
23
+ entry: 'edge' | 'node';
24
+ nodeEnv: 'development' | 'production';
25
+ scenario: DecoderScenario;
26
+ }): Record<string, unknown> {
27
+ const result = spawnSync(
28
+ process.execPath,
29
+ [
30
+ '--conditions=react-server',
31
+ childPath,
32
+ options.scenario,
33
+ resolvePackage(`react-server-dom-rspack/server.${options.entry}`),
34
+ resolvePackage(`react-server-dom-rspack/client.${options.entry}`),
35
+ ],
36
+ {
37
+ encoding: 'utf8',
38
+ env: { ...process.env, NODE_ENV: options.nodeEnv },
39
+ timeout: 5_000,
40
+ },
41
+ );
42
+
43
+ if (result.error || result.status !== 0) {
44
+ throw new Error(
45
+ [
46
+ `RSC decoder child failed for ${options.entry}/${options.nodeEnv}/${options.scenario}`,
47
+ result.stdout,
48
+ result.stderr,
49
+ ]
50
+ .filter(Boolean)
51
+ .join('\n'),
52
+ { cause: result.error },
53
+ );
54
+ }
55
+
56
+ return JSON.parse(result.stdout) as Record<string, unknown>;
57
+ }
58
+
59
+ for (const entry of ['node', 'edge'] as const) {
60
+ for (const nodeEnv of ['development', 'production'] as const) {
61
+ describe(`${entry} ${nodeEnv} Flight decoder security`, () => {
62
+ test('bounds cyclic Map reconstruction before invoking constructors', () => {
63
+ expect(
64
+ runDecoderScenario({ entry, nodeEnv, scenario: 'cycle' }),
65
+ ).toEqual({
66
+ mapArrayConstructions: 2,
67
+ rejected: true,
68
+ });
69
+ });
70
+
71
+ test('decodes referenced FormData with one backing-store enumeration', () => {
72
+ expect(
73
+ runDecoderScenario({
74
+ entry,
75
+ nodeEnv,
76
+ scenario: 'referenced-form-data',
77
+ }),
78
+ ).toEqual({ keyIterations: 1, values: ['first', 'second'] });
79
+ });
80
+
81
+ test('round-trips referenced FormData through the public client and server APIs', () => {
82
+ expect(
83
+ runDecoderScenario({
84
+ entry,
85
+ nodeEnv,
86
+ scenario: 'form-data-roundtrip',
87
+ }),
88
+ ).toEqual({ values: ['first', 'second'] });
89
+ });
90
+
91
+ test('rejects a Blob reference backed by a string', () => {
92
+ expect(
93
+ runDecoderScenario({ entry, nodeEnv, scenario: 'blob-type' }),
94
+ ).toEqual({
95
+ rejected: true,
96
+ returnedString: false,
97
+ });
98
+ });
99
+
100
+ test('does not recursively throw into a rejecting async iterator', () => {
101
+ expect(
102
+ runDecoderScenario({ entry, nodeEnv, scenario: 'async-iterator' }),
103
+ ).toEqual({ throwCalls: 1 });
104
+ });
105
+
106
+ test('loads one server module for eight distinct action identifiers', () => {
107
+ expect(
108
+ runDecoderScenario({ entry, nodeEnv, scenario: 'action-load' }),
109
+ ).toEqual({ moduleLoads: 1, submittedEntries: [] });
110
+ });
111
+ });
112
+ }
113
+ }
@@ -0,0 +1,115 @@
1
+ import type { TInternalRuntimeContext } from '@modern-js/runtime/context';
2
+ import { routerProviderRegistryHooks } from '@modern-js/runtime/context';
3
+ import { getRouterServerSnapshot } from '../../src/runtime/lifecycle';
4
+ import { tanstackRouterPlugin } from '../../src/runtime/plugin.node';
5
+ import type { TanstackRouterPluginAPI } from '../../src/runtime/pluginShared';
6
+ import type { RouterConfig } from '../../src/runtime/types';
7
+
8
+ type BeforeRenderListener = Parameters<
9
+ TanstackRouterPluginAPI['onBeforeRender']
10
+ >[0];
11
+
12
+ function collectBeforeRender(
13
+ createRoutes: NonNullable<RouterConfig['createRoutes']>,
14
+ ) {
15
+ let listener: BeforeRenderListener | undefined;
16
+ tanstackRouterPlugin({ createRoutes }).setup?.({
17
+ getRuntimeConfig: () => ({}),
18
+ getHooks: () => routerProviderRegistryHooks,
19
+ onBeforeRender: nextListener => {
20
+ listener = nextListener;
21
+ },
22
+ wrapRoot: () => {},
23
+ });
24
+
25
+ if (!listener) {
26
+ throw new Error('Expected the TanStack server plugin to register a hook');
27
+ }
28
+ return listener;
29
+ }
30
+
31
+ function createServerContext(pathname: string) {
32
+ const status = rstest.fn();
33
+ const context = {
34
+ ssrContext: {
35
+ baseUrl: '/',
36
+ loaderContext: {},
37
+ mode: 'string',
38
+ request: {
39
+ raw: new Request(`http://localhost${pathname}`),
40
+ },
41
+ response: { status },
42
+ },
43
+ } as unknown as TInternalRuntimeContext;
44
+
45
+ return { context, status };
46
+ }
47
+
48
+ describe('tanstack server plugin router results', () => {
49
+ afterEach(() => {
50
+ rstest.restoreAllMocks();
51
+ });
52
+
53
+ test('uses the router render result as the HTTP and hydration status', async () => {
54
+ const beforeRender = collectBeforeRender(() => [
55
+ {
56
+ id: 'root',
57
+ path: '/',
58
+ Component: () => null,
59
+ children: [
60
+ {
61
+ id: 'target',
62
+ path: 'target',
63
+ Component: () => null,
64
+ },
65
+ ],
66
+ },
67
+ ]);
68
+ const { context, status } = createServerContext('/target');
69
+
70
+ await beforeRender(context, value => value);
71
+
72
+ expect(status).toHaveBeenCalledWith(200);
73
+ expect(getRouterServerSnapshot(context)).toMatchObject({
74
+ framework: 'tanstack',
75
+ statusCode: 200,
76
+ });
77
+ });
78
+
79
+ test('interrupts SSR with the router redirect response', async () => {
80
+ const beforeRender = collectBeforeRender(() => [
81
+ {
82
+ id: 'root',
83
+ path: '/',
84
+ Component: () => null,
85
+ children: [
86
+ {
87
+ id: 'redirect',
88
+ path: 'redirect',
89
+ loader: () =>
90
+ new Response(null, {
91
+ status: 307,
92
+ headers: { Location: '/target' },
93
+ }),
94
+ Component: () => null,
95
+ },
96
+ {
97
+ id: 'target',
98
+ path: 'target',
99
+ Component: () => null,
100
+ },
101
+ ],
102
+ },
103
+ ]);
104
+ const { context, status } = createServerContext('/redirect');
105
+ const interrupt = rstest.fn((value: unknown) => value);
106
+
107
+ const response = await beforeRender(context, interrupt);
108
+
109
+ expect(response).toBeInstanceOf(Response);
110
+ expect((response as Response).status).toBe(307);
111
+ expect((response as Response).headers.get('Location')).toBe('/target');
112
+ expect(interrupt).toHaveBeenCalledWith(response);
113
+ expect(status).not.toHaveBeenCalled();
114
+ });
115
+ });