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.
- package/README.md +36 -17
- package/framework/cli/commands.ts +52 -30
- package/framework/cli/dev-client-watch.ts +12 -5
- package/framework/cli/internal.ts +7 -3
- package/framework/cli/scaffold.ts +183 -5
- package/framework/runtime/build-tools.ts +3 -1
- package/framework/runtime/client-runtime.tsx +83 -33
- package/framework/runtime/config.ts +7 -2
- package/framework/runtime/index.ts +1 -1
- package/framework/runtime/link.tsx +3 -11
- package/framework/runtime/module-loader.ts +13 -1
- package/framework/runtime/render.tsx +94 -2
- package/framework/runtime/route-api.ts +1 -1
- package/framework/runtime/router.ts +75 -4
- package/framework/runtime/server.ts +8 -0
- package/framework/runtime/tree.tsx +24 -2
- package/framework/runtime/types.ts +1 -1
- package/package.json +13 -8
|
@@ -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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|