react-bun-ssr 0.1.1 → 0.3.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.
@@ -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";
@@ -52,6 +60,7 @@ interface NavigateOptions {
52
60
  interface NavigateResult {
53
61
  from: string;
54
62
  to: string;
63
+ nextUrl: URL;
55
64
  status: number;
56
65
  kind: "page" | "not_found" | "catch" | "error";
57
66
  redirected: boolean;
@@ -61,7 +70,7 @@ interface NavigateResult {
61
70
  interface PrefetchEntry {
62
71
  createdAt: number;
63
72
  modulePromise: Promise<void>;
64
- initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
73
+ initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
65
74
  donePromise: Promise<void>;
66
75
  }
67
76
 
@@ -71,7 +80,7 @@ interface TransitionRequestOptions {
71
80
  }
72
81
 
73
82
  interface TransitionRequestHandle {
74
- initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
83
+ initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null>;
75
84
  donePromise: Promise<void>;
76
85
  }
77
86
 
@@ -120,86 +129,82 @@ interface RuntimeState {
120
129
  transitionAbortController: AbortController | null;
121
130
  }
122
131
 
132
+ interface ClientRuntimeSingleton {
133
+ moduleRegistry: Map<string, RouteModuleBundle>;
134
+ pendingNavigationTransitions: Map<string, PendingNavigationTransition>;
135
+ navigationListeners: Set<(info: NavigateResult) => void>;
136
+ runtimeState: RuntimeState | null;
137
+ popstateBound: boolean;
138
+ navigationApiListenerBound: boolean;
139
+ navigationApiTransitionCounter: number;
140
+ }
141
+
123
142
  declare global {
124
143
  interface Window {
125
144
  __RBSSR_DEFERRED__?: DeferredClientRuntime;
126
145
  }
127
146
  }
128
147
 
129
- const PREFETCH_TTL_MS = 30_000;
130
148
  const NAVIGATION_API_PENDING_TIMEOUT_MS = 1_500;
131
149
  const NAVIGATION_API_PENDING_MATCH_WINDOW_MS = 10_000;
132
150
  const ROUTE_ANNOUNCER_ID = "__rbssr-route-announcer";
133
- const moduleRegistry = new Map<string, RouteModuleBundle>();
134
- const pendingNavigationTransitions = new Map<string, PendingNavigationTransition>();
135
- let runtimeState: RuntimeState | null = null;
136
- let popstateBound = false;
137
- let navigationApiListenerBound = false;
138
- let navigationApiTransitionCounter = 0;
139
-
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));
151
- }
152
-
153
- function matchSegments(segments: ClientRouteSnapshot["segments"], pathname: string): Params | null {
154
- const pathParts = normalizePathname(pathname);
155
- const params: Params = {};
151
+ const CLIENT_RUNTIME_SINGLETON_KEY = Symbol.for("react-bun-ssr.client-runtime");
156
152
 
157
- let i = 0;
158
- let j = 0;
159
-
160
- while (i < segments.length) {
161
- const segment = segments[i]!;
153
+ function getClientRuntimeSingleton(): ClientRuntimeSingleton {
154
+ const globalRegistry = globalThis as typeof globalThis & {
155
+ [CLIENT_RUNTIME_SINGLETON_KEY]?: ClientRuntimeSingleton;
156
+ };
157
+ const existing = globalRegistry[CLIENT_RUNTIME_SINGLETON_KEY];
158
+ if (existing) {
159
+ return existing;
160
+ }
162
161
 
163
- if (segment.kind === "catchall") {
164
- params[segment.value] = pathParts.slice(j).join("/");
165
- return params;
166
- }
162
+ const singleton: ClientRuntimeSingleton = {
163
+ moduleRegistry: new Map(),
164
+ pendingNavigationTransitions: new Map(),
165
+ navigationListeners: new Set(),
166
+ runtimeState: null,
167
+ popstateBound: false,
168
+ navigationApiListenerBound: false,
169
+ navigationApiTransitionCounter: 0,
170
+ };
171
+ globalRegistry[CLIENT_RUNTIME_SINGLETON_KEY] = singleton;
172
+ return singleton;
173
+ }
167
174
 
168
- const current = pathParts[j];
169
- if (current === undefined) {
170
- return null;
171
- }
175
+ const clientRuntimeSingleton = getClientRuntimeSingleton();
172
176
 
173
- if (segment.kind === "static") {
174
- if (segment.value !== current) {
175
- return null;
176
- }
177
- } else {
178
- params[segment.value] = current;
177
+ function emitNavigation(info: NavigateResult): void {
178
+ for (const listener of clientRuntimeSingleton.navigationListeners) {
179
+ try {
180
+ listener(info);
181
+ } catch (error) {
182
+ // eslint-disable-next-line no-console
183
+ console.warn("[rbssr] router navigation listener failed", error);
179
184
  }
180
-
181
- i += 1;
182
- j += 1;
183
- }
184
-
185
- if (j !== pathParts.length) {
186
- return null;
187
185
  }
186
+ }
188
187
 
189
- return params;
188
+ function pickOptionalClientModuleExport<T>(
189
+ moduleValue: Record<string, unknown>,
190
+ exportName: string,
191
+ ): T | undefined {
192
+ const value = moduleValue[exportName];
193
+ return typeof value === "function" ? (value as T) : undefined;
190
194
  }
191
195
 
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;
196
+ export function projectClientModule(
197
+ defaultExport: RouteModule["default"],
198
+ moduleValue: Record<string, unknown>,
199
+ ): RouteModule {
200
+ return {
201
+ default: defaultExport,
202
+ Loading: pickOptionalClientModuleExport<RouteModule["Loading"]>(moduleValue, "Loading"),
203
+ ErrorComponent: pickOptionalClientModuleExport<RouteModule["ErrorComponent"]>(moduleValue, "ErrorComponent"),
204
+ CatchBoundary: pickOptionalClientModuleExport<RouteModule["CatchBoundary"]>(moduleValue, "CatchBoundary"),
205
+ ErrorBoundary: pickOptionalClientModuleExport<RouteModule["ErrorBoundary"]>(moduleValue, "ErrorBoundary"),
206
+ NotFound: pickOptionalClientModuleExport<RouteModule["NotFound"]>(moduleValue, "NotFound"),
207
+ };
203
208
  }
204
209
 
205
210
  function withVersionQuery(url: string, version?: number): string {
@@ -327,18 +332,18 @@ function readNavigationDestinationHref(event: NavigateEventLike): string | null
327
332
  }
328
333
 
329
334
  function clearPendingNavigationTransition(id: string): void {
330
- const entry = pendingNavigationTransitions.get(id);
335
+ const entry = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
331
336
  if (!entry) {
332
337
  return;
333
338
  }
334
339
 
335
340
  clearTimeout(entry.timeoutId);
336
- pendingNavigationTransitions.delete(id);
341
+ clientRuntimeSingleton.pendingNavigationTransitions.delete(id);
337
342
  }
338
343
 
339
344
  function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigationTransition | null {
340
345
  if (isFrameworkNavigationInfo(event.info)) {
341
- return pendingNavigationTransitions.get(event.info.id) ?? null;
346
+ return clientRuntimeSingleton.pendingNavigationTransitions.get(event.info.id) ?? null;
342
347
  }
343
348
 
344
349
  if (event.userInitiated) {
@@ -352,7 +357,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
352
357
 
353
358
  const now = Date.now();
354
359
  let bestMatch: PendingNavigationTransition | null = null;
355
- for (const candidate of pendingNavigationTransitions.values()) {
360
+ for (const candidate of clientRuntimeSingleton.pendingNavigationTransitions.values()) {
356
361
  if (candidate.destinationHref !== destinationHref) {
357
362
  continue;
358
363
  }
@@ -395,20 +400,11 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
395
400
  }
396
401
 
397
402
  function ensureRuntimeState(): RuntimeState {
398
- if (!runtimeState) {
403
+ if (!clientRuntimeSingleton.runtimeState) {
399
404
  throw new Error("Client runtime is not initialized. Ensure hydrateInitialRoute() ran first.");
400
405
  }
401
406
 
402
- return runtimeState;
403
- }
404
-
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
- }
407
+ return clientRuntimeSingleton.runtimeState;
412
408
  }
413
409
 
414
410
  function createTransitionUrl(toUrl: URL): URL {
@@ -417,35 +413,17 @@ function createTransitionUrl(toUrl: URL): URL {
417
413
  return transitionUrl;
418
414
  }
419
415
 
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
416
  function startTransitionRequest(
443
417
  toUrl: URL,
444
418
  options: TransitionRequestOptions = {},
445
419
  ): TransitionRequestHandle {
446
- let resolveInitial: (value: TransitionInitialChunk | TransitionRedirectChunk | null) => void = () => undefined;
420
+ let resolveInitial: (
421
+ value: TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null,
422
+ ) => void = () => undefined;
447
423
  let rejectInitial: (reason?: unknown) => void = () => undefined;
448
- const initialPromise = new Promise<TransitionInitialChunk | TransitionRedirectChunk | null>((resolve, reject) => {
424
+ const initialPromise = new Promise<
425
+ TransitionInitialChunk | TransitionRedirectChunk | TransitionDocumentChunk | null
426
+ >((resolve, reject) => {
449
427
  resolveInitial = resolve;
450
428
  rejectInitial = reject;
451
429
  });
@@ -464,8 +442,7 @@ function startTransitionRequest(
464
442
 
465
443
  const reader = response.body.getReader();
466
444
  const decoder = new TextDecoder();
467
- let initialChunk: TransitionInitialChunk | TransitionRedirectChunk | null = null;
468
- let textBuffer = "";
445
+ let parserState = createTransitionChunkParserState();
469
446
 
470
447
  while (true) {
471
448
  const { done, value } = await reader.read();
@@ -473,38 +450,35 @@ function startTransitionRequest(
473
450
  break;
474
451
  }
475
452
 
476
- textBuffer += decoder.decode(value, { stream: true });
477
- const { lines, rest } = splitLines(textBuffer);
478
- textBuffer = rest;
453
+ const previousInitialChunk = parserState.initialChunk;
454
+ const previousDeferredCount = parserState.deferredChunks.length;
455
+ parserState = consumeTransitionChunkText(
456
+ parserState,
457
+ decoder.decode(value, { stream: true }),
458
+ );
479
459
 
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
- }
460
+ if (!previousInitialChunk && parserState.initialChunk) {
461
+ resolveInitial(parserState.initialChunk);
462
+ }
489
463
 
464
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
490
465
  options.onDeferredChunk?.(chunk);
491
466
  }
492
467
  }
493
468
 
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
- }
469
+ const previousInitialChunk = parserState.initialChunk;
470
+ const previousDeferredCount = parserState.deferredChunks.length;
471
+ parserState = flushTransitionChunkText(parserState);
472
+
473
+ if (!previousInitialChunk && parserState.initialChunk) {
474
+ resolveInitial(parserState.initialChunk);
505
475
  }
506
476
 
507
- if (!initialChunk) {
477
+ for (const chunk of parserState.deferredChunks.slice(previousDeferredCount)) {
478
+ options.onDeferredChunk?.(chunk);
479
+ }
480
+
481
+ if (!parserState.initialChunk) {
508
482
  resolveInitial(null);
509
483
  }
510
484
  })();
@@ -534,7 +508,7 @@ function applyDeferredChunk(chunk: TransitionDeferredChunk): void {
534
508
  }
535
509
 
536
510
  async function ensureRouteModuleLoaded(routeId: string, snapshot: ClientRouterSnapshot): Promise<void> {
537
- if (moduleRegistry.has(routeId)) {
511
+ if (clientRuntimeSingleton.moduleRegistry.has(routeId)) {
538
512
  return;
539
513
  }
540
514
 
@@ -877,6 +851,7 @@ async function renderTransitionInitial(
877
851
  return {
878
852
  from: options.fromPath,
879
853
  to: toUrl.pathname + toUrl.search + toUrl.hash,
854
+ nextUrl: new URL(toUrl.toString()),
880
855
  status: chunk.status,
881
856
  kind: chunk.kind,
882
857
  redirected: options.redirected ?? false,
@@ -917,11 +892,11 @@ async function navigateToInternal(
917
892
  const currentPath = window.location.pathname + window.location.search + window.location.hash;
918
893
  const targetPath = toUrl.pathname + toUrl.search + toUrl.hash;
919
894
 
920
- if (currentPath === targetPath && !options.isPopState && !options.historyManagedByNavigationApi) {
895
+ if (shouldSkipSoftNavigation(currentPath, targetPath, options)) {
921
896
  return null;
922
897
  }
923
898
 
924
- const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
899
+ const matched = matchClientPageRoute(state.routerSnapshot.pages, toUrl.pathname);
925
900
  const routeId = matched?.route.id ?? null;
926
901
 
927
902
  if (state.transitionAbortController) {
@@ -942,7 +917,7 @@ async function navigateToInternal(
942
917
 
943
918
  try {
944
919
  await prefetchEntry.modulePromise;
945
- if (navigationToken !== state.navigationToken) {
920
+ if (isStaleNavigationToken(state.navigationToken, navigationToken)) {
946
921
  return null;
947
922
  }
948
923
 
@@ -965,7 +940,7 @@ async function navigateToInternal(
965
940
  }
966
941
 
967
942
  const initialChunk = await prefetchEntry.initialPromise;
968
- if (navigationToken !== state.navigationToken) {
943
+ if (isStaleNavigationToken(state.navigationToken, navigationToken)) {
969
944
  return null;
970
945
  }
971
946
 
@@ -973,6 +948,11 @@ async function navigateToInternal(
973
948
  throw new Error("Transition response did not include an initial payload.");
974
949
  }
975
950
 
951
+ if (initialChunk.type === "document") {
952
+ hardNavigate(new URL(initialChunk.location, window.location.origin));
953
+ return null;
954
+ }
955
+
976
956
  if (initialChunk.type === "redirect") {
977
957
  const redirectUrl = new URL(initialChunk.location, window.location.origin);
978
958
  if (!isInternalUrl(redirectUrl)) {
@@ -981,7 +961,7 @@ async function navigateToInternal(
981
961
  }
982
962
 
983
963
  const depth = (options.redirectDepth ?? 0) + 1;
984
- if (depth > 8) {
964
+ if (shouldHardNavigateForRedirectDepth(depth)) {
985
965
  hardNavigate(redirectUrl);
986
966
  return null;
987
967
  }
@@ -991,6 +971,9 @@ async function navigateToInternal(
991
971
  replace: true,
992
972
  redirected: true,
993
973
  redirectDepth: depth,
974
+ // The intercepted navigation has already committed the source URL.
975
+ // The redirected target must update history explicitly.
976
+ historyManagedByNavigationApi: false,
994
977
  });
995
978
  }
996
979
 
@@ -1000,6 +983,7 @@ async function navigateToInternal(
1000
983
  fromPath: currentPath,
1001
984
  });
1002
985
  options.onNavigate?.(result);
986
+ emitNavigation(result);
1003
987
  return result;
1004
988
  } catch {
1005
989
  hardNavigate(toUrl);
@@ -1012,8 +996,8 @@ async function navigateToInternal(
1012
996
  }
1013
997
 
1014
998
  function nextNavigationTransitionId(): string {
1015
- navigationApiTransitionCounter += 1;
1016
- return `rbssr-nav-${Date.now()}-${navigationApiTransitionCounter}`;
999
+ clientRuntimeSingleton.navigationApiTransitionCounter += 1;
1000
+ return `rbssr-nav-${Date.now()}-${clientRuntimeSingleton.navigationApiTransitionCounter}`;
1017
1001
  }
1018
1002
 
1019
1003
  function settlePendingNavigationTransition(
@@ -1030,7 +1014,7 @@ function settlePendingNavigationTransition(
1030
1014
  }
1031
1015
 
1032
1016
  function cancelPendingNavigationTransition(id: string): void {
1033
- const pending = pendingNavigationTransitions.get(id);
1017
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
1034
1018
  if (!pending || pending.settled) {
1035
1019
  return;
1036
1020
  }
@@ -1066,7 +1050,7 @@ function createPendingNavigationTransition(options: {
1066
1050
  }): Promise<NavigateResult | null> {
1067
1051
  return new Promise(resolve => {
1068
1052
  const timeoutId = window.setTimeout(() => {
1069
- const pending = pendingNavigationTransitions.get(options.id);
1053
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(options.id);
1070
1054
  if (!pending || pending.settled) {
1071
1055
  return;
1072
1056
  }
@@ -1082,7 +1066,7 @@ function createPendingNavigationTransition(options: {
1082
1066
  });
1083
1067
  }, NAVIGATION_API_PENDING_TIMEOUT_MS);
1084
1068
 
1085
- pendingNavigationTransitions.set(options.id, {
1069
+ clientRuntimeSingleton.pendingNavigationTransitions.set(options.id, {
1086
1070
  id: options.id,
1087
1071
  destinationHref: options.toUrl.toString(),
1088
1072
  replace: options.replace,
@@ -1097,7 +1081,7 @@ function createPendingNavigationTransition(options: {
1097
1081
  }
1098
1082
 
1099
1083
  function bindNavigationApiNavigateListener(): void {
1100
- if (navigationApiListenerBound || typeof window === "undefined") {
1084
+ if (clientRuntimeSingleton.navigationApiListenerBound || typeof window === "undefined") {
1101
1085
  return;
1102
1086
  }
1103
1087
 
@@ -1157,15 +1141,15 @@ function bindNavigationApiNavigateListener(): void {
1157
1141
  return;
1158
1142
  }
1159
1143
 
1160
- navigationApiListenerBound = true;
1144
+ clientRuntimeSingleton.navigationApiListenerBound = true;
1161
1145
  }
1162
1146
 
1163
1147
  function bindPopstate(): void {
1164
- if (popstateBound || typeof window === "undefined") {
1148
+ if (clientRuntimeSingleton.popstateBound || typeof window === "undefined") {
1165
1149
  return;
1166
1150
  }
1167
1151
 
1168
- popstateBound = true;
1152
+ clientRuntimeSingleton.popstateBound = true;
1169
1153
  window.addEventListener("popstate", () => {
1170
1154
  const targetUrl = new URL(window.location.href);
1171
1155
  void navigateToInternal(targetUrl, {
@@ -1181,16 +1165,16 @@ export async function prefetchTo(to: string): Promise<void> {
1181
1165
  return;
1182
1166
  }
1183
1167
 
1184
- if (!runtimeState) {
1168
+ if (!clientRuntimeSingleton.runtimeState) {
1185
1169
  return;
1186
1170
  }
1187
- const state = runtimeState;
1171
+ const state = clientRuntimeSingleton.runtimeState;
1188
1172
  const toUrl = new URL(to, window.location.href);
1189
1173
  if (!isInternalUrl(toUrl)) {
1190
1174
  return;
1191
1175
  }
1192
1176
 
1193
- const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
1177
+ const matched = matchClientPageRoute(state.routerSnapshot.pages, toUrl.pathname);
1194
1178
  const routeId = matched?.route.id ?? null;
1195
1179
  getOrCreatePrefetchEntry(toUrl, routeId, state.routerSnapshot);
1196
1180
  }
@@ -1209,7 +1193,7 @@ export async function navigateWithNavigationApiOrFallback(
1209
1193
  return null;
1210
1194
  }
1211
1195
 
1212
- if (!runtimeState) {
1196
+ if (!clientRuntimeSingleton.runtimeState) {
1213
1197
  hardNavigate(toUrl);
1214
1198
  return null;
1215
1199
  }
@@ -1264,7 +1248,7 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1264
1248
  return null;
1265
1249
  }
1266
1250
 
1267
- if (!runtimeState) {
1251
+ if (!clientRuntimeSingleton.runtimeState) {
1268
1252
  hardNavigate(toUrl);
1269
1253
  return null;
1270
1254
  }
@@ -1272,21 +1256,28 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1272
1256
  return navigateToInternal(toUrl, options);
1273
1257
  }
1274
1258
 
1259
+ export function subscribeToNavigation(listener: (info: NavigateResult) => void): () => void {
1260
+ clientRuntimeSingleton.navigationListeners.add(listener);
1261
+ return () => {
1262
+ clientRuntimeSingleton.navigationListeners.delete(listener);
1263
+ };
1264
+ }
1265
+
1275
1266
  export function registerRouteModules(routeId: string, modules: RouteModuleBundle): void {
1276
- moduleRegistry.set(routeId, modules);
1277
- if (runtimeState) {
1278
- runtimeState.moduleRegistry.set(routeId, modules);
1267
+ clientRuntimeSingleton.moduleRegistry.set(routeId, modules);
1268
+ if (clientRuntimeSingleton.runtimeState) {
1269
+ clientRuntimeSingleton.runtimeState.moduleRegistry.set(routeId, modules);
1279
1270
  }
1280
1271
  }
1281
1272
 
1282
1273
  export function hydrateInitialRoute(routeId: string): void {
1283
- if (typeof document === "undefined" || runtimeState) {
1274
+ if (typeof document === "undefined" || clientRuntimeSingleton.runtimeState) {
1284
1275
  return;
1285
1276
  }
1286
1277
 
1287
1278
  const payload = reviveDeferredPayload(getScriptJson<RenderPayload>(RBSSR_PAYLOAD_SCRIPT_ID));
1288
1279
  const routerSnapshot = getScriptJson<ClientRouterSnapshot>(RBSSR_ROUTER_SCRIPT_ID);
1289
- const modules = moduleRegistry.get(routeId);
1280
+ const modules = clientRuntimeSingleton.moduleRegistry.get(routeId);
1290
1281
  if (!modules) {
1291
1282
  throw new Error(`Missing module registry for initial route "${routeId}"`);
1292
1283
  }
@@ -1306,13 +1297,13 @@ export function hydrateInitialRoute(routeId: string): void {
1306
1297
  }
1307
1298
 
1308
1299
  const root = hydrateRoot(container, appTree);
1309
- runtimeState = {
1300
+ clientRuntimeSingleton.runtimeState = {
1310
1301
  root,
1311
1302
  currentPayload: payload,
1312
1303
  currentRouteId: routeId,
1313
1304
  currentModules: modules,
1314
1305
  routerSnapshot,
1315
- moduleRegistry,
1306
+ moduleRegistry: clientRuntimeSingleton.moduleRegistry,
1316
1307
  prefetchCache: new Map(),
1317
1308
  navigationToken: 0,
1318
1309
  transitionAbortController: null,