react-bun-ssr 0.1.1 → 0.2.0

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.
@@ -5,6 +5,7 @@ import {
5
5
  ensureDir,
6
6
  existsPath,
7
7
  glob,
8
+ removePath,
8
9
  statPath,
9
10
  writeTextIfChanged,
10
11
  } from './io';
@@ -25,12 +26,20 @@ const BUILD_OPTIMIZE_IMPORTS = [
25
26
  '@datadog/browser-rum-react',
26
27
  ];
27
28
 
28
- interface ClientEntryFile {
29
+ export interface ClientEntryFile {
29
30
  routeId: string;
30
31
  entryFilePath: string;
31
32
  route: PageRouteDefinition;
32
33
  }
33
34
 
35
+ export interface ClientEntrySyncResult {
36
+ entries: ClientEntryFile[];
37
+ addedEntryPaths: string[];
38
+ changedEntryPaths: string[];
39
+ removedEntryPaths: string[];
40
+ entrySetChanged: boolean;
41
+ }
42
+
34
43
  async function walkFiles(rootDir: string): Promise<string[]> {
35
44
  if (!(await existsPath(rootDir))) {
36
45
  return [];
@@ -39,6 +48,21 @@ async function walkFiles(rootDir: string): Promise<string[]> {
39
48
  return glob('**/*', { cwd: rootDir, absolute: true });
40
49
  }
41
50
 
51
+ function buildClientModuleProjectionSource(defaultRef: string, moduleRef: string): string {
52
+ return `projectClientModule(${defaultRef}, ${moduleRef})`;
53
+ }
54
+
55
+ function toClientEntryFile(options: {
56
+ route: PageRouteDefinition;
57
+ entryFilePath: string;
58
+ }): ClientEntryFile {
59
+ return {
60
+ routeId: options.route.id,
61
+ entryFilePath: options.entryFilePath,
62
+ route: options.route,
63
+ };
64
+ }
65
+
42
66
  function buildClientEntrySource(options: {
43
67
  generatedDir: string;
44
68
  route: PageRouteDefinition;
@@ -54,7 +78,7 @@ function buildClientEntrySource(options: {
54
78
  const routeImport = toImportPath(generatedDir, route.filePath);
55
79
 
56
80
  imports.push(
57
- `import { hydrateInitialRoute, registerRouteModules } from "${runtimeImport}";`,
81
+ `import { hydrateInitialRoute, projectClientModule, registerRouteModules } from "${runtimeImport}";`,
58
82
  );
59
83
 
60
84
  imports.push(`import RootDefault from "${rootImport}";`);
@@ -72,16 +96,16 @@ function buildClientEntrySource(options: {
72
96
  `import * as Layout${index}Module from "${layoutImportPath}";`,
73
97
  );
74
98
  layoutModuleRefs.push(
75
- `{ ...Layout${index}Module, default: Layout${index}Default }`,
99
+ buildClientModuleProjectionSource(`Layout${index}Default`, `Layout${index}Module`),
76
100
  );
77
101
  }
78
102
 
79
103
  return `${imports.join('\n')}
80
104
 
81
105
  const modules = {
82
- root: { ...RootModule, default: RootDefault },
106
+ root: ${buildClientModuleProjectionSource('RootDefault', 'RootModule')},
83
107
  layouts: [${layoutModuleRefs.join(', ')}],
84
- route: { ...RouteModule, default: RouteDefault },
108
+ route: ${buildClientModuleProjectionSource('RouteDefault', 'RouteModule')},
85
109
  };
86
110
 
87
111
  registerRouteModules(${JSON.stringify(route.id)}, modules);
@@ -112,13 +136,84 @@ export async function generateClientEntries(options: {
112
136
 
113
137
  await writeTextIfChanged(entryFilePath, source);
114
138
 
115
- return {
116
- routeId: route.id,
139
+ return toClientEntryFile({
140
+ route,
117
141
  entryFilePath,
142
+ });
143
+ }),
144
+ );
145
+ }
146
+
147
+ export async function syncClientEntries(options: {
148
+ config: ResolvedConfig;
149
+ manifest: RouteManifest;
150
+ generatedDir: string;
151
+ }): Promise<ClientEntrySyncResult> {
152
+ const { config, manifest, generatedDir } = options;
153
+ await ensureDir(generatedDir);
154
+
155
+ const runtimeClientFile = path.resolve(import.meta.dir, 'client-runtime.tsx');
156
+ const desiredEntries = new Map<string, { route: PageRouteDefinition; entryFilePath: string }>();
157
+ for (const route of manifest.pages) {
158
+ const entryFilePath = path.join(generatedDir, `route__${route.id}.tsx`);
159
+ desiredEntries.set(entryFilePath, {
160
+ route,
161
+ entryFilePath,
162
+ });
163
+ }
164
+
165
+ const existingEntryPaths = new Set(
166
+ (await glob('route__*.tsx', {
167
+ cwd: generatedDir,
168
+ absolute: true,
169
+ }))
170
+ .map((filePath) => path.resolve(filePath)),
171
+ );
172
+
173
+ const addedEntryPaths: string[] = [];
174
+ const changedEntryPaths: string[] = [];
175
+ const removedEntryPaths: string[] = [];
176
+
177
+ await Promise.all(
178
+ [...desiredEntries.values()].map(async ({ route, entryFilePath }) => {
179
+ const source = buildClientEntrySource({
180
+ generatedDir,
118
181
  route,
119
- };
182
+ rootModulePath: config.rootModule,
183
+ runtimeClientFile,
184
+ });
185
+ const existed = existingEntryPaths.has(entryFilePath);
186
+ const changed = await writeTextIfChanged(entryFilePath, source);
187
+ if (!existed) {
188
+ addedEntryPaths.push(entryFilePath);
189
+ } else if (changed) {
190
+ changedEntryPaths.push(entryFilePath);
191
+ }
192
+ existingEntryPaths.delete(entryFilePath);
193
+ }),
194
+ );
195
+
196
+ await Promise.all(
197
+ [...existingEntryPaths].map(async (entryFilePath) => {
198
+ removedEntryPaths.push(entryFilePath);
199
+ await removePath(entryFilePath);
120
200
  }),
121
201
  );
202
+
203
+ const entries = manifest.pages.map((route) => {
204
+ return toClientEntryFile({
205
+ route,
206
+ entryFilePath: path.join(generatedDir, `route__${route.id}.tsx`),
207
+ });
208
+ });
209
+
210
+ return {
211
+ entries,
212
+ addedEntryPaths: addedEntryPaths.sort(),
213
+ changedEntryPaths: changedEntryPaths.sort(),
214
+ removedEntryPaths: removedEntryPaths.sort(),
215
+ entrySetChanged: addedEntryPaths.length > 0 || removedEntryPaths.length > 0,
216
+ };
122
217
  }
123
218
 
124
219
  async function mapBuildOutputsByPrefix(options: {
@@ -155,7 +250,7 @@ async function mapBuildOutputsByPrefix(options: {
155
250
  return routeAssets;
156
251
  }
157
252
 
158
- function normalizeMetafilePath(filePath: string): string {
253
+ export function normalizeMetafilePath(filePath: string): string {
159
254
  return normalizeSlashes(filePath).replace(/^\.\//, "");
160
255
  }
161
256
 
@@ -163,7 +258,7 @@ function toPublicBuildPath(publicPrefix: string, filePath: string): string {
163
258
  return `${publicPrefix}${normalizeMetafilePath(filePath)}`;
164
259
  }
165
260
 
166
- function mapBuildOutputsFromMetafile(options: {
261
+ export function mapBuildOutputsFromMetafile(options: {
167
262
  metafile: Bun.BuildMetafile;
168
263
  entries: ClientEntryFile[];
169
264
  publicPrefix: string;
@@ -204,6 +299,21 @@ function mapBuildOutputsFromMetafile(options: {
204
299
  return routeAssets;
205
300
  }
206
301
 
302
+ export function listBuildOutputFiles(metafile: Bun.BuildMetafile): string[] {
303
+ return Object.keys(metafile.outputs)
304
+ .map(normalizeMetafilePath)
305
+ .sort();
306
+ }
307
+
308
+ export function createClientEntrySetSignature(entries: ClientEntryFile[]): string {
309
+ return stableHash(
310
+ entries
311
+ .map((entry) => normalizeSlashes(path.resolve(entry.entryFilePath)))
312
+ .sort()
313
+ .join('|'),
314
+ );
315
+ }
316
+
207
317
  export async function bundleClientEntries(options: {
208
318
  entries: ClientEntryFile[];
209
319
  outDir: string;
@@ -299,8 +409,17 @@ export function createBuildManifest(
299
409
  };
300
410
  }
301
411
 
302
- export async function discoverFileSignature(rootDir: string): Promise<string> {
303
- const files = (await walkFiles(rootDir))
412
+ export async function discoverFileSignature(rootPath: string): Promise<string> {
413
+ const rootStat = await statPath(rootPath);
414
+ if (!rootStat) {
415
+ return stableHash("");
416
+ }
417
+
418
+ const files = (
419
+ rootStat.isFile()
420
+ ? [rootPath]
421
+ : await walkFiles(rootPath)
422
+ )
304
423
  .filter((file) => !normalizeSlashes(file).includes('/node_modules/'))
305
424
  .sort();
306
425
 
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { ensureCleanDir, ensureDir, writeTextIfChanged } from "./io";
2
+ import { ensureDir, glob, removePath, writeTextIfChanged } from "./io";
3
+ import { matchRouteBySegments } from "./matcher";
3
4
  import { scanRoutes } from "./route-scanner";
4
5
  import type {
5
6
  ApiRouteDefinition,
@@ -116,17 +117,30 @@ async function writeProjectionRoutes<T extends PageRouteDefinition | ApiRouteDef
116
117
  }),
117
118
  );
118
119
 
120
+ const expectedPaths = new Set(writes.map(({ projectedFilePath }) => path.resolve(projectedFilePath)));
121
+ const existingPaths = new Set(await glob(`**/*${extension}`, {
122
+ cwd: outDir,
123
+ absolute: true,
124
+ }));
125
+
126
+ await Promise.all(
127
+ [...existingPaths]
128
+ .filter((projectedFilePath) => !expectedPaths.has(path.resolve(projectedFilePath)))
129
+ .map((projectedFilePath) => removePath(projectedFilePath)),
130
+ );
131
+
119
132
  return byProjectedFilePath;
120
133
  }
121
134
 
122
135
  function toRouteMatch<T extends PageRouteDefinition | ApiRouteDefinition>(
136
+ orderedRoutes: T[],
123
137
  routeByProjectedPath: Map<string, T>,
124
138
  pathname: string,
125
139
  router: Bun.FileSystemRouter,
126
140
  ): RouteMatch<T> | null {
127
141
  const matched = router.match(pathname);
128
142
  if (!matched) {
129
- return null;
143
+ return matchRouteBySegments(orderedRoutes, pathname);
130
144
  }
131
145
 
132
146
  const matchedSource = normalizeRouteKey(
@@ -136,7 +150,7 @@ function toRouteMatch<T extends PageRouteDefinition | ApiRouteDefinition>(
136
150
  );
137
151
  const route = routeByProjectedPath.get(matchedSource);
138
152
  if (!route) {
139
- return null;
153
+ return matchRouteBySegments(orderedRoutes, pathname);
140
154
  }
141
155
 
142
156
  return {
@@ -153,8 +167,7 @@ export async function createBunRouteAdapter(options: {
153
167
  const manifest = await scanRoutes(options.routesDir, {
154
168
  generatedMarkdownRootDir: options.generatedMarkdownRootDir,
155
169
  });
156
-
157
- await ensureCleanDir(options.projectionRootDir);
170
+ await ensureDir(options.projectionRootDir);
158
171
 
159
172
  const pagesProjectionDir = path.join(options.projectionRootDir, "pages");
160
173
  const apiProjectionDir = path.join(options.projectionRootDir, "api");
@@ -191,10 +204,10 @@ export async function createBunRouteAdapter(options: {
191
204
  return {
192
205
  manifest,
193
206
  matchPage(pathname) {
194
- return toRouteMatch(pageRouteByProjectedPath, pathname, pageRouter);
207
+ return toRouteMatch(manifest.pages, pageRouteByProjectedPath, pathname, pageRouter);
195
208
  },
196
209
  matchApi(pathname) {
197
- return toRouteMatch(apiRouteByProjectedPath, pathname, apiRouter);
210
+ return toRouteMatch(manifest.api, apiRouteByProjectedPath, pathname, apiRouter);
198
211
  },
199
212
  };
200
213
  }
@@ -1,4 +1,14 @@
1
1
  import { hydrateRoot, type Root } from "react-dom/client";
2
+ import {
3
+ consumeTransitionChunkText,
4
+ createTransitionChunkParserState,
5
+ flushTransitionChunkText,
6
+ isStaleNavigationToken,
7
+ matchClientPageRoute,
8
+ sanitizePrefetchCache,
9
+ shouldHardNavigateForRedirectDepth,
10
+ shouldSkipSoftNavigation,
11
+ } from "./client-transition-core";
2
12
  import { isDeferredToken } from "./deferred";
3
13
  import {
4
14
  addNavigationNavigateListener,
@@ -21,14 +31,12 @@ import {
21
31
  } from "./tree";
22
32
  import { isRouteErrorResponse } from "./route-errors";
23
33
  import type {
24
- ClientRouteSnapshot,
25
34
  ClientRouterSnapshot,
26
- Params,
27
35
  RenderPayload,
28
36
  RouteModule,
29
37
  RouteModuleBundle,
30
- TransitionChunk,
31
38
  TransitionDeferredChunk,
39
+ TransitionDocumentChunk,
32
40
  TransitionInitialChunk,
33
41
  TransitionRedirectChunk,
34
42
  } from "./types";
@@ -61,7 +69,7 @@ interface NavigateResult {
61
69
  interface PrefetchEntry {
62
70
  createdAt: number;
63
71
  modulePromise: Promise<void>;
64
- initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
72
+ initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
65
73
  donePromise: Promise<void>;
66
74
  }
67
75
 
@@ -71,7 +79,7 @@ interface TransitionRequestOptions {
71
79
  }
72
80
 
73
81
  interface TransitionRequestHandle {
74
- initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
82
+ initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
75
83
  donePromise: Promise<void>;
76
84
  }
77
85
 
@@ -126,7 +134,6 @@ declare global {
126
134
  }
127
135
  }
128
136
 
129
- const PREFETCH_TTL_MS = 30_000;
130
137
  const NAVIGATION_API_PENDING_TIMEOUT_MS = 1_500;
131
138
  const NAVIGATION_API_PENDING_MATCH_WINDOW_MS = 10_000;
132
139
  const ROUTE_ANNOUNCER_ID = "__rbssr-route-announcer";
@@ -137,69 +144,26 @@ let popstateBound = false;
137
144
  let navigationApiListenerBound = false;
138
145
  let navigationApiTransitionCounter = 0;
139
146
 
140
- function normalizePathname(pathname: string): string[] {
141
- if (!pathname || pathname === "/") {
142
- return [];
143
- }
144
-
145
- return pathname
146
- .replace(/^\/+/, "")
147
- .replace(/\/+$/, "")
148
- .split("/")
149
- .filter(Boolean)
150
- .map(part => decodeURIComponent(part));
147
+ function pickOptionalClientModuleExport<T>(
148
+ moduleValue: Record<string, unknown>,
149
+ exportName: string,
150
+ ): T | undefined {
151
+ const value = moduleValue[exportName];
152
+ return typeof value === "function" ? (value as T) : undefined;
151
153
  }
152
154
 
153
- function matchSegments(segments: ClientRouteSnapshot["segments"], pathname: string): Params | null {
154
- const pathParts = normalizePathname(pathname);
155
- const params: Params = {};
156
-
157
- let i = 0;
158
- let j = 0;
159
-
160
- while (i < segments.length) {
161
- const segment = segments[i]!;
162
-
163
- if (segment.kind === "catchall") {
164
- params[segment.value] = pathParts.slice(j).join("/");
165
- return params;
166
- }
167
-
168
- const current = pathParts[j];
169
- if (current === undefined) {
170
- return null;
171
- }
172
-
173
- if (segment.kind === "static") {
174
- if (segment.value !== current) {
175
- return null;
176
- }
177
- } else {
178
- params[segment.value] = current;
179
- }
180
-
181
- i += 1;
182
- j += 1;
183
- }
184
-
185
- if (j !== pathParts.length) {
186
- return null;
187
- }
188
-
189
- return params;
190
- }
191
-
192
- function matchPageRoute(
193
- routes: ClientRouteSnapshot[],
194
- pathname: string,
195
- ): { route: ClientRouteSnapshot; params: Params } | null {
196
- for (const route of routes) {
197
- const params = matchSegments(route.segments, pathname);
198
- if (params) {
199
- return { route, params };
200
- }
201
- }
202
- return null;
155
+ export function projectClientModule(
156
+ defaultExport: RouteModule["default"],
157
+ moduleValue: Record<string, unknown>,
158
+ ): RouteModule {
159
+ return {
160
+ default: defaultExport,
161
+ Loading: pickOptionalClientModuleExport<RouteModule["Loading"]>(moduleValue, "Loading"),
162
+ ErrorComponent: pickOptionalClientModuleExport<RouteModule["ErrorComponent"]>(moduleValue, "ErrorComponent"),
163
+ CatchBoundary: pickOptionalClientModuleExport<RouteModule["CatchBoundary"]>(moduleValue, "CatchBoundary"),
164
+ ErrorBoundary: pickOptionalClientModuleExport<RouteModule["ErrorBoundary"]>(moduleValue, "ErrorBoundary"),
165
+ NotFound: pickOptionalClientModuleExport<RouteModule["NotFound"]>(moduleValue, "NotFound"),
166
+ };
203
167
  }
204
168
 
205
169
  function withVersionQuery(url: string, version?: number): string {
@@ -402,50 +366,23 @@ function ensureRuntimeState(): RuntimeState {
402
366
  return runtimeState;
403
367
  }
404
368
 
405
- function sanitizePrefetchCache(cache: Map<string, PrefetchEntry>): void {
406
- const now = Date.now();
407
- for (const [key, entry] of cache.entries()) {
408
- if (now - entry.createdAt > PREFETCH_TTL_MS) {
409
- cache.delete(key);
410
- }
411
- }
412
- }
413
-
414
369
  function createTransitionUrl(toUrl: URL): URL {
415
370
  const transitionUrl = new URL("/__rbssr/transition", window.location.origin);
416
371
  transitionUrl.searchParams.set("to", toUrl.pathname + toUrl.search + toUrl.hash);
417
372
  return transitionUrl;
418
373
  }
419
374
 
420
- function splitLines(buffer: string): { lines: string[]; rest: string } {
421
- const lines: string[] = [];
422
- let start = 0;
423
-
424
- for (let index = 0; index < buffer.length; index += 1) {
425
- if (buffer[index] !== "\n") {
426
- continue;
427
- }
428
-
429
- const line = buffer.slice(start, index).trim();
430
- if (line.length > 0) {
431
- lines.push(line);
432
- }
433
- start = index + 1;
434
- }
435
-
436
- return {
437
- lines,
438
- rest: buffer.slice(start),
439
- };
440
- }
441
-
442
375
  function startTransitionRequest(
443
376
  toUrl: URL,
444
377
  options: TransitionRequestOptions = {},
445
378
  ): TransitionRequestHandle {
446
- let resolveInitial: (value: TransitionInitialChunk | TransitionRedirectChunk | null) => void = () => undefined;
379
+ let resolveInitial: (
380
+ value: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null,
381
+ ) => void = () => undefined;
447
382
  let rejectInitial: (reason?: unknown) => void = () => undefined;
448
- const initialPromise = new Promise<TransitionInitialChunk | TransitionRedirectChunk | null>((resolve, reject) => {
383
+ const initialPromise = new Promise<
384
+ TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null
385
+ >((resolve, reject) => {
449
386
  resolveInitial = resolve;
450
387
  rejectInitial = reject;
451
388
  });
@@ -464,8 +401,7 @@ function startTransitionRequest(
464
401
 
465
402
  const reader = response.body.getReader();
466
403
  const decoder = new TextDecoder();
467
- let initialChunk: TransitionInitialChunk | TransitionRedirectChunk | null = null;
468
- let textBuffer = "";
404
+ let parserState = createTransitionChunkParserState();
469
405
 
470
406
  while (true) {
471
407
  const { done, value } = await reader.read();
@@ -473,38 +409,35 @@ function startTransitionRequest(
473
409
  break;
474
410
  }
475
411
 
476
- textBuffer += decoder.decode(value, { stream: true });
477
- const { lines, rest } = splitLines(textBuffer);
478
- textBuffer = rest;
412
+ const previousInitialChunk = parserState.initialChunk;
413
+ const previousDeferredCount = parserState.deferredChunks.length;
414
+ parserState = consumeTransitionChunkText(
415
+ parserState,
416
+ decoder.decode(value, { stream: true }),
417
+ );
479
418
 
480
- for (const line of lines) {
481
- const chunk = JSON.parse(line) as TransitionChunk;
482
- if (chunk.type === "initial" || chunk.type === "redirect") {
483
- if (!initialChunk) {
484
- initialChunk = chunk;
485
- resolveInitial(initialChunk);
486
- }
487
- continue;
488
- }
419
+ if (!previousInitialChunk && parserState.initialChunk) {
420
+ resolveInitial(parserState.initialChunk);
421
+ }
489
422
 
423
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
490
424
  options.onDeferredChunk?.(chunk);
491
425
  }
492
426
  }
493
427
 
494
- const trailing = textBuffer.trim();
495
- if (trailing.length > 0) {
496
- const chunk = JSON.parse(trailing) as TransitionChunk;
497
- if (chunk.type === "initial" || chunk.type === "redirect") {
498
- if (!initialChunk) {
499
- initialChunk = chunk;
500
- resolveInitial(initialChunk);
501
- }
502
- } else {
503
- options.onDeferredChunk?.(chunk);
504
- }
428
+ const previousInitialChunk = parserState.initialChunk;
429
+ const previousDeferredCount = parserState.deferredChunks.length;
430
+ parserState = flushTransitionChunkText(parserState);
431
+
432
+ if (!previousInitialChunk && parserState.initialChunk) {
433
+ resolveInitial(parserState.initialChunk);
505
434
  }
506
435
 
507
- if (!initialChunk) {
436
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
437
+ options.onDeferredChunk?.(chunk);
438
+ }
439
+
440
+ if (!parserState.initialChunk) {
508
441
  resolveInitial(null);
509
442
  }
510
443
  })();
@@ -917,11 +850,11 @@ async function navigateToInternal(
917
850
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
918
851
  const targetPath = toUrl.pathname + toUrl.search + toUrl.hash;
919
852
 
920
- if (currentPath === targetPath && !options.isPopState && !options.historyManagedByNavigationApi) {
853
+ if (shouldSkipSoftNavigation(currentPath, targetPath, options)) {
921
854
  return null;
922
855
  }
923
856
 
924
- const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
857
+ const matched = matchClientPageRoute(state.routerSnapshot.pages, toUrl.pathname);
925
858
  const routeId = matched?.route.id ?? null;
926
859
 
927
860
  if (state.transitionAbortController) {
@@ -942,7 +875,7 @@ async function navigateToInternal(
942
875
 
943
876
  try {
944
877
  await prefetchEntry.modulePromise;
945
- if (navigationToken !== state.navigationToken) {
878
+ if (isStaleNavigationToken(state.navigationToken, navigationToken)) {
946
879
  return null;
947
880
  }
948
881
 
@@ -965,7 +898,7 @@ async function navigateToInternal(
965
898
  }
966
899
 
967
900
  const initialChunk = await prefetchEntry.initialPromise;
968
- if (navigationToken !== state.navigationToken) {
901
+ if (isStaleNavigationToken(state.navigationToken, navigationToken)) {
969
902
  return null;
970
903
  }
971
904
 
@@ -973,6 +906,11 @@ async function navigateToInternal(
973
906
  throw new Error("Transition response did not include an initial payload.");
974
907
  }
975
908
 
909
+ if (initialChunk.type === "document") {
910
+ hardNavigate(new URL(initialChunk.location, window.location.origin));
911
+ return null;
912
+ }
913
+
976
914
  if (initialChunk.type === "redirect") {
977
915
  const redirectUrl = new URL(initialChunk.location, window.location.origin);
978
916
  if (!isInternalUrl(redirectUrl)) {
@@ -981,7 +919,7 @@ async function navigateToInternal(
981
919
  }
982
920
 
983
921
  const depth = (options.redirectDepth ?? 0) + 1;
984
- if (depth > 8) {
922
+ if (shouldHardNavigateForRedirectDepth(depth)) {
985
923
  hardNavigate(redirectUrl);
986
924
  return null;
987
925
  }
@@ -991,6 +929,9 @@ async function navigateToInternal(
991
929
  replace: true,
992
930
  redirected: true,
993
931
  redirectDepth: depth,
932
+ // The intercepted navigation has already committed the source URL.
933
+ // The redirected target must update history explicitly.
934
+ historyManagedByNavigationApi: false,
994
935
  });
995
936
  }
996
937
 
@@ -1190,7 +1131,7 @@ export async function prefetchTo(to: string): Promise<void> {
1190
1131
  return;
1191
1132
  }
1192
1133
 
1193
- const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
1134
+ const matched = matchClientPageRoute(state.routerSnapshot.pages, toUrl.pathname);
1194
1135
  const routeId = matched?.route.id ?? null;
1195
1136
  getOrCreatePrefetchEntry(toUrl, routeId, state.routerSnapshot);
1196
1137
  }