react-bun-ssr 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,6 +60,7 @@ interface NavigateOptions {
60
60
  interface NavigateResult {
61
61
  from: string;
62
62
  to: string;
63
+ nextUrl: URL;
63
64
  status: number;
64
65
  kind: "page" | "not_found" | "catch" | "error";
65
66
  redirected: boolean;
@@ -128,6 +129,16 @@ interface RuntimeState {
128
129
  transitionAbortController: AbortController | null;
129
130
  }
130
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
+
131
142
  declare global {
132
143
  interface Window {
133
144
  __RBSSR_DEFERRED__?: DeferredClientRuntime;
@@ -137,12 +148,42 @@ declare global {
137
148
  const NAVIGATION_API_PENDING_TIMEOUT_MS = 1_500;
138
149
  const NAVIGATION_API_PENDING_MATCH_WINDOW_MS = 10_000;
139
150
  const ROUTE_ANNOUNCER_ID = "__rbssr-route-announcer";
140
- const moduleRegistry = new Map<string, RouteModuleBundle>();
141
- const pendingNavigationTransitions = new Map<string, PendingNavigationTransition>();
142
- let runtimeState: RuntimeState | null = null;
143
- let popstateBound = false;
144
- let navigationApiListenerBound = false;
145
- let navigationApiTransitionCounter = 0;
151
+ const CLIENT_RUNTIME_SINGLETON_KEY = Symbol.for("react-bun-ssr.client-runtime");
152
+
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
+ }
161
+
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
+ }
174
+
175
+ const clientRuntimeSingleton = getClientRuntimeSingleton();
176
+
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);
184
+ }
185
+ }
186
+ }
146
187
 
147
188
  function pickOptionalClientModuleExport<T>(
148
189
  moduleValue: Record<string, unknown>,
@@ -291,18 +332,18 @@ function readNavigationDestinationHref(event: NavigateEventLike): string | null
291
332
  }
292
333
 
293
334
  function clearPendingNavigationTransition(id: string): void {
294
- const entry = pendingNavigationTransitions.get(id);
335
+ const entry = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
295
336
  if (!entry) {
296
337
  return;
297
338
  }
298
339
 
299
340
  clearTimeout(entry.timeoutId);
300
- pendingNavigationTransitions.delete(id);
341
+ clientRuntimeSingleton.pendingNavigationTransitions.delete(id);
301
342
  }
302
343
 
303
344
  function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigationTransition | null {
304
345
  if (isFrameworkNavigationInfo(event.info)) {
305
- return pendingNavigationTransitions.get(event.info.id) ?? null;
346
+ return clientRuntimeSingleton.pendingNavigationTransitions.get(event.info.id) ?? null;
306
347
  }
307
348
 
308
349
  if (event.userInitiated) {
@@ -316,7 +357,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
316
357
 
317
358
  const now = Date.now();
318
359
  let bestMatch: PendingNavigationTransition | null = null;
319
- for (const candidate of pendingNavigationTransitions.values()) {
360
+ for (const candidate of clientRuntimeSingleton.pendingNavigationTransitions.values()) {
320
361
  if (candidate.destinationHref !== destinationHref) {
321
362
  continue;
322
363
  }
@@ -359,11 +400,11 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
359
400
  }
360
401
 
361
402
  function ensureRuntimeState(): RuntimeState {
362
- if (!runtimeState) {
403
+ if (!clientRuntimeSingleton.runtimeState) {
363
404
  throw new Error("Client runtime is not initialized. Ensure hydrateInitialRoute() ran first.");
364
405
  }
365
406
 
366
- return runtimeState;
407
+ return clientRuntimeSingleton.runtimeState;
367
408
  }
368
409
 
369
410
  function createTransitionUrl(toUrl: URL): URL {
@@ -467,7 +508,7 @@ function applyDeferredChunk(chunk: TransitionDeferredChunk): void {
467
508
  }
468
509
 
469
510
  async function ensureRouteModuleLoaded(routeId: string, snapshot: ClientRouterSnapshot): Promise<void> {
470
- if (moduleRegistry.has(routeId)) {
511
+ if (clientRuntimeSingleton.moduleRegistry.has(routeId)) {
471
512
  return;
472
513
  }
473
514
 
@@ -810,6 +851,7 @@ async function renderTransitionInitial(
810
851
  return {
811
852
  from: options.fromPath,
812
853
  to: toUrl.pathname + toUrl.search + toUrl.hash,
854
+ nextUrl: new URL(toUrl.toString()),
813
855
  status: chunk.status,
814
856
  kind: chunk.kind,
815
857
  redirected: options.redirected ?? false,
@@ -941,6 +983,7 @@ async function navigateToInternal(
941
983
  fromPath: currentPath,
942
984
  });
943
985
  options.onNavigate?.(result);
986
+ emitNavigation(result);
944
987
  return result;
945
988
  } catch {
946
989
  hardNavigate(toUrl);
@@ -953,8 +996,8 @@ async function navigateToInternal(
953
996
  }
954
997
 
955
998
  function nextNavigationTransitionId(): string {
956
- navigationApiTransitionCounter += 1;
957
- return `rbssr-nav-${Date.now()}-${navigationApiTransitionCounter}`;
999
+ clientRuntimeSingleton.navigationApiTransitionCounter += 1;
1000
+ return `rbssr-nav-${Date.now()}-${clientRuntimeSingleton.navigationApiTransitionCounter}`;
958
1001
  }
959
1002
 
960
1003
  function settlePendingNavigationTransition(
@@ -971,7 +1014,7 @@ function settlePendingNavigationTransition(
971
1014
  }
972
1015
 
973
1016
  function cancelPendingNavigationTransition(id: string): void {
974
- const pending = pendingNavigationTransitions.get(id);
1017
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(id);
975
1018
  if (!pending || pending.settled) {
976
1019
  return;
977
1020
  }
@@ -1007,7 +1050,7 @@ function createPendingNavigationTransition(options: {
1007
1050
  }): Promise<NavigateResult | null> {
1008
1051
  return new Promise(resolve => {
1009
1052
  const timeoutId = window.setTimeout(() => {
1010
- const pending = pendingNavigationTransitions.get(options.id);
1053
+ const pending = clientRuntimeSingleton.pendingNavigationTransitions.get(options.id);
1011
1054
  if (!pending || pending.settled) {
1012
1055
  return;
1013
1056
  }
@@ -1023,7 +1066,7 @@ function createPendingNavigationTransition(options: {
1023
1066
  });
1024
1067
  }, NAVIGATION_API_PENDING_TIMEOUT_MS);
1025
1068
 
1026
- pendingNavigationTransitions.set(options.id, {
1069
+ clientRuntimeSingleton.pendingNavigationTransitions.set(options.id, {
1027
1070
  id: options.id,
1028
1071
  destinationHref: options.toUrl.toString(),
1029
1072
  replace: options.replace,
@@ -1038,7 +1081,7 @@ function createPendingNavigationTransition(options: {
1038
1081
  }
1039
1082
 
1040
1083
  function bindNavigationApiNavigateListener(): void {
1041
- if (navigationApiListenerBound || typeof window === "undefined") {
1084
+ if (clientRuntimeSingleton.navigationApiListenerBound || typeof window === "undefined") {
1042
1085
  return;
1043
1086
  }
1044
1087
 
@@ -1098,15 +1141,15 @@ function bindNavigationApiNavigateListener(): void {
1098
1141
  return;
1099
1142
  }
1100
1143
 
1101
- navigationApiListenerBound = true;
1144
+ clientRuntimeSingleton.navigationApiListenerBound = true;
1102
1145
  }
1103
1146
 
1104
1147
  function bindPopstate(): void {
1105
- if (popstateBound || typeof window === "undefined") {
1148
+ if (clientRuntimeSingleton.popstateBound || typeof window === "undefined") {
1106
1149
  return;
1107
1150
  }
1108
1151
 
1109
- popstateBound = true;
1152
+ clientRuntimeSingleton.popstateBound = true;
1110
1153
  window.addEventListener("popstate", () => {
1111
1154
  const targetUrl = new URL(window.location.href);
1112
1155
  void navigateToInternal(targetUrl, {
@@ -1122,10 +1165,10 @@ export async function prefetchTo(to: string): Promise<void> {
1122
1165
  return;
1123
1166
  }
1124
1167
 
1125
- if (!runtimeState) {
1168
+ if (!clientRuntimeSingleton.runtimeState) {
1126
1169
  return;
1127
1170
  }
1128
- const state = runtimeState;
1171
+ const state = clientRuntimeSingleton.runtimeState;
1129
1172
  const toUrl = new URL(to, window.location.href);
1130
1173
  if (!isInternalUrl(toUrl)) {
1131
1174
  return;
@@ -1150,7 +1193,7 @@ export async function navigateWithNavigationApiOrFallback(
1150
1193
  return null;
1151
1194
  }
1152
1195
 
1153
- if (!runtimeState) {
1196
+ if (!clientRuntimeSingleton.runtimeState) {
1154
1197
  hardNavigate(toUrl);
1155
1198
  return null;
1156
1199
  }
@@ -1205,7 +1248,7 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1205
1248
  return null;
1206
1249
  }
1207
1250
 
1208
- if (!runtimeState) {
1251
+ if (!clientRuntimeSingleton.runtimeState) {
1209
1252
  hardNavigate(toUrl);
1210
1253
  return null;
1211
1254
  }
@@ -1213,21 +1256,28 @@ export async function navigateTo(to: string, options: NavigateOptions = {}): Pro
1213
1256
  return navigateToInternal(toUrl, options);
1214
1257
  }
1215
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
+
1216
1266
  export function registerRouteModules(routeId: string, modules: RouteModuleBundle): void {
1217
- moduleRegistry.set(routeId, modules);
1218
- if (runtimeState) {
1219
- runtimeState.moduleRegistry.set(routeId, modules);
1267
+ clientRuntimeSingleton.moduleRegistry.set(routeId, modules);
1268
+ if (clientRuntimeSingleton.runtimeState) {
1269
+ clientRuntimeSingleton.runtimeState.moduleRegistry.set(routeId, modules);
1220
1270
  }
1221
1271
  }
1222
1272
 
1223
1273
  export function hydrateInitialRoute(routeId: string): void {
1224
- if (typeof document === "undefined" || runtimeState) {
1274
+ if (typeof document === "undefined" || clientRuntimeSingleton.runtimeState) {
1225
1275
  return;
1226
1276
  }
1227
1277
 
1228
1278
  const payload = reviveDeferredPayload(getScriptJson<RenderPayload>(RBSSR_PAYLOAD_SCRIPT_ID));
1229
1279
  const routerSnapshot = getScriptJson<ClientRouterSnapshot>(RBSSR_ROUTER_SCRIPT_ID);
1230
- const modules = moduleRegistry.get(routeId);
1280
+ const modules = clientRuntimeSingleton.moduleRegistry.get(routeId);
1231
1281
  if (!modules) {
1232
1282
  throw new Error(`Missing module registry for initial route "${routeId}"`);
1233
1283
  }
@@ -1247,13 +1297,13 @@ export function hydrateInitialRoute(routeId: string): void {
1247
1297
  }
1248
1298
 
1249
1299
  const root = hydrateRoot(container, appTree);
1250
- runtimeState = {
1300
+ clientRuntimeSingleton.runtimeState = {
1251
1301
  root,
1252
1302
  currentPayload: payload,
1253
1303
  currentRouteId: routeId,
1254
1304
  currentModules: modules,
1255
1305
  routerSnapshot,
1256
- moduleRegistry,
1306
+ moduleRegistry: clientRuntimeSingleton.moduleRegistry,
1257
1307
  prefetchCache: new Map(),
1258
1308
  navigationToken: 0,
1259
1309
  transitionAbortController: null,
@@ -71,15 +71,20 @@ function toHeaderRules(config: FrameworkConfig): ResolvedResponseHeaderRule[] {
71
71
  throw new Error(`[rbssr config] \`headers[${index}].headers\` must include at least one header.`);
72
72
  }
73
73
 
74
- const headers: Record<string, string> = {};
74
+ const headers: Record<string, string | null> = {};
75
75
  for (const [key, value] of entries) {
76
76
  if (typeof key !== "string" || key.trim().length === 0) {
77
77
  throw new Error(`[rbssr config] \`headers[${index}].headers\` contains an empty header name.`);
78
78
  }
79
79
 
80
+ if (value === null) {
81
+ headers[key] = null;
82
+ continue;
83
+ }
84
+
80
85
  if (typeof value !== "string" || value.trim().length === 0) {
81
86
  throw new Error(
82
- `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value.`,
87
+ `[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value or null.`,
83
88
  );
84
89
  }
85
90
 
@@ -26,5 +26,5 @@ export { createServer, startHttpServer } from "./server";
26
26
  export { defer, json, redirect, defineConfig } from "./helpers";
27
27
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
28
28
  export { Link, type LinkProps } from "./link";
29
- export { useRouter, type Router, type RouterNavigateOptions } from "./router";
29
+ export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
30
30
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -4,15 +4,7 @@ import type {
4
4
  MouseEvent,
5
5
  TouchEvent,
6
6
  } from "react";
7
-
8
- interface NavigateInfo {
9
- from: string;
10
- to: string;
11
- status: number;
12
- kind: "page" | "not_found" | "catch" | "error";
13
- redirected: boolean;
14
- prefetched: boolean;
15
- }
7
+ import type { RouterNavigateInfo } from "./router";
16
8
 
17
9
  export interface LinkProps
18
10
  extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
@@ -20,7 +12,7 @@ export interface LinkProps
20
12
  replace?: boolean;
21
13
  scroll?: boolean;
22
14
  prefetch?: "intent" | "none";
23
- onNavigate?: (info: NavigateInfo) => void;
15
+ onNavigate?: (info: RouterNavigateInfo) => void;
24
16
  }
25
17
 
26
18
  function shouldHandleNavigation(event: MouseEvent<HTMLAnchorElement>): boolean {
@@ -89,7 +81,7 @@ async function prefetch(href: string): Promise<void> {
89
81
  async function navigate(href: string, options: {
90
82
  replace?: boolean;
91
83
  scroll?: boolean;
92
- onNavigate?: (info: NavigateInfo) => void;
84
+ onNavigate?: (info: RouterNavigateInfo) => void;
93
85
  }): Promise<void> {
94
86
  if (typeof window === "undefined") {
95
87
  return;
@@ -29,21 +29,26 @@ export interface RouteModuleLoadOptions {
29
29
  cacheBustKey?: string;
30
30
  serverBytecode?: boolean;
31
31
  devSourceImports?: boolean;
32
+ nodeEnv?: "development" | "production";
32
33
  }
33
34
 
34
35
  export function createServerModuleCacheKey(options: {
35
36
  absoluteFilePath: string;
36
37
  cacheBustKey?: string;
37
38
  serverBytecode: boolean;
39
+ nodeEnv?: "development" | "production";
38
40
  }): string {
39
- return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|bun:${Bun.version}`;
41
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
42
+ return `${options.absoluteFilePath}|${options.cacheBustKey ?? 'prod'}|bytecode:${options.serverBytecode ? '1' : '0'}|env:${nodeEnv}|bun:${Bun.version}`;
40
43
  }
41
44
 
42
45
  export function createServerBuildConfig(options: {
43
46
  absoluteFilePath: string;
44
47
  outDir: string;
45
48
  serverBytecode: boolean;
49
+ nodeEnv?: "development" | "production";
46
50
  }): Bun.BuildConfig {
51
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
47
52
  return {
48
53
  entrypoints: [options.absoluteFilePath],
49
54
  outdir: options.outDir,
@@ -56,6 +61,9 @@ export function createServerBuildConfig(options: {
56
61
  minify: false,
57
62
  naming: 'entry-[hash].[ext]',
58
63
  external: SERVER_BUILD_EXTERNAL,
64
+ define: {
65
+ "process.env.NODE_ENV": JSON.stringify(nodeEnv),
66
+ },
59
67
  };
60
68
  }
61
69
 
@@ -128,10 +136,12 @@ async function buildServerModule(
128
136
 
129
137
  const cacheBustKey = options.cacheBustKey;
130
138
  const serverBytecode = options.serverBytecode ?? true;
139
+ const nodeEnv = options.nodeEnv ?? (process.env.NODE_ENV === "production" ? "production" : "development");
131
140
  const cacheKey = createServerModuleCacheKey({
132
141
  absoluteFilePath,
133
142
  cacheBustKey,
134
143
  serverBytecode,
144
+ nodeEnv,
135
145
  });
136
146
  const existing = serverBundlePathCache.get(cacheKey);
137
147
  if (existing) {
@@ -153,6 +163,7 @@ async function buildServerModule(
153
163
  absoluteFilePath,
154
164
  outDir,
155
165
  serverBytecode,
166
+ nodeEnv,
156
167
  }),
157
168
  );
158
169
 
@@ -169,6 +180,7 @@ async function buildServerModule(
169
180
  absoluteFilePath,
170
181
  outDir,
171
182
  serverBytecode: false,
183
+ nodeEnv,
172
184
  }),
173
185
  );
174
186
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Children,
3
3
  cloneElement,
4
+ Fragment,
4
5
  isValidElement,
5
6
  Suspense,
6
7
  use,
@@ -29,6 +30,50 @@ import {
29
30
  createPageAppTree,
30
31
  } from "./tree";
31
32
 
33
+ function isTitleElement(node: ReactNode): node is ReactElement {
34
+ return isValidElement(node) && node.type === "title";
35
+ }
36
+
37
+ function isMetaElement(node: ReactNode): node is ReactElement {
38
+ return isValidElement(node) && node.type === "meta";
39
+ }
40
+
41
+ function getMetaDedupeKey(node: ReactNode): string | null {
42
+ if (!isMetaElement(node)) {
43
+ return null;
44
+ }
45
+
46
+ const props = node.props as {
47
+ name?: string;
48
+ property?: string;
49
+ httpEquiv?: string;
50
+ charSet?: string;
51
+ itemProp?: string;
52
+ };
53
+
54
+ if (typeof props.name === "string" && props.name.length > 0) {
55
+ return `name:${props.name}`;
56
+ }
57
+
58
+ if (typeof props.property === "string" && props.property.length > 0) {
59
+ return `property:${props.property}`;
60
+ }
61
+
62
+ if (typeof props.httpEquiv === "string" && props.httpEquiv.length > 0) {
63
+ return `httpEquiv:${props.httpEquiv}`;
64
+ }
65
+
66
+ if (typeof props.itemProp === "string" && props.itemProp.length > 0) {
67
+ return `itemProp:${props.itemProp}`;
68
+ }
69
+
70
+ if (props.charSet !== undefined) {
71
+ return "charSet";
72
+ }
73
+
74
+ return null;
75
+ }
76
+
32
77
  export function renderPageApp(modules: RouteModuleBundle, payload: RenderPayload): string {
33
78
  return renderToString(createPageAppTree(modules, payload));
34
79
  }
@@ -84,6 +129,27 @@ function normalizeTitleChildren(node: ReactNode): ReactNode {
84
129
  return cloneElement(node, undefined, nextChildren);
85
130
  }
86
131
 
132
+ function expandHeadNodes(node: ReactNode): ReactNode[] {
133
+ if (Array.isArray(node)) {
134
+ return node.flatMap(value => expandHeadNodes(value));
135
+ }
136
+
137
+ if (node === null || node === undefined || typeof node === "boolean") {
138
+ return [];
139
+ }
140
+
141
+ if (!isValidElement(node)) {
142
+ return [node];
143
+ }
144
+
145
+ if (node.type === Fragment) {
146
+ const props = node.props as { children?: ReactNode };
147
+ return Children.toArray(props.children).flatMap(child => expandHeadNodes(child));
148
+ }
149
+
150
+ return [node];
151
+ }
152
+
87
153
  function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload, keyPrefix: string): ReactNode[] {
88
154
  const tags: ReactNode[] = [];
89
155
 
@@ -99,7 +165,7 @@ function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload,
99
165
  if (typeof headResult === "string") {
100
166
  tags.push(<title key={`${keyPrefix}:title`}>{headResult}</title>);
101
167
  } else if (headResult !== null && headResult !== undefined) {
102
- const nodes = Children.toArray(normalizeTitleChildren(headResult));
168
+ const nodes = expandHeadNodes(normalizeTitleChildren(headResult));
103
169
  for (let index = 0; index < nodes.length; index += 1) {
104
170
  const node = nodes[index]!;
105
171
  if (isValidElement(node)) {
@@ -122,11 +188,37 @@ function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload,
122
188
  }
123
189
 
124
190
  export function collectHeadElements(modules: RouteModuleBundle, payload: RenderPayload): ReactNode[] {
125
- return [
191
+ const elements = [
126
192
  ...moduleHeadToElements(modules.root, payload, "root"),
127
193
  ...modules.layouts.flatMap((layout, index) => moduleHeadToElements(layout, payload, `layout:${index}`)),
128
194
  ...moduleHeadToElements(modules.route, payload, "route"),
129
195
  ];
196
+
197
+ let lastTitleIndex = -1;
198
+ const lastMetaIndexes = new Map<string, number>();
199
+ for (let index = 0; index < elements.length; index += 1) {
200
+ if (isTitleElement(elements[index])) {
201
+ lastTitleIndex = index;
202
+ }
203
+
204
+ const metaKey = getMetaDedupeKey(elements[index]);
205
+ if (metaKey) {
206
+ lastMetaIndexes.set(metaKey, index);
207
+ }
208
+ }
209
+
210
+ return elements.filter((element, index) => {
211
+ if (isTitleElement(element)) {
212
+ return lastTitleIndex === -1 || index === lastTitleIndex;
213
+ }
214
+
215
+ const metaKey = getMetaDedupeKey(element);
216
+ if (metaKey) {
217
+ return lastMetaIndexes.get(metaKey) === index;
218
+ }
219
+
220
+ return true;
221
+ });
130
222
  }
131
223
 
132
224
  export function collectHeadMarkup(modules: RouteModuleBundle, payload: RenderPayload): string {
@@ -19,5 +19,5 @@ export type {
19
19
  export { defer, json, redirect } from "./helpers";
20
20
  export { isRouteErrorResponse, notFound, routeError } from "./route-errors";
21
21
  export { Link, type LinkProps } from "./link";
22
- export { useRouter, type Router, type RouterNavigateOptions } from "./router";
22
+ export { useRouter, type Router, type RouterNavigateInfo, type RouterNavigateListener, type RouterNavigateOptions } from "./router";
23
23
  export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
@@ -1,10 +1,36 @@
1
- import { useMemo } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { goBack, goForward, reloadPage } from "./navigation-api";
3
3
 
4
4
  export interface RouterNavigateOptions {
5
5
  scroll?: boolean;
6
6
  }
7
7
 
8
+ export interface RouterNavigateInfo {
9
+ from: string;
10
+ to: string;
11
+ nextUrl: URL;
12
+ status: number;
13
+ kind: "page" | "not_found" | "catch" | "error";
14
+ redirected: boolean;
15
+ prefetched: boolean;
16
+ }
17
+
18
+ export type RouterNavigateListener = (nextUrl: URL) => void;
19
+
20
+ export function notifyRouterNavigateListeners(
21
+ listeners: readonly RouterNavigateListener[],
22
+ nextUrl: URL,
23
+ ): void {
24
+ for (const listener of listeners) {
25
+ try {
26
+ listener(nextUrl);
27
+ } catch (error) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn("[rbssr] router onNavigate listener failed", error);
30
+ }
31
+ }
32
+ }
33
+
8
34
  export interface Router {
9
35
  push(href: string, options?: RouterNavigateOptions): void;
10
36
  replace(href: string, options?: RouterNavigateOptions): void;
@@ -12,6 +38,7 @@ export interface Router {
12
38
  back(): void;
13
39
  forward(): void;
14
40
  refresh(): void;
41
+ onNavigate(listener: RouterNavigateListener): void;
15
42
  }
16
43
 
17
44
  function toAbsoluteHref(href: string): string {
@@ -28,9 +55,10 @@ const SERVER_ROUTER: Router = {
28
55
  back: () => undefined,
29
56
  forward: () => undefined,
30
57
  refresh: () => undefined,
58
+ onNavigate: () => undefined,
31
59
  };
32
60
 
33
- function createClientRouter(): Router {
61
+ function createClientRouter(onNavigate: Router["onNavigate"]): Router {
34
62
  return {
35
63
  push: (href, options) => {
36
64
  const absoluteHref = toAbsoluteHref(href);
@@ -69,12 +97,55 @@ function createClientRouter(): Router {
69
97
  refresh: () => {
70
98
  reloadPage();
71
99
  },
100
+ onNavigate,
72
101
  };
73
102
  }
74
103
 
75
104
  export function useRouter(): Router {
105
+ const navigateListenersRef = useRef<RouterNavigateListener[]>([]);
106
+ const didEmitInitialNavigationRef = useRef(false);
107
+ navigateListenersRef.current = [];
108
+
109
+ const onNavigate = useCallback<Router["onNavigate"]>((listener) => {
110
+ navigateListenersRef.current.push(listener);
111
+ }, []);
112
+
113
+ useEffect(() => {
114
+ if (typeof window === "undefined") {
115
+ return;
116
+ }
117
+
118
+ if (!didEmitInitialNavigationRef.current) {
119
+ didEmitInitialNavigationRef.current = true;
120
+ notifyRouterNavigateListeners(
121
+ navigateListenersRef.current,
122
+ new URL(window.location.href),
123
+ );
124
+ }
125
+
126
+ let unsubscribe: () => void = () => undefined;
127
+ let active = true;
128
+
129
+ void import("./client-runtime")
130
+ .then(runtime => {
131
+ if (!active) {
132
+ return;
133
+ }
134
+
135
+ unsubscribe = runtime.subscribeToNavigation((info) => {
136
+ notifyRouterNavigateListeners(navigateListenersRef.current, info.nextUrl);
137
+ });
138
+ })
139
+ .catch(() => undefined);
140
+
141
+ return () => {
142
+ active = false;
143
+ unsubscribe();
144
+ };
145
+ }, []);
146
+
76
147
  return useMemo(
77
- () => (typeof window === "undefined" ? SERVER_ROUTER : createClientRouter()),
78
- [],
148
+ () => (typeof window === "undefined" ? SERVER_ROUTER : createClientRouter(onNavigate)),
149
+ [onNavigate],
79
150
  );
80
151
  }