react-bun-ssr 0.1.0 → 0.1.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 +116 -132
- package/framework/runtime/build-tools.ts +152 -48
- package/framework/runtime/client-runtime.tsx +1277 -15
- package/framework/runtime/config.ts +4 -1
- package/framework/runtime/index.ts +6 -0
- package/framework/runtime/io.ts +1 -1
- package/framework/runtime/link.tsx +205 -0
- package/framework/runtime/markdown-headings.ts +54 -0
- package/framework/runtime/markdown-routes.ts +8 -26
- package/framework/runtime/module-loader.ts +172 -47
- package/framework/runtime/navigation-api.ts +223 -0
- package/framework/runtime/render.tsx +56 -92
- package/framework/runtime/route-api.ts +6 -0
- package/framework/runtime/route-errors.ts +166 -0
- package/framework/runtime/router.ts +80 -0
- package/framework/runtime/runtime-constants.ts +4 -0
- package/framework/runtime/server.ts +696 -71
- package/framework/runtime/tree.tsx +171 -3
- package/framework/runtime/types.ts +70 -3
- package/framework/runtime/utils.ts +6 -5
- package/package.json +18 -5
|
@@ -1,10 +1,123 @@
|
|
|
1
|
-
import { hydrateRoot } from "react-dom/client";
|
|
1
|
+
import { hydrateRoot, type Root } from "react-dom/client";
|
|
2
2
|
import { isDeferredToken } from "./deferred";
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
addNavigationNavigateListener,
|
|
5
|
+
canNavigationNavigateWithIntercept,
|
|
6
|
+
dispatchNavigationNavigate,
|
|
7
|
+
type NavigationHistoryMode,
|
|
8
|
+
} from "./navigation-api";
|
|
9
|
+
import {
|
|
10
|
+
RBSSR_HEAD_MARKER_END_ATTR,
|
|
11
|
+
RBSSR_HEAD_MARKER_START_ATTR,
|
|
12
|
+
RBSSR_PAYLOAD_SCRIPT_ID,
|
|
13
|
+
RBSSR_ROUTER_SCRIPT_ID,
|
|
14
|
+
} from "./runtime-constants";
|
|
15
|
+
import {
|
|
16
|
+
createCatchAppTree,
|
|
17
|
+
createErrorAppTree,
|
|
18
|
+
createLoadingAppTree,
|
|
19
|
+
createNotFoundAppTree,
|
|
20
|
+
createPageAppTree,
|
|
21
|
+
} from "./tree";
|
|
22
|
+
import { isRouteErrorResponse } from "./route-errors";
|
|
23
|
+
import type {
|
|
24
|
+
ClientRouteSnapshot,
|
|
25
|
+
ClientRouterSnapshot,
|
|
26
|
+
Params,
|
|
27
|
+
RenderPayload,
|
|
28
|
+
RouteModule,
|
|
29
|
+
RouteModuleBundle,
|
|
30
|
+
TransitionChunk,
|
|
31
|
+
TransitionDeferredChunk,
|
|
32
|
+
TransitionInitialChunk,
|
|
33
|
+
TransitionRedirectChunk,
|
|
34
|
+
} from "./types";
|
|
5
35
|
|
|
6
36
|
interface DeferredClientRuntime {
|
|
7
37
|
get(id: string): Promise<unknown>;
|
|
38
|
+
resolve(id: string, value: unknown): void;
|
|
39
|
+
reject(id: string, message: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface NavigateOptions {
|
|
43
|
+
replace?: boolean;
|
|
44
|
+
scroll?: boolean;
|
|
45
|
+
onNavigate?: (info: NavigateResult) => void;
|
|
46
|
+
isPopState?: boolean;
|
|
47
|
+
historyManagedByNavigationApi?: boolean;
|
|
48
|
+
redirected?: boolean;
|
|
49
|
+
redirectDepth?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface NavigateResult {
|
|
53
|
+
from: string;
|
|
54
|
+
to: string;
|
|
55
|
+
status: number;
|
|
56
|
+
kind: "page" | "not_found" | "catch" | "error";
|
|
57
|
+
redirected: boolean;
|
|
58
|
+
prefetched: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface PrefetchEntry {
|
|
62
|
+
createdAt: number;
|
|
63
|
+
modulePromise: Promise<void>;
|
|
64
|
+
initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
|
|
65
|
+
donePromise: Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TransitionRequestOptions {
|
|
69
|
+
onDeferredChunk?: (chunk: TransitionDeferredChunk) => void;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TransitionRequestHandle {
|
|
74
|
+
initialPromise: Promise<TransitionInitialChunk | TransitionRedirectChunk | null>;
|
|
75
|
+
donePromise: Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface FrameworkNavigationInfo {
|
|
79
|
+
__rbssrTransition: true;
|
|
80
|
+
id: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface NavigationDestinationLike {
|
|
84
|
+
url?: string | URL;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface NavigationInterceptOptionsLike {
|
|
88
|
+
handler?: () => void | Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface NavigateEventLike {
|
|
92
|
+
info?: unknown;
|
|
93
|
+
canIntercept?: boolean;
|
|
94
|
+
userInitiated?: boolean;
|
|
95
|
+
destination?: NavigationDestinationLike;
|
|
96
|
+
intercept?: (options: NavigationInterceptOptionsLike) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface PendingNavigationTransition {
|
|
100
|
+
id: string;
|
|
101
|
+
destinationHref: string;
|
|
102
|
+
replace: boolean;
|
|
103
|
+
scroll: boolean;
|
|
104
|
+
onNavigate?: (info: NavigateResult) => void;
|
|
105
|
+
createdAt: number;
|
|
106
|
+
resolve: (value: NavigateResult | null) => void;
|
|
107
|
+
settled: boolean;
|
|
108
|
+
timeoutId: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RuntimeState {
|
|
112
|
+
root: Root;
|
|
113
|
+
currentPayload: RenderPayload;
|
|
114
|
+
currentRouteId: string;
|
|
115
|
+
currentModules: RouteModuleBundle;
|
|
116
|
+
routerSnapshot: ClientRouterSnapshot;
|
|
117
|
+
moduleRegistry: Map<string, RouteModuleBundle>;
|
|
118
|
+
prefetchCache: Map<string, PrefetchEntry>;
|
|
119
|
+
navigationToken: number;
|
|
120
|
+
transitionAbortController: AbortController | null;
|
|
8
121
|
}
|
|
9
122
|
|
|
10
123
|
declare global {
|
|
@@ -13,6 +126,249 @@ declare global {
|
|
|
13
126
|
}
|
|
14
127
|
}
|
|
15
128
|
|
|
129
|
+
const PREFETCH_TTL_MS = 30_000;
|
|
130
|
+
const NAVIGATION_API_PENDING_TIMEOUT_MS = 1_500;
|
|
131
|
+
const NAVIGATION_API_PENDING_MATCH_WINDOW_MS = 10_000;
|
|
132
|
+
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 = {};
|
|
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;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function withVersionQuery(url: string, version?: number): string {
|
|
206
|
+
if (typeof version !== "number") {
|
|
207
|
+
return url;
|
|
208
|
+
}
|
|
209
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
210
|
+
return `${url}${separator}v=${version}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getScriptJson<T>(id: string): T {
|
|
214
|
+
const script = document.getElementById(id);
|
|
215
|
+
if (!script) {
|
|
216
|
+
throw new Error(`Missing script tag #${id}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const raw = script.textContent ?? "{}";
|
|
220
|
+
return JSON.parse(raw) as T;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ensureRouteAnnouncer(): HTMLDivElement | null {
|
|
224
|
+
if (typeof document === "undefined") {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const existing = document.getElementById(ROUTE_ANNOUNCER_ID);
|
|
229
|
+
if (existing instanceof HTMLDivElement) {
|
|
230
|
+
return existing;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const announcer = document.createElement("div");
|
|
234
|
+
announcer.id = ROUTE_ANNOUNCER_ID;
|
|
235
|
+
announcer.setAttribute("aria-live", "assertive");
|
|
236
|
+
announcer.setAttribute("aria-atomic", "true");
|
|
237
|
+
announcer.style.position = "absolute";
|
|
238
|
+
announcer.style.width = "1px";
|
|
239
|
+
announcer.style.height = "1px";
|
|
240
|
+
announcer.style.padding = "0";
|
|
241
|
+
announcer.style.margin = "-1px";
|
|
242
|
+
announcer.style.overflow = "hidden";
|
|
243
|
+
announcer.style.clip = "rect(0, 0, 0, 0)";
|
|
244
|
+
announcer.style.whiteSpace = "nowrap";
|
|
245
|
+
announcer.style.border = "0";
|
|
246
|
+
|
|
247
|
+
document.body.appendChild(announcer);
|
|
248
|
+
return announcer;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getRouteAnnouncementText(): string {
|
|
252
|
+
const title = document.title.trim();
|
|
253
|
+
const heading = document.querySelector("h1");
|
|
254
|
+
const headingText = heading?.textContent?.trim() ?? "";
|
|
255
|
+
|
|
256
|
+
if (title.length > 0) {
|
|
257
|
+
if (headingText.length === 0) {
|
|
258
|
+
return title;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const normalizedTitle = title.toLowerCase();
|
|
262
|
+
const normalizedHeading = headingText.toLowerCase();
|
|
263
|
+
if (
|
|
264
|
+
normalizedTitle.includes(normalizedHeading)
|
|
265
|
+
|| normalizedHeading.includes(normalizedTitle)
|
|
266
|
+
) {
|
|
267
|
+
return title;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (headingText.length > 0) {
|
|
272
|
+
return headingText;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (title.length > 0) {
|
|
276
|
+
return title;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return window.location.pathname || "/";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function announceRouteChange(): void {
|
|
283
|
+
const announcer = ensureRouteAnnouncer();
|
|
284
|
+
if (!announcer) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
window.requestAnimationFrame(() => {
|
|
289
|
+
window.setTimeout(() => {
|
|
290
|
+
const announcement = getRouteAnnouncementText();
|
|
291
|
+
announcer.textContent = "";
|
|
292
|
+
window.setTimeout(() => {
|
|
293
|
+
announcer.textContent = announcement;
|
|
294
|
+
}, 0);
|
|
295
|
+
}, 50);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isFrameworkNavigationInfo(value: unknown): value is FrameworkNavigationInfo {
|
|
300
|
+
if (typeof value !== "object" || value === null) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const candidate = value as {
|
|
305
|
+
__rbssrTransition?: unknown;
|
|
306
|
+
id?: unknown;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return candidate.__rbssrTransition === true && typeof candidate.id === "string";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function toAbsoluteNavigationHref(href: string): string {
|
|
313
|
+
return new URL(href, window.location.href).toString();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function readNavigationDestinationHref(event: NavigateEventLike): string | null {
|
|
317
|
+
const rawUrl = event.destination?.url;
|
|
318
|
+
if (typeof rawUrl === "string") {
|
|
319
|
+
return toAbsoluteNavigationHref(rawUrl);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (rawUrl instanceof URL) {
|
|
323
|
+
return rawUrl.toString();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function clearPendingNavigationTransition(id: string): void {
|
|
330
|
+
const entry = pendingNavigationTransitions.get(id);
|
|
331
|
+
if (!entry) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
clearTimeout(entry.timeoutId);
|
|
336
|
+
pendingNavigationTransitions.delete(id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigationTransition | null {
|
|
340
|
+
if (isFrameworkNavigationInfo(event.info)) {
|
|
341
|
+
return pendingNavigationTransitions.get(event.info.id) ?? null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (event.userInitiated) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const destinationHref = readNavigationDestinationHref(event);
|
|
349
|
+
if (!destinationHref) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
let bestMatch: PendingNavigationTransition | null = null;
|
|
355
|
+
for (const candidate of pendingNavigationTransitions.values()) {
|
|
356
|
+
if (candidate.destinationHref !== destinationHref) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (now - candidate.createdAt > NAVIGATION_API_PENDING_MATCH_WINDOW_MS) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!bestMatch || candidate.createdAt > bestMatch.createdAt) {
|
|
365
|
+
bestMatch = candidate;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return bestMatch;
|
|
370
|
+
}
|
|
371
|
+
|
|
16
372
|
function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
17
373
|
const sourceData = payload.data;
|
|
18
374
|
if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
|
|
@@ -38,25 +394,931 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
|
38
394
|
};
|
|
39
395
|
}
|
|
40
396
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw new Error("Missing SSR payload script tag");
|
|
397
|
+
function ensureRuntimeState(): RuntimeState {
|
|
398
|
+
if (!runtimeState) {
|
|
399
|
+
throw new Error("Client runtime is not initialized. Ensure hydrateInitialRoute() ran first.");
|
|
45
400
|
}
|
|
46
401
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function createTransitionUrl(toUrl: URL): URL {
|
|
415
|
+
const transitionUrl = new URL("/__rbssr/transition", window.location.origin);
|
|
416
|
+
transitionUrl.searchParams.set("to", toUrl.pathname + toUrl.search + toUrl.hash);
|
|
417
|
+
return transitionUrl;
|
|
418
|
+
}
|
|
419
|
+
|
|
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
|
+
function startTransitionRequest(
|
|
443
|
+
toUrl: URL,
|
|
444
|
+
options: TransitionRequestOptions = {},
|
|
445
|
+
): TransitionRequestHandle {
|
|
446
|
+
let resolveInitial: (value: TransitionInitialChunk | TransitionRedirectChunk | null) => void = () => undefined;
|
|
447
|
+
let rejectInitial: (reason?: unknown) => void = () => undefined;
|
|
448
|
+
const initialPromise = new Promise<TransitionInitialChunk | TransitionRedirectChunk | null>((resolve, reject) => {
|
|
449
|
+
resolveInitial = resolve;
|
|
450
|
+
rejectInitial = reject;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const donePromise = (async () => {
|
|
454
|
+
const endpoint = createTransitionUrl(toUrl);
|
|
455
|
+
const response = await fetch(endpoint.toString(), {
|
|
456
|
+
method: "GET",
|
|
457
|
+
credentials: "same-origin",
|
|
458
|
+
signal: options.signal,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (!response.ok || !response.body) {
|
|
462
|
+
throw new Error(`Transition request failed with status ${response.status}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const reader = response.body.getReader();
|
|
466
|
+
const decoder = new TextDecoder();
|
|
467
|
+
let initialChunk: TransitionInitialChunk | TransitionRedirectChunk | null = null;
|
|
468
|
+
let textBuffer = "";
|
|
469
|
+
|
|
470
|
+
while (true) {
|
|
471
|
+
const { done, value } = await reader.read();
|
|
472
|
+
if (done) {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
textBuffer += decoder.decode(value, { stream: true });
|
|
477
|
+
const { lines, rest } = splitLines(textBuffer);
|
|
478
|
+
textBuffer = rest;
|
|
479
|
+
|
|
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
|
+
}
|
|
489
|
+
|
|
490
|
+
options.onDeferredChunk?.(chunk);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
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
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!initialChunk) {
|
|
508
|
+
resolveInitial(null);
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
|
|
512
|
+
donePromise.catch(error => {
|
|
513
|
+
rejectInitial(error);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
initialPromise,
|
|
518
|
+
donePromise,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function applyDeferredChunk(chunk: TransitionDeferredChunk): void {
|
|
523
|
+
const runtime = window.__RBSSR_DEFERRED__;
|
|
524
|
+
if (!runtime) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (chunk.ok) {
|
|
529
|
+
runtime.resolve(chunk.id, chunk.value);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
runtime.reject(chunk.id, chunk.error ?? "Deferred value rejected");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function ensureRouteModuleLoaded(routeId: string, snapshot: ClientRouterSnapshot): Promise<void> {
|
|
537
|
+
if (moduleRegistry.has(routeId)) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const asset = snapshot.assets[routeId];
|
|
542
|
+
if (!asset?.script) {
|
|
543
|
+
throw new Error(`Missing client asset script for route "${routeId}"`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const scriptUrl = withVersionQuery(asset.script, snapshot.devVersion);
|
|
547
|
+
await import(scriptUrl);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function getOrCreatePrefetchEntry(
|
|
551
|
+
toUrl: URL,
|
|
552
|
+
routeId: string | null,
|
|
553
|
+
snapshot: ClientRouterSnapshot,
|
|
554
|
+
signal?: AbortSignal,
|
|
555
|
+
): PrefetchEntry {
|
|
556
|
+
const state = ensureRuntimeState();
|
|
557
|
+
sanitizePrefetchCache(state.prefetchCache);
|
|
558
|
+
const cacheKey = toUrl.pathname + toUrl.search + toUrl.hash;
|
|
559
|
+
const existing = state.prefetchCache.get(cacheKey);
|
|
560
|
+
if (existing) {
|
|
561
|
+
return existing;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const modulePromise = routeId
|
|
565
|
+
? ensureRouteModuleLoaded(routeId, snapshot).catch(() => undefined)
|
|
566
|
+
: Promise.resolve();
|
|
567
|
+
|
|
568
|
+
const transitionRequest = startTransitionRequest(toUrl, {
|
|
569
|
+
onDeferredChunk: applyDeferredChunk,
|
|
570
|
+
signal,
|
|
571
|
+
});
|
|
572
|
+
const initialPromise = transitionRequest.initialPromise.catch(() => {
|
|
573
|
+
state.prefetchCache.delete(cacheKey);
|
|
574
|
+
return null;
|
|
575
|
+
});
|
|
576
|
+
const donePromise = transitionRequest.donePromise.catch(() => {
|
|
577
|
+
state.prefetchCache.delete(cacheKey);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const entry: PrefetchEntry = {
|
|
581
|
+
createdAt: Date.now(),
|
|
582
|
+
modulePromise,
|
|
583
|
+
initialPromise,
|
|
584
|
+
donePromise,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
state.prefetchCache.set(cacheKey, entry);
|
|
588
|
+
return entry;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function createFallbackNotFoundRoute(rootModule: RouteModule): RouteModule {
|
|
592
|
+
return {
|
|
593
|
+
default: () => null,
|
|
594
|
+
NotFound: rootModule.NotFound,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function nodeSignature(node: Node): string {
|
|
599
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
600
|
+
return `text:${node.textContent ?? ""}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
604
|
+
return `comment:${node.textContent ?? ""}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
608
|
+
return `node:${node.nodeType}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const element = node as Element;
|
|
612
|
+
const attrs = Array.from(element.attributes)
|
|
613
|
+
.map(attribute => `${attribute.name}=${attribute.value}`)
|
|
614
|
+
.sort((a, b) => a.localeCompare(b))
|
|
615
|
+
.join("|");
|
|
616
|
+
|
|
617
|
+
return `element:${element.tagName.toLowerCase()}:${attrs}:${element.innerHTML}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function isIgnorableTextNode(node: Node): boolean {
|
|
621
|
+
return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
|
|
625
|
+
const nodes: Node[] = [];
|
|
626
|
+
let cursor = startMarker.nextSibling;
|
|
627
|
+
while (cursor && cursor !== endMarker) {
|
|
628
|
+
nodes.push(cursor);
|
|
629
|
+
cursor = cursor.nextSibling;
|
|
630
|
+
}
|
|
631
|
+
return nodes;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function removeNode(node: Node): void {
|
|
635
|
+
if (node.parentNode) {
|
|
636
|
+
node.parentNode.removeChild(node);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
|
|
641
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const element = node as Element;
|
|
646
|
+
return (
|
|
647
|
+
element.tagName.toLowerCase() === "link"
|
|
648
|
+
&& (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
|
|
649
|
+
&& Boolean(element.getAttribute("href"))
|
|
650
|
+
);
|
|
50
651
|
}
|
|
51
652
|
|
|
52
|
-
|
|
53
|
-
|
|
653
|
+
function toAbsoluteHref(href: string): string {
|
|
654
|
+
return new URL(href, document.baseURI).toString();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
|
|
658
|
+
const sheet = link.sheet;
|
|
659
|
+
if (sheet) {
|
|
660
|
+
return Promise.resolve();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return new Promise(resolve => {
|
|
664
|
+
const finish = () => {
|
|
665
|
+
link.removeEventListener("load", finish);
|
|
666
|
+
link.removeEventListener("error", finish);
|
|
667
|
+
resolve();
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
link.addEventListener("load", finish, { once: true });
|
|
671
|
+
link.addEventListener("error", finish, { once: true });
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function reconcileStylesheetLinks(options: {
|
|
676
|
+
head: HTMLHeadElement;
|
|
677
|
+
desiredStylesheetHrefs: string[];
|
|
678
|
+
}): Promise<void> {
|
|
679
|
+
const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(toAbsoluteHref);
|
|
680
|
+
const existingLinks = Array.from(
|
|
681
|
+
options.head.querySelectorAll('link[rel="stylesheet"][href]'),
|
|
682
|
+
) as HTMLLinkElement[];
|
|
683
|
+
|
|
684
|
+
const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
|
|
685
|
+
for (const link of existingLinks) {
|
|
686
|
+
const href = link.getAttribute("href");
|
|
687
|
+
if (!href) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const absoluteHref = toAbsoluteHref(href);
|
|
691
|
+
const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
|
|
692
|
+
list.push(link);
|
|
693
|
+
existingByAbsoluteHref.set(absoluteHref, list);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const waitForLoads: Promise<void>[] = [];
|
|
697
|
+
for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
|
|
698
|
+
const href = options.desiredStylesheetHrefs[index]!;
|
|
699
|
+
const absoluteHref = desiredAbsoluteHrefs[index]!;
|
|
700
|
+
const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
|
|
701
|
+
if (existing) {
|
|
702
|
+
waitForLoads.push(waitForStylesheetLoad(existing));
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const link = document.createElement("link");
|
|
707
|
+
link.setAttribute("rel", "stylesheet");
|
|
708
|
+
link.setAttribute("href", href);
|
|
709
|
+
options.head.appendChild(link);
|
|
710
|
+
waitForLoads.push(waitForStylesheetLoad(link));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const seen = new Set<string>();
|
|
714
|
+
for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
715
|
+
const href = link.getAttribute("href");
|
|
716
|
+
if (!href) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const absoluteHref = toAbsoluteHref(href);
|
|
721
|
+
if (seen.has(absoluteHref)) {
|
|
722
|
+
removeNode(link);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
seen.add(absoluteHref);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
await Promise.all(waitForLoads);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function replaceManagedHead(headHtml: string): Promise<void> {
|
|
733
|
+
const head = document.head;
|
|
734
|
+
const startMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_START_ATTR}]`);
|
|
735
|
+
const endMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_END_ATTR}]`);
|
|
736
|
+
|
|
737
|
+
if (!startMarker || !endMarker || startMarker === endMarker) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const template = document.createElement("template");
|
|
742
|
+
template.innerHTML = headHtml;
|
|
743
|
+
|
|
744
|
+
const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
|
|
745
|
+
.map(link => link.getAttribute("href"))
|
|
746
|
+
.filter((value): value is string => Boolean(value));
|
|
747
|
+
for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
748
|
+
removeNode(styleNode);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
|
|
752
|
+
const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
|
|
753
|
+
if (isIgnorableTextNode(node)) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (isStylesheetLinkNode(node)) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return true;
|
|
762
|
+
});
|
|
763
|
+
const unusedCurrentNodes = new Set(currentNodes);
|
|
764
|
+
|
|
765
|
+
let cursor = startMarker.nextSibling;
|
|
766
|
+
|
|
767
|
+
for (const desiredNode of desiredNodes) {
|
|
768
|
+
while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
|
|
769
|
+
const next = cursor.nextSibling;
|
|
770
|
+
removeNode(cursor);
|
|
771
|
+
cursor = next;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const desiredSignature = nodeSignature(desiredNode);
|
|
775
|
+
|
|
776
|
+
if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
|
|
777
|
+
unusedCurrentNodes.delete(cursor);
|
|
778
|
+
cursor = cursor.nextSibling;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let matchedNode: Node | null = null;
|
|
783
|
+
for (const currentNode of currentNodes) {
|
|
784
|
+
if (!unusedCurrentNodes.has(currentNode)) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (nodeSignature(currentNode) === desiredSignature) {
|
|
788
|
+
matchedNode = currentNode;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (matchedNode) {
|
|
794
|
+
unusedCurrentNodes.delete(matchedNode);
|
|
795
|
+
head.insertBefore(matchedNode, cursor ?? endMarker);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
for (const leftover of unusedCurrentNodes) {
|
|
803
|
+
removeNode(leftover);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
await reconcileStylesheetLinks({
|
|
807
|
+
head,
|
|
808
|
+
desiredStylesheetHrefs,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function renderTransitionInitial(
|
|
813
|
+
chunk: TransitionInitialChunk,
|
|
814
|
+
toUrl: URL,
|
|
815
|
+
options: NavigateOptions & { prefetched: boolean; fromPath: string },
|
|
816
|
+
): Promise<NavigateResult> {
|
|
817
|
+
const state = ensureRuntimeState();
|
|
818
|
+
const revivedPayload = reviveDeferredPayload(chunk.payload);
|
|
819
|
+
let modules: RouteModuleBundle | null = null;
|
|
820
|
+
let tree = null as ReturnType<typeof createPageAppTree> | ReturnType<typeof createNotFoundAppTree>;
|
|
821
|
+
|
|
822
|
+
if (chunk.kind === "not_found") {
|
|
823
|
+
modules = {
|
|
824
|
+
root: state.currentModules.root,
|
|
825
|
+
layouts: [],
|
|
826
|
+
route: createFallbackNotFoundRoute(state.currentModules.root),
|
|
827
|
+
};
|
|
828
|
+
tree = createNotFoundAppTree(modules, revivedPayload);
|
|
829
|
+
} else {
|
|
830
|
+
modules = state.moduleRegistry.get(revivedPayload.routeId) ?? null;
|
|
831
|
+
if (!modules) {
|
|
832
|
+
throw new Error(`Missing loaded module bundle for route "${revivedPayload.routeId}"`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (chunk.kind === "error") {
|
|
836
|
+
tree = createErrorAppTree(
|
|
837
|
+
modules,
|
|
838
|
+
revivedPayload,
|
|
839
|
+
new Error(messageFromPayloadError(revivedPayload.error)),
|
|
840
|
+
);
|
|
841
|
+
} else if (chunk.kind === "catch") {
|
|
842
|
+
if (!isRouteErrorResponse(revivedPayload.error)) {
|
|
843
|
+
throw new Error("Transition catch payload is missing a valid route error.");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
tree = createCatchAppTree(modules, revivedPayload, revivedPayload.error);
|
|
847
|
+
} else {
|
|
848
|
+
tree = createPageAppTree(modules, revivedPayload);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!tree || !modules) {
|
|
853
|
+
throw new Error("Failed to build app tree for transition render.");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
await replaceManagedHead(chunk.head);
|
|
857
|
+
state.root.render(tree);
|
|
858
|
+
announceRouteChange();
|
|
859
|
+
|
|
860
|
+
if (!options.isPopState && !options.historyManagedByNavigationApi) {
|
|
861
|
+
const nextUrl = toUrl.pathname + toUrl.search + toUrl.hash;
|
|
862
|
+
if (options.replace || options.redirected) {
|
|
863
|
+
window.history.replaceState(null, "", nextUrl);
|
|
864
|
+
} else {
|
|
865
|
+
window.history.pushState(null, "", nextUrl);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!options.isPopState && options.scroll !== false) {
|
|
870
|
+
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
state.currentPayload = revivedPayload;
|
|
874
|
+
state.currentRouteId = revivedPayload.routeId;
|
|
875
|
+
state.currentModules = modules;
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
from: options.fromPath,
|
|
879
|
+
to: toUrl.pathname + toUrl.search + toUrl.hash,
|
|
880
|
+
status: chunk.status,
|
|
881
|
+
kind: chunk.kind,
|
|
882
|
+
redirected: options.redirected ?? false,
|
|
883
|
+
prefetched: options.prefetched,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function isInternalUrl(url: URL): boolean {
|
|
888
|
+
return url.origin === window.location.origin;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function hardNavigate(url: URL): void {
|
|
892
|
+
window.location.assign(url.toString());
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function messageFromPayloadError(value: unknown): string {
|
|
896
|
+
if (typeof value === "string") {
|
|
897
|
+
return value;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (!value || typeof value !== "object") {
|
|
901
|
+
return "Route render error";
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const candidate = value as { message?: unknown };
|
|
905
|
+
if (typeof candidate.message === "string" && candidate.message.trim().length > 0) {
|
|
906
|
+
return candidate.message;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return "Route render error";
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function navigateToInternal(
|
|
913
|
+
toUrl: URL,
|
|
914
|
+
options: NavigateOptions = {},
|
|
915
|
+
): Promise<NavigateResult | null> {
|
|
916
|
+
const state = ensureRuntimeState();
|
|
917
|
+
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
|
918
|
+
const targetPath = toUrl.pathname + toUrl.search + toUrl.hash;
|
|
919
|
+
|
|
920
|
+
if (currentPath === targetPath && !options.isPopState && !options.historyManagedByNavigationApi) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
|
|
925
|
+
const routeId = matched?.route.id ?? null;
|
|
926
|
+
|
|
927
|
+
if (state.transitionAbortController) {
|
|
928
|
+
state.transitionAbortController.abort();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const abortController = new AbortController();
|
|
932
|
+
state.transitionAbortController = abortController;
|
|
933
|
+
|
|
934
|
+
sanitizePrefetchCache(state.prefetchCache);
|
|
935
|
+
const prefetchKey = toUrl.pathname + toUrl.search + toUrl.hash;
|
|
936
|
+
const existingPrefetch = state.prefetchCache.get(prefetchKey);
|
|
937
|
+
const prefetchEntry = existingPrefetch
|
|
938
|
+
?? getOrCreatePrefetchEntry(toUrl, routeId, state.routerSnapshot, abortController.signal);
|
|
939
|
+
const usedPrefetch = Boolean(existingPrefetch);
|
|
940
|
+
state.navigationToken += 1;
|
|
941
|
+
const navigationToken = state.navigationToken;
|
|
942
|
+
|
|
943
|
+
try {
|
|
944
|
+
await prefetchEntry.modulePromise;
|
|
945
|
+
if (navigationToken !== state.navigationToken) {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (matched) {
|
|
950
|
+
const matchedModules = state.moduleRegistry.get(matched.route.id);
|
|
951
|
+
if (matchedModules) {
|
|
952
|
+
const loadingTree = createLoadingAppTree(
|
|
953
|
+
matchedModules,
|
|
954
|
+
{
|
|
955
|
+
routeId: matched.route.id,
|
|
956
|
+
data: null,
|
|
957
|
+
params: matched.params,
|
|
958
|
+
url: toUrl.toString(),
|
|
959
|
+
},
|
|
960
|
+
);
|
|
961
|
+
if (loadingTree) {
|
|
962
|
+
state.root.render(loadingTree);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const initialChunk = await prefetchEntry.initialPromise;
|
|
968
|
+
if (navigationToken !== state.navigationToken) {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!initialChunk) {
|
|
973
|
+
throw new Error("Transition response did not include an initial payload.");
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (initialChunk.type === "redirect") {
|
|
977
|
+
const redirectUrl = new URL(initialChunk.location, window.location.origin);
|
|
978
|
+
if (!isInternalUrl(redirectUrl)) {
|
|
979
|
+
hardNavigate(redirectUrl);
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const depth = (options.redirectDepth ?? 0) + 1;
|
|
984
|
+
if (depth > 8) {
|
|
985
|
+
hardNavigate(redirectUrl);
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return navigateToInternal(redirectUrl, {
|
|
990
|
+
...options,
|
|
991
|
+
replace: true,
|
|
992
|
+
redirected: true,
|
|
993
|
+
redirectDepth: depth,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const result = await renderTransitionInitial(initialChunk, toUrl, {
|
|
998
|
+
...options,
|
|
999
|
+
prefetched: usedPrefetch,
|
|
1000
|
+
fromPath: currentPath,
|
|
1001
|
+
});
|
|
1002
|
+
options.onNavigate?.(result);
|
|
1003
|
+
return result;
|
|
1004
|
+
} catch {
|
|
1005
|
+
hardNavigate(toUrl);
|
|
1006
|
+
return null;
|
|
1007
|
+
} finally {
|
|
1008
|
+
if (state.transitionAbortController === abortController) {
|
|
1009
|
+
state.transitionAbortController = null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function nextNavigationTransitionId(): string {
|
|
1015
|
+
navigationApiTransitionCounter += 1;
|
|
1016
|
+
return `rbssr-nav-${Date.now()}-${navigationApiTransitionCounter}`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function settlePendingNavigationTransition(
|
|
1020
|
+
transition: PendingNavigationTransition,
|
|
1021
|
+
result: NavigateResult | null,
|
|
1022
|
+
): void {
|
|
1023
|
+
if (transition.settled) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
transition.settled = true;
|
|
1028
|
+
clearPendingNavigationTransition(transition.id);
|
|
1029
|
+
transition.resolve(result);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function cancelPendingNavigationTransition(id: string): void {
|
|
1033
|
+
const pending = pendingNavigationTransitions.get(id);
|
|
1034
|
+
if (!pending || pending.settled) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
pending.settled = true;
|
|
1039
|
+
clearPendingNavigationTransition(id);
|
|
1040
|
+
pending.resolve(null);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function fallbackPendingNavigationTransition(pending: PendingNavigationTransition): void {
|
|
1044
|
+
if (pending.settled) {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
pending.settled = true;
|
|
1049
|
+
clearPendingNavigationTransition(pending.id);
|
|
1050
|
+
const destinationUrl = new URL(pending.destinationHref, window.location.href);
|
|
1051
|
+
void navigateToInternal(destinationUrl, {
|
|
1052
|
+
replace: pending.replace,
|
|
1053
|
+
scroll: pending.scroll,
|
|
1054
|
+
onNavigate: pending.onNavigate,
|
|
1055
|
+
}).then(result => {
|
|
1056
|
+
pending.resolve(result);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function createPendingNavigationTransition(options: {
|
|
1061
|
+
id: string;
|
|
1062
|
+
toUrl: URL;
|
|
1063
|
+
replace: boolean;
|
|
1064
|
+
scroll: boolean;
|
|
1065
|
+
onNavigate?: (info: NavigateResult) => void;
|
|
1066
|
+
}): Promise<NavigateResult | null> {
|
|
1067
|
+
return new Promise(resolve => {
|
|
1068
|
+
const timeoutId = window.setTimeout(() => {
|
|
1069
|
+
const pending = pendingNavigationTransitions.get(options.id);
|
|
1070
|
+
if (!pending || pending.settled) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
pending.settled = true;
|
|
1075
|
+
clearPendingNavigationTransition(options.id);
|
|
1076
|
+
void navigateToInternal(options.toUrl, {
|
|
1077
|
+
replace: options.replace,
|
|
1078
|
+
scroll: options.scroll,
|
|
1079
|
+
onNavigate: options.onNavigate,
|
|
1080
|
+
}).then(result => {
|
|
1081
|
+
resolve(result);
|
|
1082
|
+
});
|
|
1083
|
+
}, NAVIGATION_API_PENDING_TIMEOUT_MS);
|
|
1084
|
+
|
|
1085
|
+
pendingNavigationTransitions.set(options.id, {
|
|
1086
|
+
id: options.id,
|
|
1087
|
+
destinationHref: options.toUrl.toString(),
|
|
1088
|
+
replace: options.replace,
|
|
1089
|
+
scroll: options.scroll,
|
|
1090
|
+
onNavigate: options.onNavigate,
|
|
1091
|
+
createdAt: Date.now(),
|
|
1092
|
+
resolve,
|
|
1093
|
+
settled: false,
|
|
1094
|
+
timeoutId,
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function bindNavigationApiNavigateListener(): void {
|
|
1100
|
+
if (navigationApiListenerBound || typeof window === "undefined") {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (!canNavigationNavigateWithIntercept()) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const unsubscribe = addNavigationNavigateListener(rawEvent => {
|
|
1109
|
+
const navigateEvent = rawEvent as NavigateEventLike;
|
|
1110
|
+
if (typeof navigateEvent?.intercept !== "function") {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (navigateEvent.canIntercept === false) {
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const pending = findPendingTransitionForEvent(navigateEvent);
|
|
1119
|
+
if (!pending) {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const destinationHref = readNavigationDestinationHref(navigateEvent);
|
|
1124
|
+
if (!destinationHref) {
|
|
1125
|
+
fallbackPendingNavigationTransition(pending);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const destinationUrl = new URL(destinationHref, window.location.href);
|
|
1130
|
+
if (!isInternalUrl(destinationUrl)) {
|
|
1131
|
+
fallbackPendingNavigationTransition(pending);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
navigateEvent.intercept({
|
|
1137
|
+
handler: async () => {
|
|
1138
|
+
try {
|
|
1139
|
+
const result = await navigateToInternal(destinationUrl, {
|
|
1140
|
+
replace: pending.replace,
|
|
1141
|
+
scroll: pending.scroll,
|
|
1142
|
+
onNavigate: pending.onNavigate,
|
|
1143
|
+
historyManagedByNavigationApi: true,
|
|
1144
|
+
});
|
|
1145
|
+
settlePendingNavigationTransition(pending, result);
|
|
1146
|
+
} catch {
|
|
1147
|
+
settlePendingNavigationTransition(pending, null);
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
} catch {
|
|
1152
|
+
fallbackPendingNavigationTransition(pending);
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
if (!unsubscribe) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
navigationApiListenerBound = true;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function bindPopstate(): void {
|
|
1164
|
+
if (popstateBound || typeof window === "undefined") {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
popstateBound = true;
|
|
1169
|
+
window.addEventListener("popstate", () => {
|
|
1170
|
+
const targetUrl = new URL(window.location.href);
|
|
1171
|
+
void navigateToInternal(targetUrl, {
|
|
1172
|
+
replace: true,
|
|
1173
|
+
scroll: false,
|
|
1174
|
+
isPopState: true,
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
export async function prefetchTo(to: string): Promise<void> {
|
|
1180
|
+
if (typeof window === "undefined") {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (!runtimeState) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const state = runtimeState;
|
|
1188
|
+
const toUrl = new URL(to, window.location.href);
|
|
1189
|
+
if (!isInternalUrl(toUrl)) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const matched = matchPageRoute(state.routerSnapshot.pages, toUrl.pathname);
|
|
1194
|
+
const routeId = matched?.route.id ?? null;
|
|
1195
|
+
getOrCreatePrefetchEntry(toUrl, routeId, state.routerSnapshot);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
export async function navigateWithNavigationApiOrFallback(
|
|
1199
|
+
to: string,
|
|
1200
|
+
options: NavigateOptions = {},
|
|
1201
|
+
): Promise<NavigateResult | null> {
|
|
1202
|
+
if (typeof window === "undefined") {
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const toUrl = new URL(to, window.location.href);
|
|
1207
|
+
if (!isInternalUrl(toUrl)) {
|
|
1208
|
+
hardNavigate(toUrl);
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (!runtimeState) {
|
|
1213
|
+
hardNavigate(toUrl);
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
bindNavigationApiNavigateListener();
|
|
1218
|
+
if (!canNavigationNavigateWithIntercept()) {
|
|
1219
|
+
return navigateToInternal(toUrl, options);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const transitionId = nextNavigationTransitionId();
|
|
1223
|
+
const pendingPromise = createPendingNavigationTransition({
|
|
1224
|
+
id: transitionId,
|
|
1225
|
+
toUrl,
|
|
1226
|
+
replace: Boolean(options.replace),
|
|
1227
|
+
scroll: options.scroll !== false,
|
|
1228
|
+
onNavigate: options.onNavigate,
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
const history: NavigationHistoryMode = options.replace ? "replace" : "push";
|
|
1232
|
+
const dispatchResult = dispatchNavigationNavigate(toUrl.toString(), {
|
|
1233
|
+
history,
|
|
1234
|
+
info: {
|
|
1235
|
+
__rbssrTransition: true,
|
|
1236
|
+
id: transitionId,
|
|
1237
|
+
} satisfies FrameworkNavigationInfo,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
if (!dispatchResult.dispatched) {
|
|
1241
|
+
cancelPendingNavigationTransition(transitionId);
|
|
1242
|
+
return navigateToInternal(toUrl, options);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
if (dispatchResult.committed) {
|
|
1246
|
+
const committed = await dispatchResult.committed;
|
|
1247
|
+
if (!committed) {
|
|
1248
|
+
cancelPendingNavigationTransition(transitionId);
|
|
1249
|
+
return navigateToInternal(toUrl, options);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return pendingPromise;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export async function navigateTo(to: string, options: NavigateOptions = {}): Promise<NavigateResult | null> {
|
|
1257
|
+
if (typeof window === "undefined") {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const toUrl = new URL(to, window.location.href);
|
|
1262
|
+
if (!isInternalUrl(toUrl)) {
|
|
1263
|
+
hardNavigate(toUrl);
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (!runtimeState) {
|
|
1268
|
+
hardNavigate(toUrl);
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return navigateToInternal(toUrl, options);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export function registerRouteModules(routeId: string, modules: RouteModuleBundle): void {
|
|
1276
|
+
moduleRegistry.set(routeId, modules);
|
|
1277
|
+
if (runtimeState) {
|
|
1278
|
+
runtimeState.moduleRegistry.set(routeId, modules);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
export function hydrateInitialRoute(routeId: string): void {
|
|
1283
|
+
if (typeof document === "undefined" || runtimeState) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const payload = reviveDeferredPayload(getScriptJson<RenderPayload>(RBSSR_PAYLOAD_SCRIPT_ID));
|
|
1288
|
+
const routerSnapshot = getScriptJson<ClientRouterSnapshot>(RBSSR_ROUTER_SCRIPT_ID);
|
|
1289
|
+
const modules = moduleRegistry.get(routeId);
|
|
1290
|
+
if (!modules) {
|
|
1291
|
+
throw new Error(`Missing module registry for initial route "${routeId}"`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
54
1294
|
const container = document.getElementById("rbssr-root");
|
|
55
1295
|
if (!container) {
|
|
56
1296
|
throw new Error("Missing #rbssr-root hydration container");
|
|
57
1297
|
}
|
|
58
1298
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
1299
|
+
const appTree = payload.error
|
|
1300
|
+
? isRouteErrorResponse(payload.error)
|
|
1301
|
+
? createCatchAppTree(modules, payload, payload.error)
|
|
1302
|
+
: createErrorAppTree(modules, payload, new Error(messageFromPayloadError(payload.error)))
|
|
1303
|
+
: createPageAppTree(modules, payload);
|
|
1304
|
+
if (!appTree) {
|
|
1305
|
+
throw new Error("Failed to create initial app tree.");
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const root = hydrateRoot(container, appTree);
|
|
1309
|
+
runtimeState = {
|
|
1310
|
+
root,
|
|
1311
|
+
currentPayload: payload,
|
|
1312
|
+
currentRouteId: routeId,
|
|
1313
|
+
currentModules: modules,
|
|
1314
|
+
routerSnapshot,
|
|
1315
|
+
moduleRegistry,
|
|
1316
|
+
prefetchCache: new Map(),
|
|
1317
|
+
navigationToken: 0,
|
|
1318
|
+
transitionAbortController: null,
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
ensureRouteAnnouncer();
|
|
1322
|
+
bindNavigationApiNavigateListener();
|
|
1323
|
+
bindPopstate();
|
|
62
1324
|
}
|