@bleedingdev/modern-js-plugin-tanstack 3.9.0-ultramodern.10 → 3.9.0-ultramodern.12

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 (68) hide show
  1. package/dist/cjs/cli/artifacts.js +2 -1
  2. package/dist/cjs/cli/tanstackTypes/canonicalRoutes.js +7 -7
  3. package/dist/cjs/cli/tanstackTypes/shared.js +0 -14
  4. package/dist/cjs/cli/tanstackTypes/source.js +28 -63
  5. package/dist/cjs/runtime/lifecycle.js +0 -3
  6. package/dist/cjs/runtime/loaderBridge.js +10 -4
  7. package/dist/cjs/runtime/navigation.js +102 -0
  8. package/dist/cjs/runtime/plugin.js +6 -1
  9. package/dist/cjs/runtime/plugin.node.js +82 -64
  10. package/dist/cjs/runtime/routeTree/construction.js +19 -61
  11. package/dist/cjs/runtime/routeTree/loaders.js +1 -1
  12. package/dist/cjs/runtime/routeTree/paths.js +0 -6
  13. package/dist/cjs/runtime/utils.js +1 -0
  14. package/dist/cjs/shared/routeDescriptor.js +60 -0
  15. package/dist/esm/cli/artifacts.mjs +2 -1
  16. package/dist/esm/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  17. package/dist/esm/cli/tanstackTypes/shared.mjs +1 -9
  18. package/dist/esm/cli/tanstackTypes/source.mjs +29 -64
  19. package/dist/esm/runtime/lifecycle.mjs +1 -1
  20. package/dist/esm/runtime/loaderBridge.mjs +8 -5
  21. package/dist/esm/runtime/navigation.mjs +64 -0
  22. package/dist/esm/runtime/plugin.mjs +6 -1
  23. package/dist/esm/runtime/plugin.node.mjs +83 -65
  24. package/dist/esm/runtime/routeTree/construction.mjs +19 -61
  25. package/dist/esm/runtime/routeTree/loaders.mjs +2 -2
  26. package/dist/esm/runtime/routeTree/paths.mjs +1 -4
  27. package/dist/esm/runtime/utils.mjs +1 -0
  28. package/dist/esm/shared/routeDescriptor.mjs +22 -0
  29. package/dist/esm-node/cli/artifacts.mjs +2 -1
  30. package/dist/esm-node/cli/tanstackTypes/canonicalRoutes.mjs +7 -7
  31. package/dist/esm-node/cli/tanstackTypes/shared.mjs +1 -9
  32. package/dist/esm-node/cli/tanstackTypes/source.mjs +29 -64
  33. package/dist/esm-node/runtime/lifecycle.mjs +1 -1
  34. package/dist/esm-node/runtime/loaderBridge.mjs +8 -5
  35. package/dist/esm-node/runtime/navigation.mjs +65 -0
  36. package/dist/esm-node/runtime/plugin.mjs +6 -1
  37. package/dist/esm-node/runtime/plugin.node.mjs +83 -65
  38. package/dist/esm-node/runtime/routeTree/construction.mjs +19 -61
  39. package/dist/esm-node/runtime/routeTree/loaders.mjs +2 -2
  40. package/dist/esm-node/runtime/routeTree/paths.mjs +1 -4
  41. package/dist/esm-node/runtime/utils.mjs +1 -0
  42. package/dist/esm-node/shared/routeDescriptor.mjs +23 -0
  43. package/dist/types/cli/tanstackTypes/shared.d.ts +0 -2
  44. package/dist/types/runtime/lifecycle.d.ts +1 -1
  45. package/dist/types/runtime/loaderBridge.d.ts +1 -0
  46. package/dist/types/runtime/navigation.d.ts +4 -0
  47. package/dist/types/runtime/routeTree/paths.d.ts +0 -2
  48. package/dist/types/shared/routeDescriptor.d.ts +26 -0
  49. package/package.json +12 -12
  50. package/src/cli/artifacts.ts +6 -1
  51. package/src/cli/tanstackTypes/canonicalRoutes.ts +17 -12
  52. package/src/cli/tanstackTypes/shared.ts +0 -14
  53. package/src/cli/tanstackTypes/source.ts +43 -119
  54. package/src/runtime/lifecycle.ts +0 -1
  55. package/src/runtime/loaderBridge.ts +9 -9
  56. package/src/runtime/navigation.ts +78 -0
  57. package/src/runtime/plugin.node.tsx +111 -89
  58. package/src/runtime/plugin.tsx +6 -0
  59. package/src/runtime/routeTree/construction.ts +32 -112
  60. package/src/runtime/routeTree/loaders.ts +2 -4
  61. package/src/runtime/routeTree/paths.ts +0 -6
  62. package/src/runtime/utils.tsx +1 -0
  63. package/src/shared/routeDescriptor.ts +46 -0
  64. package/tests/router/generateRouteArtifacts.test.ts +36 -2
  65. package/tests/router/localisedIdentity.test.tsx +1 -0
  66. package/tests/router/prefetchLink.test.tsx +29 -0
  67. package/tests/router/serverPlugin.test.ts +145 -0
  68. package/tests/router/tanstackTypes.test.ts +120 -2
@@ -1,11 +1,14 @@
1
1
  // @effect-diagnostics asyncFunction:off strictBooleanExpressions:off
2
2
 
3
- import { canonicaliseLocalisedRoutes } from '@modern-js/i18n-runtime-extensions';
4
3
  import { DefaultNotFound } from '@modern-js/runtime/context';
5
4
  import type { RouteObject } from '@modern-js/runtime-utils/router';
6
5
  import type { AnyRoute, AnyRouter } from '@tanstack/react-router';
7
6
  import { rootRouteId } from '@tanstack/react-router';
8
7
 
8
+ import {
9
+ describeRouteTree,
10
+ type RouteDescriptor,
11
+ } from '../../shared/routeDescriptor';
9
12
  import { createRouteStaticData } from '../../shared/routeStaticData';
10
13
  import { withModernRouteMatchContext } from '../outlet';
11
14
  import {
@@ -14,7 +17,6 @@ import {
14
17
  toRouteComponent,
15
18
  } from './components';
16
19
  import { createModernShouldReload, wrapRouteObjectLoader } from './loaders';
17
- import { isRouteObjectPathlessLayout, toTanstackPath } from './paths';
18
20
  import { mergeModernRouteHandle } from './staticData';
19
21
  import {
20
22
  createTanstackRootRoute,
@@ -24,25 +26,16 @@ import {
24
26
  import type {
25
27
  ModernRouteObject,
26
28
  ModernTanstackRootRoute,
27
- MutableTanstackRoute,
28
29
  RouteRevalidationState,
29
30
  RouteTreeOptions,
30
31
  TanstackRootRouteOptions,
31
32
  TanstackRouteOptions,
32
33
  } from './types';
33
34
 
34
- function createRouteFromRouteObject(opts: {
35
- options?: RouteTreeOptions;
36
- parent: AnyRoute;
37
- pathlessFallbackId?: string;
38
- routeObject: RouteObject;
39
- }): AnyRoute {
40
- const {
41
- options = {},
42
- parent,
43
- pathlessFallbackId = 'pathless',
44
- routeObject,
45
- } = opts;
35
+ function createRouteOptions(
36
+ routeObject: RouteObject,
37
+ options: RouteTreeOptions,
38
+ ): TanstackRouteOptions {
46
39
  const modernRouteObject = routeObject as ModernRouteObject;
47
40
  const revalidationState: RouteRevalidationState = {};
48
41
  const shouldRevalidate = modernRouteObject.shouldRevalidate;
@@ -50,13 +43,8 @@ function createRouteFromRouteObject(opts: {
50
43
  shouldRevalidate,
51
44
  revalidationState,
52
45
  );
53
-
54
- const stableFallbackId =
55
- routeObject.id || modernRouteObject.file || pathlessFallbackId;
56
-
57
46
  const component = toRouteComponent(routeObject);
58
47
  const base: TanstackRouteOptions = {
59
- getParentRoute: () => parent,
60
48
  component,
61
49
  pendingComponent: toPendingComponent(routeObject),
62
50
  errorComponent: toErrorComponent(routeObject),
@@ -87,117 +75,49 @@ function createRouteFromRouteObject(opts: {
87
75
  base.shouldReload = shouldReload;
88
76
  }
89
77
 
90
- if (isRouteObjectPathlessLayout(routeObject)) {
91
- base.id = stableFallbackId;
92
- } else {
93
- base.path = routeObject.index
94
- ? '/'
95
- : toTanstackPath((routeObject.path as string) || '');
96
- }
97
-
98
- const route = createTanstackRoute(base);
99
- wrapRouteComponentWithModernContext(route, component, routeObject.id);
78
+ return base;
79
+ }
100
80
 
101
- const children = routeObject.children;
102
- if (children && children.length > 0) {
103
- const childRoutes = children.map((child: RouteObject, index) =>
104
- createRouteFromRouteObject({
105
- options,
106
- parent: route,
107
- pathlessFallbackId: `pathless-${index}`,
108
- routeObject: child,
109
- }),
81
+ function createRouteFromDescriptor(
82
+ descriptor: RouteDescriptor<RouteObject>,
83
+ parent: AnyRoute,
84
+ options: RouteTreeOptions,
85
+ ): AnyRoute {
86
+ const { source, location, children } = descriptor;
87
+ const routeOptions = createRouteOptions(source, options);
88
+ const route = createTanstackRoute({
89
+ ...routeOptions,
90
+ ...location,
91
+ getParentRoute: () => parent,
92
+ });
93
+ wrapRouteComponentWithModernContext(route, routeOptions.component, source.id);
94
+ if (children.length) {
95
+ route.addChildren(
96
+ children.map(child => createRouteFromDescriptor(child, route, options)),
110
97
  );
111
- route.addChildren(childRoutes);
112
98
  }
113
-
114
99
  return route;
115
100
  }
116
101
 
117
- function getRootLikeRouteObject(routes: RouteObject[]) {
118
- return routes.find(route => route.path === '/' && !route.index);
119
- }
120
-
121
102
  export function createRouteTreeFromRouteObjects(
122
103
  routes: RouteObject[],
123
104
  options: RouteTreeOptions = {},
124
105
  ): ModernTanstackRootRoute {
125
- routes = canonicaliseLocalisedRoutes(routes);
126
- const rootLikeRoute = getRootLikeRouteObject(routes) as
127
- | ModernRouteObject
128
- | undefined;
129
- const rootRevalidationState: RouteRevalidationState = {};
130
- const rootShouldRevalidate = rootLikeRoute?.shouldRevalidate;
131
- const rootShouldReload = createModernShouldReload(
132
- rootShouldRevalidate,
133
- rootRevalidationState,
134
- );
135
-
136
- const rootComponent = rootLikeRoute
137
- ? toRouteComponent(rootLikeRoute)
138
- : undefined;
106
+ const { root, children } = describeRouteTree(routes);
139
107
  const rootRouteOptions: TanstackRootRouteOptions = {
140
- component: rootComponent,
141
- pendingComponent: rootLikeRoute
142
- ? toPendingComponent(rootLikeRoute)
143
- : undefined,
144
- errorComponent: rootLikeRoute ? toErrorComponent(rootLikeRoute) : undefined,
145
- validateSearch: rootLikeRoute?.validateSearch,
146
- loaderDeps: rootLikeRoute?.loaderDeps,
108
+ ...(root ? createRouteOptions(root, options) : {}),
147
109
  notFoundComponent: DefaultNotFound,
148
- staticData: createRouteStaticData({
149
- modernRouteId: rootLikeRoute?.id,
150
- modernRouteAction: rootLikeRoute?.action,
151
- modernRouteHandle: rootLikeRoute
152
- ? mergeModernRouteHandle(rootLikeRoute)
153
- : undefined,
154
- modernRouteHasAction:
155
- rootLikeRoute?.hasAction || Boolean(rootLikeRoute?.action),
156
- modernRouteHasClientLoader:
157
- rootLikeRoute?.hasClientLoader ||
158
- typeof rootLikeRoute?.clientData !== 'undefined',
159
- modernRouteHasLoader:
160
- rootLikeRoute?.hasLoader || typeof rootLikeRoute?.loader === 'function',
161
- modernRouteIsClientComponent: rootLikeRoute?.isClientComponent,
162
- modernRouteLoader: rootLikeRoute?.loader,
163
- modernRouteShouldRevalidate: rootShouldRevalidate,
164
- }),
165
- loader: rootLikeRoute
166
- ? wrapRouteObjectLoader(rootLikeRoute, rootRevalidationState, options)
167
- : undefined,
168
110
  };
169
- if (rootShouldReload) {
170
- rootRouteOptions.shouldReload = rootShouldReload;
171
- }
172
- if (rootLikeRoute?.inValidSSRRoute) {
173
- rootRouteOptions.ssr = false;
174
- }
175
-
176
111
  const rootRoute = createTanstackRootRoute(rootRouteOptions);
177
- if (rootComponent) {
112
+ if (rootRouteOptions.component) {
178
113
  rootRoute.options.component = withModernRouteMatchContext(
179
- rootComponent,
114
+ rootRouteOptions.component,
180
115
  rootRouteId,
181
116
  ) as typeof rootRoute.options.component;
182
117
  }
183
-
184
- const topLevel = rootLikeRoute
185
- ? [
186
- ...((rootLikeRoute.children as RouteObject[] | undefined) || []),
187
- ...routes.filter(route => route !== rootLikeRoute),
188
- ]
189
- : routes;
190
-
191
- const childRoutes = topLevel.map((routeObject, index) =>
192
- createRouteFromRouteObject({
193
- options,
194
- parent: rootRoute,
195
- pathlessFallbackId: `pathless-${index}`,
196
- routeObject,
197
- }),
118
+ rootRoute.addChildren(
119
+ children.map(child => createRouteFromDescriptor(child, rootRoute, options)),
198
120
  );
199
-
200
- rootRoute.addChildren(childRoutes);
201
121
  return rootRoute as unknown as ModernTanstackRootRoute;
202
122
  }
203
123
 
@@ -3,6 +3,7 @@
3
3
  import type { RouteObject } from '@modern-js/runtime-utils/router';
4
4
  import {
5
5
  getLoaderHref,
6
+ getLoaderSignal,
6
7
  handleModernLoaderError,
7
8
  handleModernLoaderResult,
8
9
  mapSplatParamsForModernLoader,
@@ -118,10 +119,7 @@ export function wrapRouteObjectLoader(
118
119
  } catch {}
119
120
  }
120
121
 
121
- const signal: AbortSignal =
122
- ctx?.abortController?.signal ||
123
- ctx?.signal ||
124
- new AbortController().signal;
122
+ const signal = getLoaderSignal(ctx);
125
123
  const baseRequest: Request | undefined =
126
124
  ctx?.context?.request instanceof Request
127
125
  ? ctx.context.request
@@ -1,7 +1,5 @@
1
1
  // @effect-diagnostics asyncFunction:off strictBooleanExpressions:off
2
2
 
3
- import type { RouteObject } from '@modern-js/runtime-utils/router';
4
-
5
3
  export function toTanstackPath(pathname: string): string {
6
4
  // TanStack Router uses `$param` and `$` (splat) style params.
7
5
  // Modern's conventional routing currently generates React Router style params (e.g. `:id`, `*`).
@@ -30,7 +28,3 @@ export function toTanstackPath(pathname: string): string {
30
28
  })
31
29
  .join('/');
32
30
  }
33
-
34
- export function isRouteObjectPathlessLayout(route: RouteObject) {
35
- return !route.path && !route.index;
36
- }
@@ -55,6 +55,7 @@ function toTanstackRouteObject(
55
55
  if (route.type === 'nested') {
56
56
  return {
57
57
  modernLocalisedRoute: Reflect.get(route, 'modernLocalisedRoute'),
58
+ isRoot: Reflect.get(route, 'isRoot'),
58
59
  path: route.path,
59
60
  id: route.id,
60
61
  loader: route.loader,
@@ -0,0 +1,46 @@
1
+ import { toTanstackPath } from '../runtime/routeTree/paths';
2
+
3
+ type RouteShape = {
4
+ id?: string;
5
+ file?: string;
6
+ path?: string;
7
+ index?: boolean;
8
+ isRoot?: boolean;
9
+ children?: RouteShape[];
10
+ };
11
+
12
+ /** Browser-safe structure shared by the executable tree and concrete TS emitter. */
13
+ export type RouteDescriptor<T extends RouteShape> = {
14
+ source: T;
15
+ location: { id: string; path?: never } | { path: string; id?: never };
16
+ hasSplat: boolean;
17
+ children: RouteDescriptor<T>[];
18
+ };
19
+
20
+ export function describeRouteTree<T extends RouteShape>(routes: T[]) {
21
+ const root = routes.find(
22
+ route =>
23
+ route.isRoot === true || (route.path === '/' && route.index !== true),
24
+ );
25
+ const describe = (route: T, index: number): RouteDescriptor<T> => ({
26
+ source: route,
27
+ location:
28
+ (route.path ?? '').length === 0 && route.index !== true
29
+ ? { id: route.id || route.file || `pathless-${index}` }
30
+ : {
31
+ path: route.index === true ? '/' : toTanstackPath(route.path || ''),
32
+ },
33
+ hasSplat: Boolean(route.path?.includes('*')),
34
+ children: ((route.children ?? []) as T[]).map(describe),
35
+ });
36
+ return {
37
+ root,
38
+ children: (root !== undefined
39
+ ? [
40
+ ...((root.children ?? []) as T[]),
41
+ ...routes.filter(route => route !== root),
42
+ ]
43
+ : routes
44
+ ).map(describe),
45
+ };
46
+ }
@@ -5,7 +5,9 @@ const createRunOptionsMock = rstest.fn(async (options: unknown) => ({
5
5
  mockRunOptions: true,
6
6
  received: options,
7
7
  }));
8
- const cliInitMock = rstest.fn(async () => ({ appContext: {} }));
8
+ const cliInitMock = rstest.fn(async () => ({
9
+ appContext: { hooks: { onBeforeExit: { call: async () => {} } } },
10
+ }));
9
11
 
10
12
  rstest.mock('@modern-js/app-tools/cli/run', () => ({
11
13
  createRunOptions: createRunOptionsMock,
@@ -17,6 +19,7 @@ rstest.mock('@modern-js/plugin/cli', () => ({
17
19
  import fs from 'node:fs';
18
20
  import os from 'node:os';
19
21
  import path from 'node:path';
22
+ import { createAsyncHook } from '@modern-js/plugin';
20
23
  import { INTERNAL_RUNTIME_PLUGINS } from '@modern-js/utils';
21
24
  import {
22
25
  generateTanstackRouteArtifacts,
@@ -63,6 +66,12 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
63
66
  mockRunOptions: true,
64
67
  received: runOptions,
65
68
  });
69
+ const onBeforeExit = createAsyncHook<() => Promise<void>>();
70
+ let disposed = false;
71
+ onBeforeExit.tap(async () => {
72
+ await Promise.resolve();
73
+ disposed = true;
74
+ });
66
75
  cliInitMock.mockImplementation(async () => {
67
76
  await writeTanstackRouterTypesForEntries({
68
77
  appContext: {
@@ -88,7 +97,7 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
88
97
  ] as any,
89
98
  },
90
99
  });
91
- return { appContext: {} };
100
+ return { appContext: { hooks: { onBeforeExit } } };
92
101
  });
93
102
 
94
103
  try {
@@ -110,6 +119,7 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
110
119
  );
111
120
 
112
121
  expect(cliInitMock).toHaveBeenCalledTimes(1);
122
+ expect(disposed).toBe(true);
113
123
 
114
124
  expect(
115
125
  fs.existsSync(
@@ -131,6 +141,30 @@ describe('generateTanstackRouteArtifacts (headless routes-generate)', () => {
131
141
  }
132
142
  });
133
143
 
144
+ it('preserves initialization and cleanup failures', async () => {
145
+ const appDirectory = makeAppDir(true);
146
+ const initializationFailure = new Error('prepare failed');
147
+ const cleanupFailure = new Error('dispose failed');
148
+ const onBeforeExit = createAsyncHook<() => Promise<void>>();
149
+ onBeforeExit.tap(async () => {
150
+ throw cleanupFailure;
151
+ });
152
+ try {
153
+ cliInitMock.mockRejectedValueOnce(initializationFailure);
154
+ await expect(
155
+ generateTanstackRouteArtifacts({ appDirectory }),
156
+ ).rejects.toBe(initializationFailure);
157
+ cliInitMock.mockResolvedValueOnce({
158
+ appContext: { hooks: { onBeforeExit } },
159
+ });
160
+ await expect(
161
+ generateTanstackRouteArtifacts({ appDirectory }),
162
+ ).rejects.toBe(cleanupFailure);
163
+ } finally {
164
+ fs.rmSync(appDirectory, { force: true, recursive: true });
165
+ }
166
+ });
167
+
134
168
  it('throws a clear error when no config file exists', async () => {
135
169
  const appDirectory = makeAppDir(false);
136
170
 
@@ -55,6 +55,7 @@ test('localized URLs preserve native loader and params route identity in SSR and
55
55
  '/search': { en: '/find', cs: '/hledat' },
56
56
  '/resources/:id': { en: '/resources/:id', cs: '/zdroje/:id' },
57
57
  },
58
+ 'canonical',
58
59
  );
59
60
  const routeObjects = createTanstackRouteObjectsFromConfig({
60
61
  routesConfig: { routes },
@@ -7,6 +7,7 @@ import {
7
7
  } from '@tanstack/react-router';
8
8
  import { act, render, waitFor } from '@testing-library/react';
9
9
  import React from 'react';
10
+ import { createTanstackNavigation } from '../../src/runtime/navigation';
10
11
  import { Link, NavLink } from '../../src/runtime/prefetchLink';
11
12
 
12
13
  // Real-router harness (no module mocking): STATIC_ACTIVE_PROPS (the
@@ -120,3 +121,31 @@ describe('tanstack prefetch link adapter - aria-current override', () => {
120
121
  expect(ref.current).toBe(anchor);
121
122
  });
122
123
  });
124
+
125
+ test('provider navigation observes native navigation and preserves history state', async () => {
126
+ const { router, unmount } = await renderLink({ initialPath: '/' });
127
+ const capability = createTanstackNavigation(router);
128
+ const initial = capability.getSnapshot();
129
+ expect(capability.getSnapshot()).toBe(initial);
130
+ const listener = rstest.fn();
131
+ const stop = capability.subscribe(listener);
132
+ await act(() =>
133
+ capability.navigate('/settings?q=two#detail', {
134
+ replace: true,
135
+ state: { custom: true },
136
+ }),
137
+ );
138
+ expect(listener).toHaveBeenCalled();
139
+ expect(capability.getSnapshot().location).toEqual({
140
+ pathname: '/settings',
141
+ search: '?q=two',
142
+ hash: '#detail',
143
+ });
144
+ expect(router.state.location.state).toMatchObject({ custom: true });
145
+ expect(router.history.length).toBe(1);
146
+ stop();
147
+ listener.mockClear();
148
+ await act(() => capability.navigate('/'));
149
+ expect(listener).not.toHaveBeenCalled();
150
+ unmount();
151
+ });
@@ -9,6 +9,8 @@ import {
9
9
  } from '../../src/runtime/lifecycle';
10
10
  import { tanstackRouterPlugin } from '../../src/runtime/plugin.node';
11
11
  import type { TanstackRouterPluginAPI } from '../../src/runtime/pluginShared';
12
+ import * as ssrUtils from '../../src/runtime/ssrManagedTags';
13
+ import * as ssrPreload from '../../src/runtime/ssrPreload';
12
14
  import type { RouterConfig } from '../../src/runtime/types';
13
15
 
14
16
  type BeforeRenderListener = Parameters<
@@ -155,3 +157,146 @@ describe('tanstack server plugin router results', () => {
155
157
  expect(status).not.toHaveBeenCalled();
156
158
  });
157
159
  });
160
+
161
+ describe('TanStack preparation resource lifetime', () => {
162
+ test.each([
163
+ 'attach',
164
+ 'load',
165
+ 'result',
166
+ 'preload',
167
+ 'dehydrate',
168
+ 'abort',
169
+ 'clientRender',
170
+ 'afterCreate',
171
+ ])('disposes exactly once when %s fails', async stage => {
172
+ const failure = new Error(`${stage} failed`);
173
+ const cleanup = rstest.fn();
174
+ const controller = new AbortController();
175
+ const { context } = createServerContext('/');
176
+ context.ssrContext!.request.raw = new Request('http://localhost/', {
177
+ signal: controller.signal,
178
+ });
179
+ const attach = ssrUtils.attachServerSsrUtils;
180
+ rstest
181
+ .spyOn(ssrUtils, 'attachServerSsrUtils')
182
+ .mockImplementation(async router => {
183
+ await attach(router);
184
+ rstest.spyOn(router.serverSsr!, 'cleanup').mockImplementation(cleanup);
185
+ if (stage === 'attach') throw failure;
186
+ if (stage === 'load')
187
+ rstest.spyOn(router, 'load').mockRejectedValue(failure);
188
+ if (stage === 'result')
189
+ rstest.spyOn(router, 'load').mockResolvedValue(undefined);
190
+ if (stage === 'dehydrate')
191
+ rstest
192
+ .spyOn(router.serverSsr!, 'dehydrate')
193
+ .mockRejectedValue(failure);
194
+ if (stage === 'abort') controller.abort(failure);
195
+ });
196
+ if (stage === 'clientRender') {
197
+ context.ssrContext!.loaderFailureMode = 'clientRender';
198
+ rstest
199
+ .spyOn(ssrUtils, 'collectRouterErrors')
200
+ .mockReturnValue({ root: failure });
201
+ }
202
+ if (stage === 'afterCreate')
203
+ rstest
204
+ .spyOn(routerProviderRegistryHooks.onAfterCreateRouter, 'call')
205
+ .mockImplementation(() => {
206
+ throw failure;
207
+ });
208
+ if (stage === 'preload')
209
+ rstest
210
+ .spyOn(ssrPreload, 'preloadMatchedRouteComponents')
211
+ .mockRejectedValue(failure);
212
+ const beforeRender = collectBeforeRender(() => [
213
+ { id: 'root', path: '/', Component: () => null },
214
+ ]);
215
+ await expect(beforeRender(context, value => value)).rejects.toThrow(
216
+ stage === 'result' ? 'without a server result' : failure.message,
217
+ );
218
+ expect(cleanup).toHaveBeenCalledTimes(1);
219
+ await getRouterRuntimeState(context)?.cleanup?.();
220
+ expect(cleanup).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ test.each([
224
+ 'string',
225
+ 'stream',
226
+ ] as const)('retains %s router resources for response termination', async mode => {
227
+ const { context } = createServerContext('/products/shoe?q=1#detail');
228
+ context.ssrContext!.mode = mode;
229
+ const beforeRender = collectBeforeRender(() => [
230
+ { id: 'product', path: '/products/:id', Component: () => null },
231
+ ]);
232
+ await beforeRender(context, value => value);
233
+ const state = getRouterRuntimeState(context)!;
234
+ const router = state.instance as AnyRouter;
235
+ const cleanup = rstest.spyOn(router.serverSsr!, 'cleanup');
236
+ const navigation = state.navigation!;
237
+ expect(navigation.getSnapshot()).toMatchObject({
238
+ location: { pathname: '/products/shoe', search: '?q=1', hash: '#detail' },
239
+ params: { id: 'shoe' },
240
+ });
241
+ expect(navigation.getSnapshot()).toBe(navigation.getSnapshot());
242
+ expect(navigation.Link).toBe(context.router?.Link);
243
+ expect(
244
+ navigation.createLinkProps!({
245
+ pathname: '/products/shoe',
246
+ href: '/products/shoe?q=2#detail',
247
+ search: { q: '2' },
248
+ hash: 'detail',
249
+ hashScrollIntoView: false,
250
+ prefetch: 'none',
251
+ }),
252
+ ).toEqual({
253
+ to: '/products/shoe',
254
+ search: { q: '2' },
255
+ hash: 'detail',
256
+ hashScrollIntoView: false,
257
+ preload: false,
258
+ });
259
+ expect(cleanup).not.toHaveBeenCalled();
260
+ await state.cleanup?.();
261
+ await state.cleanup?.();
262
+ expect(cleanup).toHaveBeenCalledTimes(1);
263
+ });
264
+ });
265
+
266
+ test('incoming abort reaches an active Modern loader and disposes its router', async () => {
267
+ const incoming = new AbortController();
268
+ let started!: () => void;
269
+ const loading = new Promise<void>(resolve => {
270
+ started = resolve;
271
+ });
272
+ const { context } = createServerContext('/');
273
+ context.ssrContext!.request.raw = new Request('http://localhost/', {
274
+ signal: incoming.signal,
275
+ });
276
+ const failure = new Error('client disconnected during loader');
277
+ const beforeRender = collectBeforeRender(() => [
278
+ {
279
+ id: 'root',
280
+ path: '/',
281
+ Component: () => null,
282
+ loader: ({ request }: { request: Request }) =>
283
+ new Promise((_resolve, reject) => {
284
+ request.signal.addEventListener(
285
+ 'abort',
286
+ () => reject(request.signal.reason),
287
+ { once: true },
288
+ );
289
+ started();
290
+ }),
291
+ },
292
+ ]);
293
+ const pending = beforeRender(context, value => value);
294
+ await loading;
295
+ const router = getRouterRuntimeState(context)!.instance as AnyRouter;
296
+ const cleanup = rstest.spyOn(router.serverSsr!, 'cleanup');
297
+ incoming.abort(failure);
298
+ await expect(pending).rejects.toBe(failure);
299
+ expect(cleanup).toHaveBeenCalledTimes(1);
300
+ await getRouterRuntimeState(context)?.cleanup?.();
301
+ expect(cleanup).toHaveBeenCalledTimes(1);
302
+ });