@bleedingdev/modern-js-plugin-tanstack 3.9.0-ultramodern.2 → 3.9.0-ultramodern.21

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 (137) hide show
  1. package/dist/cjs/cli/artifacts.js +15 -1
  2. package/dist/cjs/cli/index.js +12 -0
  3. package/dist/cjs/cli/routeMetadata.js +131 -0
  4. package/dist/cjs/cli/tanstackTypes/canonicalRoutes.js +7 -7
  5. package/dist/cjs/cli/tanstackTypes/shared.js +0 -14
  6. package/dist/cjs/cli/tanstackTypes/source.js +27 -60
  7. package/dist/cjs/runtime/basepathRewrite.js +13 -6
  8. package/dist/cjs/runtime/lifecycle.js +6 -9
  9. package/dist/cjs/runtime/loaderBridge.js +10 -4
  10. package/dist/cjs/runtime/navigation.js +102 -0
  11. package/dist/cjs/runtime/plugin.js +7 -2
  12. package/dist/cjs/runtime/plugin.node.js +86 -64
  13. package/dist/cjs/runtime/prefetchLink.js +2 -7
  14. package/dist/cjs/runtime/register.js +0 -2
  15. package/dist/cjs/runtime/routeTree/construction.js +19 -59
  16. package/dist/cjs/runtime/routeTree/loaders.js +1 -1
  17. package/dist/cjs/runtime/routeTree/paths.js +0 -6
  18. package/dist/cjs/runtime/router.js +25 -9
  19. package/dist/cjs/runtime/ssrManagedTags.js +2 -12
  20. package/dist/cjs/runtime/state.js +2 -2
  21. package/dist/cjs/runtime/utils.js +3 -0
  22. package/dist/cjs/shared/routeDescriptor.js +60 -0
  23. package/dist/esm/cli/artifacts.mjs +13 -2
  24. package/dist/esm/cli/index.mjs +12 -9
  25. package/dist/esm/cli/routeMetadata.mjs +71 -0
  26. package/dist/esm/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  27. package/dist/esm/cli/tanstackTypes/shared.mjs +1 -9
  28. package/dist/esm/cli/tanstackTypes/source.mjs +28 -61
  29. package/dist/esm/runtime/basepathRewrite.mjs +10 -3
  30. package/dist/esm/runtime/lifecycle.mjs +1 -1
  31. package/dist/esm/runtime/loaderBridge.mjs +8 -5
  32. package/dist/esm/runtime/navigation.mjs +64 -0
  33. package/dist/esm/runtime/plugin.mjs +7 -2
  34. package/dist/esm/runtime/plugin.node.mjs +88 -66
  35. package/dist/esm/runtime/prefetchLink.mjs +1 -6
  36. package/dist/esm/runtime/register.mjs +0 -2
  37. package/dist/esm/runtime/routeTree/construction.mjs +19 -59
  38. package/dist/esm/runtime/routeTree/loaders.mjs +2 -2
  39. package/dist/esm/runtime/routeTree/paths.mjs +1 -4
  40. package/dist/esm/runtime/router.mjs +27 -11
  41. package/dist/esm/runtime/ssrManagedTags.mjs +1 -8
  42. package/dist/esm/runtime/state.mjs +1 -1
  43. package/dist/esm/runtime/utils.mjs +3 -0
  44. package/dist/esm/shared/routeDescriptor.mjs +22 -0
  45. package/dist/esm-node/cli/artifacts.mjs +13 -2
  46. package/dist/esm-node/cli/index.mjs +12 -9
  47. package/dist/esm-node/cli/routeMetadata.mjs +72 -0
  48. package/dist/esm-node/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  49. package/dist/esm-node/cli/tanstackTypes/shared.mjs +1 -9
  50. package/dist/esm-node/cli/tanstackTypes/source.mjs +28 -61
  51. package/dist/esm-node/runtime/basepathRewrite.mjs +10 -3
  52. package/dist/esm-node/runtime/lifecycle.mjs +1 -1
  53. package/dist/esm-node/runtime/loaderBridge.mjs +8 -5
  54. package/dist/esm-node/runtime/navigation.mjs +65 -0
  55. package/dist/esm-node/runtime/plugin.mjs +7 -2
  56. package/dist/esm-node/runtime/plugin.node.mjs +88 -66
  57. package/dist/esm-node/runtime/prefetchLink.mjs +1 -6
  58. package/dist/esm-node/runtime/register.mjs +0 -2
  59. package/dist/esm-node/runtime/routeTree/construction.mjs +19 -59
  60. package/dist/esm-node/runtime/routeTree/loaders.mjs +2 -2
  61. package/dist/esm-node/runtime/routeTree/paths.mjs +1 -4
  62. package/dist/esm-node/runtime/router.mjs +27 -11
  63. package/dist/esm-node/runtime/ssrManagedTags.mjs +1 -8
  64. package/dist/esm-node/runtime/state.mjs +1 -1
  65. package/dist/esm-node/runtime/utils.mjs +3 -0
  66. package/dist/esm-node/shared/routeDescriptor.mjs +23 -0
  67. package/dist/types/cli/artifacts.d.ts +13 -5
  68. package/dist/types/cli/index.d.ts +1 -1
  69. package/dist/types/cli/routeMetadata.d.ts +18 -0
  70. package/dist/types/cli/tanstackTypes/shared.d.ts +0 -2
  71. package/dist/types/runtime/basepathRewrite.d.ts +2 -1
  72. package/dist/types/runtime/lifecycle.d.ts +9 -2
  73. package/dist/types/runtime/loaderBridge.d.ts +1 -0
  74. package/dist/types/runtime/navigation.d.ts +4 -0
  75. package/dist/types/runtime/register.d.ts +1 -9
  76. package/dist/types/runtime/routeTree/paths.d.ts +0 -2
  77. package/dist/types/runtime/router.d.ts +2 -13
  78. package/dist/types/runtime/ssrManagedTags.d.ts +0 -1
  79. package/dist/types/runtime/ssrTypes.d.ts +0 -7
  80. package/dist/types/runtime/state.d.ts +1 -1
  81. package/dist/types/runtime/types.d.ts +2 -1
  82. package/dist/types/shared/routeDescriptor.d.ts +26 -0
  83. package/package.json +22 -19
  84. package/rstest.config.mts +2 -24
  85. package/src/cli/artifacts.ts +53 -6
  86. package/src/cli/index.ts +11 -2
  87. package/src/cli/routeMetadata.ts +121 -0
  88. package/src/cli/tanstackTypes/canonicalRoutes.ts +17 -12
  89. package/src/cli/tanstackTypes/shared.ts +0 -14
  90. package/src/cli/tanstackTypes/source.ts +42 -116
  91. package/src/runtime/basepathRewrite.ts +11 -2
  92. package/src/runtime/lifecycle.ts +14 -4
  93. package/src/runtime/loaderBridge.ts +9 -9
  94. package/src/runtime/navigation.ts +78 -0
  95. package/src/runtime/plugin.node.tsx +120 -93
  96. package/src/runtime/plugin.tsx +11 -1
  97. package/src/runtime/prefetchLink.tsx +1 -23
  98. package/src/runtime/register.ts +1 -12
  99. package/src/runtime/routeTree/construction.ts +32 -110
  100. package/src/runtime/routeTree/loaders.ts +2 -4
  101. package/src/runtime/routeTree/paths.ts +0 -6
  102. package/src/runtime/router.ts +25 -40
  103. package/src/runtime/ssrManagedTags.ts +0 -17
  104. package/src/runtime/ssrTypes.ts +0 -7
  105. package/src/runtime/state.ts +1 -1
  106. package/src/runtime/types.ts +2 -2
  107. package/src/runtime/utils.tsx +4 -1
  108. package/src/shared/routeDescriptor.ts +46 -0
  109. package/tests/router/cli.test.ts +148 -824
  110. package/tests/router/clientNavigation.ts +13 -0
  111. package/tests/router/dataMutation.test.tsx +17 -85
  112. package/tests/router/flightSerialization.roundtrip.test.tsx +9 -6
  113. package/tests/router/generateRouteArtifacts.test.ts +139 -27
  114. package/tests/router/loaderBridge.test.ts +0 -25
  115. package/tests/router/localisedIdentity.test.tsx +100 -0
  116. package/tests/router/prefetchLink.test.tsx +46 -21
  117. package/tests/router/prefetchLinkPreload.test.tsx +11 -32
  118. package/tests/router/routeHooks.test.ts +42 -105
  119. package/tests/router/routeMetadata.test.ts +233 -0
  120. package/tests/router/routeTree.test.ts +144 -836
  121. package/tests/router/rsc.test.tsx +131 -166
  122. package/tests/router/serverPlugin.test.ts +193 -1
  123. package/tests/router/tanstackTypes.test.ts +218 -963
  124. package/dist/esm/rslib-runtime~1.mjs +0 -18
  125. package/dist/esm-node/rslib-runtime~0.mjs +0 -19
  126. package/tests/realm-isolation.test.ts +0 -114
  127. package/tests/router/fastDefaults.test.ts +0 -32
  128. package/tests/router/fixtures/legacyRuntimeRouter.ts +0 -10
  129. package/tests/router/fixtures/rscDecoderSecurityChild.cjs +0 -162
  130. package/tests/router/hooks.test.ts +0 -40
  131. package/tests/router/hydrationBoundary.test.tsx +0 -23
  132. package/tests/router/packageSurface.test.ts +0 -24
  133. package/tests/router/preloadRedirect.test.ts +0 -64
  134. package/tests/router/register.test.ts +0 -8
  135. package/tests/router/routerCompatibility.test.ts +0 -239
  136. package/tests/router/rscDecoderSecurity.test.ts +0 -113
  137. package/tests/router/rscPayloadRouterMatrix.test.ts +0 -197
@@ -1,11 +1,4 @@
1
1
  import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from '@modern-js/runtime-utils/router';
2
- import type React from 'react';
3
- import { isValidElement } from 'react';
4
- import { createRscProxy } from '../../src/runtime/rsc/createRscProxy';
5
- import {
6
- reviveTanstackRscFlightValues,
7
- serializeTanstackRscFlightValues,
8
- } from '../../src/runtime/rsc/flightSerialization';
9
2
  import {
10
3
  __setTanstackRscPayloadDecoderForTests,
11
4
  createTanstackRscServerPayload,
@@ -13,12 +6,6 @@ import {
13
6
  loadTanstackRscRouteData,
14
7
  } from '../../src/runtime/rsc/payloadRouter';
15
8
  import { ReplayableStream } from '../../src/runtime/rsc/ReplayableStream';
16
- import {
17
- RENDERABLE_RSC,
18
- RSC_PROXY_PATH,
19
- SERVER_COMPONENT_STREAM,
20
- type ServerComponentStream,
21
- } from '../../src/runtime/rsc/symbols';
22
9
 
23
10
  async function readAll(stream: ReadableStream<Uint8Array>) {
24
11
  const reader = stream.getReader();
@@ -69,75 +56,6 @@ describe('tanstack rsc runtime helpers', () => {
69
56
  ]);
70
57
  });
71
58
 
72
- test('revives shared structures that the Flight serializer preserves', async () => {
73
- const shared = { label: 'shared' };
74
- const serialized = serializeTanstackRscFlightValues({
75
- left: shared,
76
- right: shared,
77
- }) as { left: unknown; right: unknown };
78
-
79
- const revived = (await reviveTanstackRscFlightValues(serialized)) as {
80
- left: unknown;
81
- right: unknown;
82
- };
83
-
84
- expect(serialized.left).toBe(serialized.right);
85
- expect(revived.left).toBe(revived.right);
86
- });
87
-
88
- test('serializes TanStack RSC flight values nested in map and set containers', () => {
89
- const stream: ServerComponentStream = {
90
- createReplayStream: () => new ReadableStream<Uint8Array>(),
91
- };
92
- const proxy = createRscProxy(() => null, {
93
- renderable: true,
94
- stream,
95
- }) as React.ReactElement & Record<PropertyKey, unknown>;
96
-
97
- const serialized = serializeTanstackRscFlightValues({
98
- map: new Map([['slot', proxy]]),
99
- set: new Set([proxy]),
100
- }) as {
101
- map: Map<string, Record<string, unknown>>;
102
- set: Set<Record<string, unknown>>;
103
- };
104
-
105
- const mapValue = serialized.map.get('slot');
106
- const [setValue] = serialized.set;
107
-
108
- expect(mapValue).not.toBe(proxy);
109
- expect(setValue).not.toBe(proxy);
110
- expect(mapValue).toMatchObject({
111
- __modernTanstackRsc: true,
112
- kind: 'renderable',
113
- });
114
- expect(setValue).toMatchObject({
115
- __modernTanstackRsc: true,
116
- kind: 'renderable',
117
- });
118
- });
119
-
120
- test('renderable RSC proxies preserve React element behavior and metadata', () => {
121
- const stream: ServerComponentStream = {
122
- createReplayStream: () => new ReadableStream<Uint8Array>(),
123
- };
124
- const proxy = createRscProxy(() => ({ sidebar: 'ok' }), {
125
- renderable: true,
126
- stream,
127
- }) as React.ReactElement & Record<PropertyKey, unknown>;
128
-
129
- expect(isValidElement(proxy)).toBe(true);
130
- expect(proxy[SERVER_COMPONENT_STREAM]).toBe(stream);
131
- expect(proxy[RENDERABLE_RSC]).toBe(true);
132
- expect(proxy.then).toBeUndefined();
133
- expect('__SEROVAL_STREAM__' in proxy).toBe(false);
134
- expect('__SEROVAL_SEQUENCE__' in proxy).toBe(false);
135
- expect(Symbol.iterator in proxy).toBe(false);
136
-
137
- const sidebar = proxy.sidebar as Record<PropertyKey, unknown>;
138
- expect(sidebar[RSC_PROXY_PATH]).toEqual(['sidebar']);
139
- });
140
-
141
59
  test('creates TanStack RSC server payload and omits client-loader data during RSC navigation', () => {
142
60
  const payload = createTanstackRscServerPayload(
143
61
  {
@@ -212,7 +130,7 @@ describe('tanstack rsc runtime helpers', () => {
212
130
  ).toBeUndefined();
213
131
  });
214
132
 
215
- test('redacts production TanStack RSC server payload errors', () => {
133
+ test('redacts production TanStack RSC server payload errors (Error, RouteErrorResponse, and plain-object)', () => {
216
134
  const routeError = new ErrorResponseImpl(
217
135
  500,
218
136
  'secret status text',
@@ -247,6 +165,14 @@ describe('tanstack rsc runtime helpers', () => {
247
165
  },
248
166
  routeId: '/products',
249
167
  },
168
+ {
169
+ error: { message: 'plain secret', token: 'token secret' },
170
+ params: {},
171
+ pathname: '/plain',
172
+ pathnameBase: '/plain',
173
+ route: { id: '/plain', parentRoute: { id: '__root__' } },
174
+ routeId: '/plain',
175
+ },
250
176
  ],
251
177
  },
252
178
  }),
@@ -264,69 +190,54 @@ describe('tanstack rsc runtime helpers', () => {
264
190
  data: 'Unexpected Server Error',
265
191
  __type: 'RouteErrorResponse',
266
192
  },
267
- });
268
- expect(JSON.stringify(payload.errors)).not.toContain('server secret');
269
- expect(JSON.stringify(payload.errors)).not.toContain('route secret');
270
- expect(JSON.stringify(payload.errors)).not.toContain('secret status text');
271
- expect(JSON.stringify(payload.errors)).not.toContain('stack secret');
272
- });
273
-
274
- test('redacts production TanStack RSC non-Error server payload errors', () => {
275
- const payload = withNodeEnv('production', () =>
276
- createTanstackRscServerPayload({
277
- state: {
278
- location: { href: '/plain' },
279
- matches: [
280
- {
281
- error: { message: 'plain secret', token: 'token secret' },
282
- params: {},
283
- pathname: '/plain',
284
- pathnameBase: '/plain',
285
- route: { id: '/plain' },
286
- routeId: '/plain',
287
- },
288
- ],
289
- },
290
- }),
291
- );
292
-
293
- expect(payload.errors).toMatchObject({
294
193
  '/plain': {
295
194
  message: 'Unexpected Server Error',
296
195
  stack: undefined,
297
196
  __type: 'Error',
298
197
  },
299
198
  });
199
+ expect(JSON.stringify(payload.errors)).not.toContain('server secret');
200
+ expect(JSON.stringify(payload.errors)).not.toContain('route secret');
201
+ expect(JSON.stringify(payload.errors)).not.toContain('secret status text');
202
+ expect(JSON.stringify(payload.errors)).not.toContain('stack secret');
300
203
  expect(JSON.stringify(payload.errors)).not.toContain('plain secret');
301
204
  expect(JSON.stringify(payload.errors)).not.toContain('token secret');
302
205
  });
303
206
 
304
- test('converts TanStack RSC redirects to Modern RSC navigation headers', () => {
305
- const response = handleTanstackRscRedirect(
306
- new Headers({ Location: '/base/login' }),
307
- '/base',
308
- 302,
309
- );
310
-
311
- expect(response.status).toBe(302);
312
- expect(response.headers.get('Location')).toBeNull();
313
- expect(response.headers.get('X-Modernjs-Redirect')).toBe('/login');
314
- expect(response.headers.get('X-Modernjs-BaseUrl')).toBe('/base');
315
- });
316
-
317
- test('preserves TanStack RSC redirect paths that do not start with the basename', () => {
318
- const response = handleTanstackRscRedirect(
319
- new Headers({ Location: '/shop/base/login' }),
320
- '/base',
321
- 302,
322
- );
207
+ test('preserves RSC redirect response status and headers in TanStack redirects', async () => {
208
+ const scenarios = [
209
+ { location: '/base', name: 'exact basename', redirect: '/' },
210
+ {
211
+ location: '/base/login?from=%2Fbase',
212
+ name: 'leading basename',
213
+ redirect: '/login?from=%2Fbase',
214
+ },
215
+ {
216
+ location: '/shop/base/login',
217
+ name: 'mid-path basename',
218
+ redirect: '/shop/base/login',
219
+ },
220
+ ] as const;
323
221
 
324
- expect(response.headers.get('X-Modernjs-Redirect')).toBe(
325
- '/shop/base/login',
326
- );
327
- });
222
+ for (const scenario of scenarios) {
223
+ const response = handleTanstackRscRedirect(
224
+ new Headers({
225
+ Location: scenario.location,
226
+ 'X-Trace': scenario.name,
227
+ }),
228
+ '/base',
229
+ 307,
230
+ );
231
+
232
+ expect(response.status).toBe(307);
233
+ expect(response.headers.get('Location')).toBeNull();
234
+ expect(response.headers.get('X-Modernjs-BaseUrl')).toBe('/base');
235
+ expect(response.headers.get('X-Modernjs-Redirect')).toBe(
236
+ scenario.redirect,
237
+ );
238
+ expect(response.headers.get('X-Trace')).toBe(scenario.name);
239
+ }
328
240
 
329
- test('preserves RSC redirect response status and headers in TanStack redirects', async () => {
330
241
  rstest.stubGlobal(
331
242
  'fetch',
332
243
  rstest.fn(() =>
@@ -362,47 +273,50 @@ describe('tanstack rsc runtime helpers', () => {
362
273
  expect(thrown.options?.to).toBe('/login');
363
274
  });
364
275
 
365
- test('loads one RSC payload for multiple server route loaders', async () => {
366
- const payload = {
367
- type: 'render',
368
- actionData: null,
369
- errors: null,
370
- loaderData: {
371
- __root__: { shell: true },
372
- '/products': { product: 1 },
276
+ test('separates RSC payload cache entries by URL and request method', async () => {
277
+ const payloads = [
278
+ {
279
+ type: 'render',
280
+ actionData: null,
281
+ errors: null,
282
+ loaderData: { '/method': { method: 'GET' } },
283
+ location: { href: '/method' },
284
+ routes: [{ id: '/method', hasLoader: true }],
373
285
  },
374
- location: { href: '/products' },
375
- routes: [
376
- { id: '__root__', hasLoader: true },
377
- { id: '/products', hasLoader: true },
378
- ],
379
- };
380
- const fetchMock = rstest.fn(() => Promise.resolve(new Response('payload')));
381
- const decodeMock = rstest.fn(async () => payload);
286
+ {
287
+ type: 'render',
288
+ actionData: null,
289
+ errors: null,
290
+ loaderData: { '/method': { method: 'POST' } },
291
+ location: { href: '/method' },
292
+ routes: [{ id: '/method', hasLoader: true }],
293
+ },
294
+ ];
295
+ let payloadIndex = 0;
296
+ const fetchMock = rstest.fn(() =>
297
+ Promise.resolve(new Response(JSON.stringify(payloads[payloadIndex++]))),
298
+ );
382
299
  rstest.stubGlobal('fetch', fetchMock);
383
- __setTanstackRscPayloadDecoderForTests(decodeMock);
300
+ __setTanstackRscPayloadDecoderForTests(async stream =>
301
+ JSON.parse(await new Response(stream).text()),
302
+ );
384
303
 
385
- const request = new Request('http://localhost/products');
386
- const [rootData, productData] = await Promise.all([
304
+ const [getData, postData] = await Promise.all([
387
305
  loadTanstackRscRouteData({
388
- loadClientData: async () => ({ fallback: 'root' }),
389
- request,
390
- routeId: '__root__',
306
+ loadClientData: async () => ({ fallback: 'get' }),
307
+ request: new Request('http://localhost/method', { method: 'GET' }),
308
+ routeId: '/method',
391
309
  }),
392
310
  loadTanstackRscRouteData({
393
- loadClientData: async () => ({ fallback: 'product' }),
394
- request,
395
- routeId: '/products',
311
+ loadClientData: async () => ({ fallback: 'post' }),
312
+ request: new Request('http://localhost/method', { method: 'POST' }),
313
+ routeId: '/method',
396
314
  }),
397
315
  ]);
398
316
 
399
- expect(rootData).toEqual({ shell: true });
400
- expect(productData).toEqual({ product: 1 });
401
- expect(fetchMock).toHaveBeenCalledTimes(1);
402
- expect(fetchMock.mock.calls[0]?.[1]?.headers.get('x-rsc-tree')).toBe(
403
- 'true',
404
- );
405
- expect(decodeMock).toHaveBeenCalledTimes(1);
317
+ expect(getData).toEqual({ method: 'GET' });
318
+ expect(postData).toEqual({ method: 'POST' });
319
+ expect(fetchMock).toHaveBeenCalledTimes(2);
406
320
  });
407
321
 
408
322
  test('separates RSC payload cache entries by forwarded request headers', async () => {
@@ -464,6 +378,57 @@ describe('tanstack rsc runtime helpers', () => {
464
378
  expect(fetchMock).toHaveBeenCalledTimes(2);
465
379
  });
466
380
 
381
+ test('rethrows matched route errors from the decoded payload', async () => {
382
+ const routeError = new Error('route loader failed');
383
+ const payload = {
384
+ type: 'render',
385
+ actionData: null,
386
+ errors: { '/broken': routeError },
387
+ loaderData: {},
388
+ location: { href: '/broken' },
389
+ routes: [{ id: '/broken', hasLoader: true }],
390
+ };
391
+ rstest.stubGlobal(
392
+ 'fetch',
393
+ rstest.fn(() => Promise.resolve(new Response('payload'))),
394
+ );
395
+ __setTanstackRscPayloadDecoderForTests(async () => payload);
396
+
397
+ await expect(
398
+ loadTanstackRscRouteData({
399
+ loadClientData: async () => ({ fallback: true }),
400
+ request: new Request('http://localhost/broken'),
401
+ routeId: '/broken',
402
+ }),
403
+ ).rejects.toBe(routeError);
404
+ });
405
+
406
+ test('returns undefined for a route id missing from the decoded payload', async () => {
407
+ const loadClientData = rstest.fn(async () => ({ fallback: true }));
408
+ const payload = {
409
+ type: 'render',
410
+ actionData: null,
411
+ errors: null,
412
+ loaderData: { '/known': { known: true } },
413
+ location: { href: '/known' },
414
+ routes: [{ id: '/known', hasLoader: true }],
415
+ };
416
+ rstest.stubGlobal(
417
+ 'fetch',
418
+ rstest.fn(() => Promise.resolve(new Response('payload'))),
419
+ );
420
+ __setTanstackRscPayloadDecoderForTests(async () => payload);
421
+
422
+ await expect(
423
+ loadTanstackRscRouteData({
424
+ loadClientData,
425
+ request: new Request('http://localhost/missing'),
426
+ routeId: '/missing',
427
+ }),
428
+ ).resolves.toBeUndefined();
429
+ expect(loadClientData).not.toHaveBeenCalled();
430
+ });
431
+
467
432
  test('uses client loader data without requesting an RSC payload for client-loader routes', async () => {
468
433
  const fetchMock = rstest.fn();
469
434
  const loadClientData = rstest.fn(async () => ({ client: true }));
@@ -1,8 +1,16 @@
1
1
  import type { TInternalRuntimeContext } from '@modern-js/runtime/context';
2
2
  import { routerProviderRegistryHooks } from '@modern-js/runtime/context';
3
- import { getRouterServerSnapshot } from '../../src/runtime/lifecycle';
3
+ import { type AnyRouter, RouterProvider } from '@tanstack/react-router';
4
+ import { createElement } from 'react';
5
+ import { renderToString } from 'react-dom/server';
6
+ import {
7
+ getRouterRuntimeState,
8
+ getRouterServerSnapshot,
9
+ } from '../../src/runtime/lifecycle';
4
10
  import { tanstackRouterPlugin } from '../../src/runtime/plugin.node';
5
11
  import type { TanstackRouterPluginAPI } from '../../src/runtime/pluginShared';
12
+ import * as ssrUtils from '../../src/runtime/ssrManagedTags';
13
+ import * as ssrPreload from '../../src/runtime/ssrPreload';
6
14
  import type { RouterConfig } from '../../src/runtime/types';
7
15
 
8
16
  type BeforeRenderListener = Parameters<
@@ -50,6 +58,42 @@ describe('tanstack server plugin router results', () => {
50
58
  rstest.restoreAllMocks();
51
59
  });
52
60
 
61
+ test.each([
62
+ '/cs/login',
63
+ '/en/login',
64
+ ])('renders native link active attributes during SSR at %s', async pathname => {
65
+ const { context } = createServerContext(pathname);
66
+ const beforeRender = collectBeforeRender(() => [
67
+ {
68
+ id: 'login',
69
+ path: '/:lang/login',
70
+ Component: () => {
71
+ const Link = context.router?.Link;
72
+ if (!Link) {
73
+ throw new Error('SSR router did not provide its native Link');
74
+ }
75
+ return createElement(Link, { to: '/cs' }, 'Home');
76
+ },
77
+ },
78
+ ]);
79
+ await beforeRender(context, value => value);
80
+
81
+ expect(context.router?.Link).toBeTypeOf('function');
82
+ const html = renderToString(
83
+ createElement(RouterProvider, {
84
+ router: getRouterRuntimeState(context)?.instance as AnyRouter,
85
+ }),
86
+ );
87
+ expect(html).toContain('href="/cs"');
88
+ if (pathname === '/cs/login') {
89
+ expect(html).toContain('data-status="active"');
90
+ expect(html).toContain('aria-current="page"');
91
+ } else {
92
+ expect(html).not.toContain('data-status="active"');
93
+ expect(html).not.toContain('aria-current="page"');
94
+ }
95
+ });
96
+
53
97
  test('uses the router render result as the HTTP and hydration status', async () => {
54
98
  const beforeRender = collectBeforeRender(() => [
55
99
  {
@@ -74,6 +118,11 @@ describe('tanstack server plugin router results', () => {
74
118
  framework: 'tanstack',
75
119
  statusCode: 200,
76
120
  });
121
+ // Without the dehydrated router the client re-renders matched remotes
122
+ // from scratch instead of hydrating the server HTML.
123
+ expect(getRouterServerSnapshot(context)?.hydrationScripts).toEqual(
124
+ expect.arrayContaining([expect.stringContaining('$_TSR')]),
125
+ );
77
126
  });
78
127
 
79
128
  test('interrupts SSR with the router redirect response', async () => {
@@ -113,3 +162,146 @@ describe('tanstack server plugin router results', () => {
113
162
  expect(status).not.toHaveBeenCalled();
114
163
  });
115
164
  });
165
+
166
+ describe('TanStack preparation resource lifetime', () => {
167
+ test.each([
168
+ 'attach',
169
+ 'load',
170
+ 'result',
171
+ 'preload',
172
+ 'dehydrate',
173
+ 'abort',
174
+ 'clientRender',
175
+ 'afterCreate',
176
+ ])('disposes exactly once when %s fails', async stage => {
177
+ const failure = new Error(`${stage} failed`);
178
+ const cleanup = rstest.fn();
179
+ const controller = new AbortController();
180
+ const { context } = createServerContext('/');
181
+ context.ssrContext!.request.raw = new Request('http://localhost/', {
182
+ signal: controller.signal,
183
+ });
184
+ const attach = ssrUtils.attachServerSsrUtils;
185
+ rstest
186
+ .spyOn(ssrUtils, 'attachServerSsrUtils')
187
+ .mockImplementation(async router => {
188
+ await attach(router);
189
+ rstest.spyOn(router.serverSsr!, 'cleanup').mockImplementation(cleanup);
190
+ if (stage === 'attach') throw failure;
191
+ if (stage === 'load')
192
+ rstest.spyOn(router, 'load').mockRejectedValue(failure);
193
+ if (stage === 'result')
194
+ rstest.spyOn(router, 'load').mockResolvedValue(undefined);
195
+ if (stage === 'dehydrate')
196
+ rstest
197
+ .spyOn(router.serverSsr!, 'dehydrate')
198
+ .mockRejectedValue(failure);
199
+ if (stage === 'abort') controller.abort(failure);
200
+ });
201
+ if (stage === 'clientRender') {
202
+ context.ssrContext!.loaderFailureMode = 'clientRender';
203
+ rstest
204
+ .spyOn(ssrUtils, 'collectRouterErrors')
205
+ .mockReturnValue({ root: failure });
206
+ }
207
+ if (stage === 'afterCreate')
208
+ rstest
209
+ .spyOn(routerProviderRegistryHooks.onAfterCreateRouter, 'call')
210
+ .mockImplementation(() => {
211
+ throw failure;
212
+ });
213
+ if (stage === 'preload')
214
+ rstest
215
+ .spyOn(ssrPreload, 'preloadMatchedRouteComponents')
216
+ .mockRejectedValue(failure);
217
+ const beforeRender = collectBeforeRender(() => [
218
+ { id: 'root', path: '/', Component: () => null },
219
+ ]);
220
+ await expect(beforeRender(context, value => value)).rejects.toThrow(
221
+ stage === 'result' ? 'without a server result' : failure.message,
222
+ );
223
+ expect(cleanup).toHaveBeenCalledTimes(1);
224
+ await getRouterRuntimeState(context)?.cleanup?.();
225
+ expect(cleanup).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ test.each([
229
+ 'string',
230
+ 'stream',
231
+ ] as const)('retains %s router resources for response termination', async mode => {
232
+ const { context } = createServerContext('/products/shoe?q=1#detail');
233
+ context.ssrContext!.mode = mode;
234
+ const beforeRender = collectBeforeRender(() => [
235
+ { id: 'product', path: '/products/:id', Component: () => null },
236
+ ]);
237
+ await beforeRender(context, value => value);
238
+ const state = getRouterRuntimeState(context)!;
239
+ const router = state.instance as AnyRouter;
240
+ const cleanup = rstest.spyOn(router.serverSsr!, 'cleanup');
241
+ const navigation = state.navigation!;
242
+ expect(navigation.getSnapshot()).toMatchObject({
243
+ location: { pathname: '/products/shoe', search: '?q=1', hash: '#detail' },
244
+ params: { id: 'shoe' },
245
+ });
246
+ expect(navigation.getSnapshot()).toBe(navigation.getSnapshot());
247
+ expect(navigation.Link).toBe(context.router?.Link);
248
+ expect(
249
+ navigation.createLinkProps!({
250
+ pathname: '/products/shoe',
251
+ href: '/products/shoe?q=2#detail',
252
+ search: { q: '2' },
253
+ hash: 'detail',
254
+ hashScrollIntoView: false,
255
+ prefetch: 'none',
256
+ }),
257
+ ).toEqual({
258
+ to: '/products/shoe',
259
+ search: { q: '2' },
260
+ hash: 'detail',
261
+ hashScrollIntoView: false,
262
+ preload: false,
263
+ });
264
+ expect(cleanup).not.toHaveBeenCalled();
265
+ await state.cleanup?.();
266
+ await state.cleanup?.();
267
+ expect(cleanup).toHaveBeenCalledTimes(1);
268
+ });
269
+ });
270
+
271
+ test('incoming abort reaches an active Modern loader and disposes its router', async () => {
272
+ const incoming = new AbortController();
273
+ let started!: () => void;
274
+ const loading = new Promise<void>(resolve => {
275
+ started = resolve;
276
+ });
277
+ const { context } = createServerContext('/');
278
+ context.ssrContext!.request.raw = new Request('http://localhost/', {
279
+ signal: incoming.signal,
280
+ });
281
+ const failure = new Error('client disconnected during loader');
282
+ const beforeRender = collectBeforeRender(() => [
283
+ {
284
+ id: 'root',
285
+ path: '/',
286
+ Component: () => null,
287
+ loader: ({ request }: { request: Request }) =>
288
+ new Promise((_resolve, reject) => {
289
+ request.signal.addEventListener(
290
+ 'abort',
291
+ () => reject(request.signal.reason),
292
+ { once: true },
293
+ );
294
+ started();
295
+ }),
296
+ },
297
+ ]);
298
+ const pending = beforeRender(context, value => value);
299
+ await loading;
300
+ const router = getRouterRuntimeState(context)!.instance as AnyRouter;
301
+ const cleanup = rstest.spyOn(router.serverSsr!, 'cleanup');
302
+ incoming.abort(failure);
303
+ await expect(pending).rejects.toBe(failure);
304
+ expect(cleanup).toHaveBeenCalledTimes(1);
305
+ await getRouterRuntimeState(context)?.cleanup?.();
306
+ expect(cleanup).toHaveBeenCalledTimes(1);
307
+ });