react-bun-ssr 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -17
- package/framework/cli/dev-runtime.ts +77 -31
- package/framework/runtime/action-stub.ts +26 -0
- package/framework/runtime/client-runtime.tsx +4 -219
- package/framework/runtime/doctype-stream.ts +38 -0
- package/framework/runtime/head-reconcile.ts +270 -0
- package/framework/runtime/helpers.ts +75 -1
- package/framework/runtime/index.ts +53 -23
- package/framework/runtime/io.ts +12 -9
- package/framework/runtime/matcher.ts +4 -2
- package/framework/runtime/module-loader.ts +197 -35
- package/framework/runtime/render.tsx +3 -32
- package/framework/runtime/response-context.ts +206 -0
- package/framework/runtime/route-api.ts +51 -18
- package/framework/runtime/route-order.ts +23 -0
- package/framework/runtime/route-scanner.ts +103 -26
- package/framework/runtime/router.ts +43 -5
- package/framework/runtime/server.ts +430 -93
- package/framework/runtime/tree.tsx +120 -4
- package/framework/runtime/types.ts +71 -10
- package/package.json +20 -2
package/README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# react-bun-ssr
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/react-bun-ssr)
|
|
4
|
+
[](https://github.com/react-formation/react-bun-ssr/actions/workflows/ci.yml)
|
|
5
|
+
|
|
3
6
|
`react-bun-ssr` is a Bun-native SSR React framework with file-based routing, loaders, actions, middleware, streaming, soft navigation, and first-class markdown routes.
|
|
4
7
|
|
|
8
|
+
> **Stability: Experimental (early alpha).**
|
|
9
|
+
> Expect breaking changes across minor releases while core APIs and ergonomics are still being shaped.
|
|
10
|
+
|
|
5
11
|
- Documentation: https://react-bun-ssr.dev/docs
|
|
6
12
|
- API reference: https://react-bun-ssr.dev/docs/api/overview
|
|
7
13
|
- Blog: https://react-bun-ssr.dev/blog
|
|
14
|
+
- Changelog: [CHANGELOG.md](./CHANGELOG.md)
|
|
8
15
|
- Repository: https://github.com/react-formation/react-bun-ssr
|
|
9
16
|
|
|
10
17
|
## Why react-bun-ssr?
|
|
@@ -107,6 +114,43 @@ Read more:
|
|
|
107
114
|
- https://react-bun-ssr.dev/docs/routing/middleware
|
|
108
115
|
- https://react-bun-ssr.dev/docs/data/loaders
|
|
109
116
|
|
|
117
|
+
### Actions with React `useActionState`
|
|
118
|
+
|
|
119
|
+
Page mutations use React 19 form actions (`useActionState`) with an explicit route stub:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// app/routes/login.tsx
|
|
123
|
+
import { useActionState } from "react";
|
|
124
|
+
import { createRouteAction } from "react-bun-ssr/route";
|
|
125
|
+
|
|
126
|
+
type LoginState = { error?: string };
|
|
127
|
+
export const action = createRouteAction<LoginState>();
|
|
128
|
+
|
|
129
|
+
export default function LoginPage() {
|
|
130
|
+
const [state, formAction, pending] = useActionState(action, {});
|
|
131
|
+
return (
|
|
132
|
+
<form action={formAction}>
|
|
133
|
+
{state.error ? <p>{state.error}</p> : null}
|
|
134
|
+
<button disabled={pending}>Sign in</button>
|
|
135
|
+
</form>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// app/routes/login.server.tsx
|
|
142
|
+
import { redirect } from "react-bun-ssr";
|
|
143
|
+
import type { Action } from "react-bun-ssr/route";
|
|
144
|
+
|
|
145
|
+
export const action: Action = async (ctx) => {
|
|
146
|
+
const email = String(ctx.formData?.get("email") ?? "").trim();
|
|
147
|
+
if (!email) return { error: "Email is required" };
|
|
148
|
+
return redirect("/dashboard");
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`createRouteAction` is the preferred pattern. `useRouteAction` remains available for backward compatibility.
|
|
153
|
+
|
|
110
154
|
### Rendering model
|
|
111
155
|
|
|
112
156
|
SSR is the default model. HTML responses stream, deferred loader data is supported, and soft client transitions are handled through `Link` and `useRouter`. The docs site in this repository uses the same routing, rendering, markdown, and transition model that framework users get.
|
|
@@ -152,6 +196,7 @@ This repository contains both the framework and the official docs site built wit
|
|
|
152
196
|
```bash
|
|
153
197
|
git clone git@github.com:react-formation/react-bun-ssr.git
|
|
154
198
|
cd react-bun-ssr
|
|
199
|
+
bun link
|
|
155
200
|
bun install
|
|
156
201
|
bun run docs:dev
|
|
157
202
|
```
|
|
@@ -189,20 +234,3 @@ Contributions should keep framework behavior, docs, tests, and generated artifac
|
|
|
189
234
|
- The release workflow derives the published package version from the Git tag and rewrites `package.json` in the release job before publishing.
|
|
190
235
|
- npm publishing uses trusted publishing with GitHub OIDC instead of an `NPM_TOKEN`.
|
|
191
236
|
- npm package settings must have a trusted publisher configured for `react-formation / react-bun-ssr / release.yml`.
|
|
192
|
-
|
|
193
|
-
## Deploying
|
|
194
|
-
|
|
195
|
-
Fly.io deployment support is already documented and used by this project.
|
|
196
|
-
|
|
197
|
-
Happy path:
|
|
198
|
-
|
|
199
|
-
```bash
|
|
200
|
-
fly auth login
|
|
201
|
-
fly deploy
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Full deployment docs:
|
|
205
|
-
|
|
206
|
-
- https://react-bun-ssr.dev/docs/deployment/bun-deployment
|
|
207
|
-
- https://react-bun-ssr.dev/docs/deployment/configuration
|
|
208
|
-
- https://react-bun-ssr.dev/docs/deployment/troubleshooting
|
|
@@ -26,13 +26,20 @@ function isConfigFileName(fileName: string): boolean {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function isTopLevelAppRuntimeFile(relativePath: string): boolean {
|
|
29
|
-
return /^root
|
|
29
|
+
return /^root(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
30
|
+
|| /^middleware(?:\.server)?\.(tsx|jsx|ts|js)$/.test(relativePath);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function isMarkdownRouteFile(relativePath: string): boolean {
|
|
33
34
|
return /^routes\/.+\.md$/.test(relativePath);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function isServerOnlyRuntimeFile(relativePath: string): boolean {
|
|
38
|
+
return /^root\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
39
|
+
|| /^middleware\.server\.(tsx|jsx|ts|js)$/.test(relativePath)
|
|
40
|
+
|| /^routes\/.+\.server\.(tsx|jsx|ts|js)$/.test(relativePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function isStructuralAppPath(relativePath: string): boolean {
|
|
37
44
|
return relativePath === "routes"
|
|
38
45
|
|| relativePath.startsWith("routes/")
|
|
@@ -43,6 +50,10 @@ function toAbsoluteAppPath(appDir: string, relativePath: string): string {
|
|
|
43
50
|
return path.join(appDir, relativePath);
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
function toNormalizedWatchPath(fileName?: string | Buffer | null): string {
|
|
54
|
+
return typeof fileName === "string" ? normalizeSlashes(fileName) : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
interface DevHotData {
|
|
47
58
|
bunServer?: Bun.Server<undefined>;
|
|
48
59
|
reloadToken?: number;
|
|
@@ -88,9 +99,11 @@ export async function runHotDevChild(options: {
|
|
|
88
99
|
let nextClientBuildReason: DevReloadReason | null = null;
|
|
89
100
|
let stopping = false;
|
|
90
101
|
|
|
91
|
-
const watchers: FSWatcher[] = [];
|
|
92
102
|
let structuralSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
|
93
103
|
let structuralSyncQueue: Promise<void> = Promise.resolve();
|
|
104
|
+
let routesWatcher: FSWatcher | null = null;
|
|
105
|
+
let appWatcher: FSWatcher | null = null;
|
|
106
|
+
let configWatcher: FSWatcher | null = null;
|
|
94
107
|
|
|
95
108
|
const publishReload = (reason: DevReloadReason): void => {
|
|
96
109
|
reloadToken += 1;
|
|
@@ -253,21 +266,27 @@ export async function runHotDevChild(options: {
|
|
|
253
266
|
process.exit(RBSSR_DEV_RESTART_EXIT_CODE);
|
|
254
267
|
};
|
|
255
268
|
|
|
256
|
-
const handleAppEvent = (eventType: string,
|
|
257
|
-
const relativePath = typeof fileName === "string"
|
|
258
|
-
? normalizeSlashes(fileName)
|
|
259
|
-
: "";
|
|
260
|
-
|
|
269
|
+
const handleAppEvent = (eventType: string, relativePath: string): void => {
|
|
261
270
|
if (!relativePath) {
|
|
262
271
|
scheduleStructuralSync();
|
|
263
272
|
return;
|
|
264
273
|
}
|
|
265
274
|
|
|
275
|
+
if (eventType === "rename" && isServerOnlyRuntimeFile(relativePath)) {
|
|
276
|
+
publishReload("server-runtime");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
if (eventType === "rename" && isStructuralAppPath(relativePath)) {
|
|
267
281
|
scheduleStructuralSync();
|
|
268
282
|
return;
|
|
269
283
|
}
|
|
270
284
|
|
|
285
|
+
if (eventType === "change" && isServerOnlyRuntimeFile(relativePath)) {
|
|
286
|
+
publishReload("server-runtime");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
271
290
|
if (eventType !== "change" || !isMarkdownRouteFile(relativePath)) {
|
|
272
291
|
return;
|
|
273
292
|
}
|
|
@@ -289,10 +308,29 @@ export async function runHotDevChild(options: {
|
|
|
289
308
|
});
|
|
290
309
|
};
|
|
291
310
|
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
|
|
311
|
+
const ensureRoutesWatcher = async (): Promise<void> => {
|
|
312
|
+
if (routesWatcher || !(await existsPath(resolved.routesDir))) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
routesWatcher = watch(resolved.routesDir, { recursive: true }, (eventType, fileName) => {
|
|
318
|
+
const nestedPath = toNormalizedWatchPath(fileName);
|
|
319
|
+
const relativePath = nestedPath ? `routes/${nestedPath}` : "routes";
|
|
320
|
+
handleAppEvent(eventType, relativePath);
|
|
321
|
+
});
|
|
322
|
+
} catch {
|
|
323
|
+
log(`recursive route watching unavailable for ${resolved.routesDir}; route topology updates may require a restart`);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const refreshRoutesWatcher = async (): Promise<void> => {
|
|
328
|
+
if (routesWatcher) {
|
|
329
|
+
routesWatcher.close();
|
|
330
|
+
routesWatcher = null;
|
|
295
331
|
}
|
|
332
|
+
|
|
333
|
+
await ensureRoutesWatcher();
|
|
296
334
|
};
|
|
297
335
|
|
|
298
336
|
const cleanup = async (options: {
|
|
@@ -303,9 +341,12 @@ export async function runHotDevChild(options: {
|
|
|
303
341
|
structuralSyncTimer = undefined;
|
|
304
342
|
}
|
|
305
343
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
344
|
+
routesWatcher?.close();
|
|
345
|
+
routesWatcher = null;
|
|
346
|
+
appWatcher?.close();
|
|
347
|
+
appWatcher = null;
|
|
348
|
+
configWatcher?.close();
|
|
349
|
+
configWatcher = null;
|
|
309
350
|
|
|
310
351
|
if (clientWatch) {
|
|
311
352
|
await clientWatch.stop();
|
|
@@ -318,31 +359,38 @@ export async function runHotDevChild(options: {
|
|
|
318
359
|
}
|
|
319
360
|
};
|
|
320
361
|
|
|
362
|
+
await refreshRoutesWatcher();
|
|
363
|
+
|
|
321
364
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
365
|
+
appWatcher = watch(resolved.appDir, (eventType, fileName) => {
|
|
366
|
+
const relativePath = toNormalizedWatchPath(fileName);
|
|
367
|
+
if (relativePath === "routes" && eventType === "rename") {
|
|
368
|
+
void refreshRoutesWatcher();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
handleAppEvent(eventType, relativePath);
|
|
372
|
+
});
|
|
327
373
|
} catch {
|
|
328
|
-
log(`
|
|
374
|
+
log(`top-level app watching unavailable for ${resolved.appDir}; route topology updates may require a restart`);
|
|
329
375
|
}
|
|
330
376
|
|
|
331
377
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
378
|
+
configWatcher = watch(options.cwd, (eventType, fileName) => {
|
|
379
|
+
const configFileName = toNormalizedWatchPath(fileName);
|
|
380
|
+
if (!configFileName || !isConfigFileName(configFileName)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (eventType === "rename" || eventType === "change") {
|
|
384
|
+
void restartForConfigChange();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
342
387
|
} catch {
|
|
343
388
|
log(`config file watching unavailable for ${options.cwd}; config changes may require a manual restart`);
|
|
344
389
|
}
|
|
345
390
|
|
|
391
|
+
await enqueueStructuralSync("bootstrap");
|
|
392
|
+
await refreshRoutesWatcher();
|
|
393
|
+
|
|
346
394
|
if (import.meta.hot) {
|
|
347
395
|
import.meta.hot.dispose(async (data: DevHotData) => {
|
|
348
396
|
data.bunServer = bunServer;
|
|
@@ -352,8 +400,6 @@ export async function runHotDevChild(options: {
|
|
|
352
400
|
});
|
|
353
401
|
}
|
|
354
402
|
|
|
355
|
-
await enqueueStructuralSync("bootstrap");
|
|
356
|
-
|
|
357
403
|
if (hotData.bunServer && bunServer) {
|
|
358
404
|
publishReload("server-runtime");
|
|
359
405
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type RouteActionStateHandler<TState = unknown> = (
|
|
2
|
+
previousState: TState,
|
|
3
|
+
formData: FormData,
|
|
4
|
+
) => Promise<TState>;
|
|
5
|
+
|
|
6
|
+
const ROUTE_ACTION_STUB_MARKER = Symbol.for("react-bun-ssr.route-action-stub");
|
|
7
|
+
|
|
8
|
+
export function markRouteActionStub<TState>(
|
|
9
|
+
handler: RouteActionStateHandler<TState>,
|
|
10
|
+
): RouteActionStateHandler<TState> {
|
|
11
|
+
Object.defineProperty(handler, ROUTE_ACTION_STUB_MARKER, {
|
|
12
|
+
value: true,
|
|
13
|
+
enumerable: false,
|
|
14
|
+
configurable: false,
|
|
15
|
+
writable: false,
|
|
16
|
+
});
|
|
17
|
+
return handler;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isRouteActionStub(value: unknown): value is RouteActionStateHandler<unknown> {
|
|
21
|
+
if (typeof value !== "function") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (value as unknown as Record<PropertyKey, unknown>)[ROUTE_ACTION_STUB_MARKER] === true;
|
|
26
|
+
}
|
|
@@ -17,11 +17,10 @@ import {
|
|
|
17
17
|
type NavigationHistoryMode,
|
|
18
18
|
} from "./navigation-api";
|
|
19
19
|
import {
|
|
20
|
-
RBSSR_HEAD_MARKER_END_ATTR,
|
|
21
|
-
RBSSR_HEAD_MARKER_START_ATTR,
|
|
22
20
|
RBSSR_PAYLOAD_SCRIPT_ID,
|
|
23
21
|
RBSSR_ROUTER_SCRIPT_ID,
|
|
24
22
|
} from "./runtime-constants";
|
|
23
|
+
import { replaceManagedHead } from "./head-reconcile";
|
|
25
24
|
import {
|
|
26
25
|
createCatchAppTree,
|
|
27
26
|
createErrorAppTree,
|
|
@@ -375,7 +374,7 @@ function findPendingTransitionForEvent(event: NavigateEventLike): PendingNavigat
|
|
|
375
374
|
}
|
|
376
375
|
|
|
377
376
|
function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
378
|
-
const sourceData = payload.
|
|
377
|
+
const sourceData = payload.loaderData;
|
|
379
378
|
if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
|
|
380
379
|
return payload;
|
|
381
380
|
}
|
|
@@ -395,7 +394,7 @@ function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
|
395
394
|
|
|
396
395
|
return {
|
|
397
396
|
...payload,
|
|
398
|
-
|
|
397
|
+
loaderData: revivedData,
|
|
399
398
|
};
|
|
400
399
|
}
|
|
401
400
|
|
|
@@ -569,220 +568,6 @@ function createFallbackNotFoundRoute(rootModule: RouteModule): RouteModule {
|
|
|
569
568
|
};
|
|
570
569
|
}
|
|
571
570
|
|
|
572
|
-
function nodeSignature(node: Node): string {
|
|
573
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
574
|
-
return `text:${node.textContent ?? ""}`;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (node.nodeType === Node.COMMENT_NODE) {
|
|
578
|
-
return `comment:${node.textContent ?? ""}`;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
582
|
-
return `node:${node.nodeType}`;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const element = node as Element;
|
|
586
|
-
const attrs = Array.from(element.attributes)
|
|
587
|
-
.map(attribute => `${attribute.name}=${attribute.value}`)
|
|
588
|
-
.sort((a, b) => a.localeCompare(b))
|
|
589
|
-
.join("|");
|
|
590
|
-
|
|
591
|
-
return `element:${element.tagName.toLowerCase()}:${attrs}:${element.innerHTML}`;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function isIgnorableTextNode(node: Node): boolean {
|
|
595
|
-
return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
|
|
599
|
-
const nodes: Node[] = [];
|
|
600
|
-
let cursor = startMarker.nextSibling;
|
|
601
|
-
while (cursor && cursor !== endMarker) {
|
|
602
|
-
nodes.push(cursor);
|
|
603
|
-
cursor = cursor.nextSibling;
|
|
604
|
-
}
|
|
605
|
-
return nodes;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function removeNode(node: Node): void {
|
|
609
|
-
if (node.parentNode) {
|
|
610
|
-
node.parentNode.removeChild(node);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
|
|
615
|
-
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
616
|
-
return false;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
const element = node as Element;
|
|
620
|
-
return (
|
|
621
|
-
element.tagName.toLowerCase() === "link"
|
|
622
|
-
&& (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
|
|
623
|
-
&& Boolean(element.getAttribute("href"))
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function toAbsoluteHref(href: string): string {
|
|
628
|
-
return new URL(href, document.baseURI).toString();
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
|
|
632
|
-
const sheet = link.sheet;
|
|
633
|
-
if (sheet) {
|
|
634
|
-
return Promise.resolve();
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return new Promise(resolve => {
|
|
638
|
-
const finish = () => {
|
|
639
|
-
link.removeEventListener("load", finish);
|
|
640
|
-
link.removeEventListener("error", finish);
|
|
641
|
-
resolve();
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
link.addEventListener("load", finish, { once: true });
|
|
645
|
-
link.addEventListener("error", finish, { once: true });
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
async function reconcileStylesheetLinks(options: {
|
|
650
|
-
head: HTMLHeadElement;
|
|
651
|
-
desiredStylesheetHrefs: string[];
|
|
652
|
-
}): Promise<void> {
|
|
653
|
-
const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(toAbsoluteHref);
|
|
654
|
-
const existingLinks = Array.from(
|
|
655
|
-
options.head.querySelectorAll('link[rel="stylesheet"][href]'),
|
|
656
|
-
) as HTMLLinkElement[];
|
|
657
|
-
|
|
658
|
-
const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
|
|
659
|
-
for (const link of existingLinks) {
|
|
660
|
-
const href = link.getAttribute("href");
|
|
661
|
-
if (!href) {
|
|
662
|
-
continue;
|
|
663
|
-
}
|
|
664
|
-
const absoluteHref = toAbsoluteHref(href);
|
|
665
|
-
const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
|
|
666
|
-
list.push(link);
|
|
667
|
-
existingByAbsoluteHref.set(absoluteHref, list);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const waitForLoads: Promise<void>[] = [];
|
|
671
|
-
for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
|
|
672
|
-
const href = options.desiredStylesheetHrefs[index]!;
|
|
673
|
-
const absoluteHref = desiredAbsoluteHrefs[index]!;
|
|
674
|
-
const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
|
|
675
|
-
if (existing) {
|
|
676
|
-
waitForLoads.push(waitForStylesheetLoad(existing));
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const link = document.createElement("link");
|
|
681
|
-
link.setAttribute("rel", "stylesheet");
|
|
682
|
-
link.setAttribute("href", href);
|
|
683
|
-
options.head.appendChild(link);
|
|
684
|
-
waitForLoads.push(waitForStylesheetLoad(link));
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const seen = new Set<string>();
|
|
688
|
-
for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
689
|
-
const href = link.getAttribute("href");
|
|
690
|
-
if (!href) {
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const absoluteHref = toAbsoluteHref(href);
|
|
695
|
-
if (seen.has(absoluteHref)) {
|
|
696
|
-
removeNode(link);
|
|
697
|
-
continue;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
seen.add(absoluteHref);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
await Promise.all(waitForLoads);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
async function replaceManagedHead(headHtml: string): Promise<void> {
|
|
707
|
-
const head = document.head;
|
|
708
|
-
const startMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_START_ATTR}]`);
|
|
709
|
-
const endMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_END_ATTR}]`);
|
|
710
|
-
|
|
711
|
-
if (!startMarker || !endMarker || startMarker === endMarker) {
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const template = document.createElement("template");
|
|
716
|
-
template.innerHTML = headHtml;
|
|
717
|
-
|
|
718
|
-
const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
|
|
719
|
-
.map(link => link.getAttribute("href"))
|
|
720
|
-
.filter((value): value is string => Boolean(value));
|
|
721
|
-
for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
|
|
722
|
-
removeNode(styleNode);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
|
|
726
|
-
const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
|
|
727
|
-
if (isIgnorableTextNode(node)) {
|
|
728
|
-
return false;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
if (isStylesheetLinkNode(node)) {
|
|
732
|
-
return false;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
return true;
|
|
736
|
-
});
|
|
737
|
-
const unusedCurrentNodes = new Set(currentNodes);
|
|
738
|
-
|
|
739
|
-
let cursor = startMarker.nextSibling;
|
|
740
|
-
|
|
741
|
-
for (const desiredNode of desiredNodes) {
|
|
742
|
-
while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
|
|
743
|
-
const next = cursor.nextSibling;
|
|
744
|
-
removeNode(cursor);
|
|
745
|
-
cursor = next;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const desiredSignature = nodeSignature(desiredNode);
|
|
749
|
-
|
|
750
|
-
if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
|
|
751
|
-
unusedCurrentNodes.delete(cursor);
|
|
752
|
-
cursor = cursor.nextSibling;
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
let matchedNode: Node | null = null;
|
|
757
|
-
for (const currentNode of currentNodes) {
|
|
758
|
-
if (!unusedCurrentNodes.has(currentNode)) {
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
if (nodeSignature(currentNode) === desiredSignature) {
|
|
762
|
-
matchedNode = currentNode;
|
|
763
|
-
break;
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (matchedNode) {
|
|
768
|
-
unusedCurrentNodes.delete(matchedNode);
|
|
769
|
-
head.insertBefore(matchedNode, cursor ?? endMarker);
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
for (const leftover of unusedCurrentNodes) {
|
|
777
|
-
removeNode(leftover);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
await reconcileStylesheetLinks({
|
|
781
|
-
head,
|
|
782
|
-
desiredStylesheetHrefs,
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
|
|
786
571
|
async function renderTransitionInitial(
|
|
787
572
|
chunk: TransitionInitialChunk,
|
|
788
573
|
toUrl: URL,
|
|
@@ -928,7 +713,7 @@ async function navigateToInternal(
|
|
|
928
713
|
matchedModules,
|
|
929
714
|
{
|
|
930
715
|
routeId: matched.route.id,
|
|
931
|
-
|
|
716
|
+
loaderData: null,
|
|
932
717
|
params: matched.params,
|
|
933
718
|
url: toUrl.toString(),
|
|
934
719
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const DOCTYPE = new TextEncoder().encode("<!doctype html>");
|
|
2
|
+
|
|
3
|
+
export function prependDoctypeStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
4
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
5
|
+
|
|
6
|
+
return new ReadableStream<Uint8Array>({
|
|
7
|
+
async start(controller) {
|
|
8
|
+
controller.enqueue(DOCTYPE);
|
|
9
|
+
reader = stream.getReader();
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
while (true) {
|
|
13
|
+
const result = await reader.read();
|
|
14
|
+
if (result.done) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
controller.enqueue(result.value);
|
|
18
|
+
}
|
|
19
|
+
controller.close();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
controller.error(error);
|
|
22
|
+
} finally {
|
|
23
|
+
const activeReader = reader;
|
|
24
|
+
reader = null;
|
|
25
|
+
activeReader?.releaseLock();
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
async cancel(reason) {
|
|
29
|
+
const activeReader = reader;
|
|
30
|
+
if (activeReader) {
|
|
31
|
+
await activeReader.cancel(reason);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await stream.cancel(reason);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|