@uniflowed/router 0.0.0-alpha.17 → 0.0.0-alpha.20
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/client.js +187 -5
- package/handler.js +2 -2
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +114 -10
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +99 -1
package/internal/stream.js
CHANGED
|
@@ -43,6 +43,8 @@ import * as React from "react";
|
|
|
43
43
|
import * as ReactDOMServer from "react-dom/server";
|
|
44
44
|
import * as ReactDOMStatic from "react-dom/static";
|
|
45
45
|
|
|
46
|
+
import { type StreamRecord, inspected } from "./inspector.js";
|
|
47
|
+
|
|
46
48
|
/**
|
|
47
49
|
* Where a document is written, when the host has a Node stream.
|
|
48
50
|
*
|
|
@@ -307,9 +309,20 @@ function queueDestination(queue: ChunkQueue): NodeDestination {
|
|
|
307
309
|
async function* assembled(
|
|
308
310
|
chunks: AsyncGenerator<string, void, void>,
|
|
309
311
|
shell: DocumentShell,
|
|
312
|
+
transformHead?: (html: string) => Promise<string>,
|
|
310
313
|
): AsyncGenerator<string, void, void> {
|
|
311
314
|
let held = "";
|
|
312
315
|
let shape = "unknown";
|
|
316
|
+
// The opening chunk is the only one the hook sees, and every path below
|
|
317
|
+
// reaches exactly one of them. Awaiting here rather than at each `yield`
|
|
318
|
+
// keeps them from drifting apart. The hook sees only the document opening:
|
|
319
|
+
// Vite's HTML parser is happy with an open body, but not with a React chunk
|
|
320
|
+
// that happens to end inside an attribute.
|
|
321
|
+
const opening = async (html: string, rest: string = ""): Promise<string> => {
|
|
322
|
+
const split = transformableOpening(html);
|
|
323
|
+
const transformed = transformHead == null ? split.opening : await transformHead(split.opening);
|
|
324
|
+
return transformed + split.rest + rest;
|
|
325
|
+
};
|
|
313
326
|
|
|
314
327
|
for await (const chunk of chunks) {
|
|
315
328
|
if (shape === "document-open" || shape === "shell-open") {
|
|
@@ -329,7 +342,7 @@ async function* assembled(
|
|
|
329
342
|
continue;
|
|
330
343
|
}
|
|
331
344
|
shape = "shell-open";
|
|
332
|
-
yield shell.open + split.head + shell.body
|
|
345
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
333
346
|
held = "";
|
|
334
347
|
continue;
|
|
335
348
|
}
|
|
@@ -337,15 +350,21 @@ async function* assembled(
|
|
|
337
350
|
const close = held.indexOf("</head>");
|
|
338
351
|
if (close !== -1) {
|
|
339
352
|
shape = "document-open";
|
|
340
|
-
yield ufDoctype(held.slice(0, close) + shell.head + held.slice(close));
|
|
353
|
+
yield await opening(ufDoctype(held.slice(0, close) + shell.head + held.slice(close)));
|
|
341
354
|
held = "";
|
|
342
355
|
continue;
|
|
343
356
|
}
|
|
344
357
|
// `<body` before `</head>` means React wrote no head; give the tags one.
|
|
345
358
|
const body = held.search(/<body[\s>]/i);
|
|
346
359
|
if (body !== -1) {
|
|
360
|
+
const bodyEnd = held.indexOf(">", body);
|
|
361
|
+
if (bodyEnd === -1) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
347
364
|
shape = "document-open";
|
|
348
|
-
yield
|
|
365
|
+
yield await opening(
|
|
366
|
+
ufDoctype(`${held.slice(0, body)}<head>${shell.head}</head>${held.slice(body)}`),
|
|
367
|
+
);
|
|
349
368
|
held = "";
|
|
350
369
|
}
|
|
351
370
|
}
|
|
@@ -355,14 +374,14 @@ async function* assembled(
|
|
|
355
374
|
// `<body>` in it, or a shell that is hoistable elements all the way down.
|
|
356
375
|
// There is nothing left to wait for in any of them.
|
|
357
376
|
if (shape === "document") {
|
|
358
|
-
yield ufDoctype(held + shell.head);
|
|
377
|
+
yield await opening(ufDoctype(held + shell.head));
|
|
359
378
|
shape = "document-open";
|
|
360
379
|
} else if (shape === "shell" || shape === "unknown") {
|
|
361
380
|
// `complete` is not consulted: nothing more is coming, so a run that was
|
|
362
381
|
// still open is over and whatever was left of it is markup like any other.
|
|
363
382
|
const split = hoisted(held);
|
|
364
383
|
shape = "shell-open";
|
|
365
|
-
yield shell.open + split.head + shell.body
|
|
384
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
366
385
|
}
|
|
367
386
|
if (shape === "shell-open") {
|
|
368
387
|
yield shell.close;
|
|
@@ -374,6 +393,25 @@ async function* assembled(
|
|
|
374
393
|
}
|
|
375
394
|
}
|
|
376
395
|
|
|
396
|
+
function transformableOpening(html: string): {| readonly opening: string, readonly rest: string |} {
|
|
397
|
+
const lower = html.toLowerCase();
|
|
398
|
+
const headEnd = lower.indexOf("</head>");
|
|
399
|
+
if (headEnd === -1) {
|
|
400
|
+
return { opening: html, rest: "" };
|
|
401
|
+
}
|
|
402
|
+
const afterHead = headEnd + "</head>".length;
|
|
403
|
+
const body = lower.indexOf("<body", afterHead);
|
|
404
|
+
if (body === -1) {
|
|
405
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
406
|
+
}
|
|
407
|
+
const bodyEnd = html.indexOf(">", body);
|
|
408
|
+
if (bodyEnd === -1) {
|
|
409
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
410
|
+
}
|
|
411
|
+
const afterBody = bodyEnd + 1;
|
|
412
|
+
return { opening: html.slice(0, afterBody), rest: html.slice(afterBody) };
|
|
413
|
+
}
|
|
414
|
+
|
|
377
415
|
/**
|
|
378
416
|
* The head elements React opened the app's markup with, split from the rest.
|
|
379
417
|
*
|
|
@@ -582,8 +620,58 @@ export type RenderOptions = {|
|
|
|
582
620
|
* shell's own failure is not reported here — it rejects instead.
|
|
583
621
|
*/
|
|
584
622
|
readonly onError: (error: mixed) => void,
|
|
623
|
+
/**
|
|
624
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
625
|
+
* tag — before it goes out.
|
|
626
|
+
*
|
|
627
|
+
* For `uf dev`, and only for it. Vite's `transformIndexHtml` rewrites asset
|
|
628
|
+
* URLs and injects `/@vite/client` and the refresh preamble, and it is a
|
|
629
|
+
* *whole document* hook, so the development server used to collect the page
|
|
630
|
+
* and transform it at the end. That made the one place a developer would
|
|
631
|
+
* notice streaming the one place it did not happen: a slow page showed
|
|
632
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
633
|
+
* See ubugeeei-prod/uf#374.
|
|
634
|
+
*
|
|
635
|
+
* The hook never sees application body markup, which is what makes this
|
|
636
|
+
* safe. Vite's injections are string-based against `<head>` and `<body>`,
|
|
637
|
+
* and its parser accepts a document that stops after the body start tag.
|
|
638
|
+
* It does not accept a React chunk that ends in the middle of an attribute.
|
|
639
|
+
*
|
|
640
|
+
* Absent everywhere else. `uf start`, `uf preview` and every deploy adapter
|
|
641
|
+
* have no such hook and stream already.
|
|
642
|
+
*/
|
|
643
|
+
readonly transformHead?: (html: string) => Promise<string>,
|
|
644
|
+
/**
|
|
645
|
+
* Told what left, in what order, and what each chunk built.
|
|
646
|
+
*
|
|
647
|
+
* For `uf dev`, like `transformHead` above, and absent everywhere else —
|
|
648
|
+
* which is what makes it free rather than cheap: with nothing supplied no
|
|
649
|
+
* recorder is constructed, `./inspector.js` is never entered, and the
|
|
650
|
+
* generator a host consumes is the same object it was. See that module for
|
|
651
|
+
* what is done with the record and why it is a development affordance rather
|
|
652
|
+
* than a metric.
|
|
653
|
+
*
|
|
654
|
+
* Called once per document, after the last chunk and also after a render that
|
|
655
|
+
* was abandoned partway. `prerenderDocument` never calls it: a build resolves
|
|
656
|
+
* everything before it writes a byte, so there is no order to report.
|
|
657
|
+
*/
|
|
658
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
585
659
|
|};
|
|
586
660
|
|
|
661
|
+
/**
|
|
662
|
+
* `chunks`, recorded on the way out when `uf dev` asked for it.
|
|
663
|
+
*
|
|
664
|
+
* The wrapping goes here rather than around `assembled` in each of the callers
|
|
665
|
+
* so that what is recorded is unambiguous: these are the bytes the host is
|
|
666
|
+
* handed, head and all, and not React's own output on the way past.
|
|
667
|
+
*/
|
|
668
|
+
function outgoing(
|
|
669
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
670
|
+
onStream?: (record: StreamRecord) => void,
|
|
671
|
+
): AsyncGenerator<string, void, void> {
|
|
672
|
+
return onStream == null ? chunks : inspected(chunks, onStream, () => performance.now());
|
|
673
|
+
}
|
|
674
|
+
|
|
587
675
|
/**
|
|
588
676
|
* Stream `node` as a document, resolving once the shell is ready.
|
|
589
677
|
*
|
|
@@ -601,7 +689,15 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
601
689
|
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(node, {
|
|
602
690
|
onShellReady() {
|
|
603
691
|
pipe(queueDestination(queue));
|
|
604
|
-
resolve(
|
|
692
|
+
resolve(
|
|
693
|
+
bodyOf(
|
|
694
|
+
outgoing(
|
|
695
|
+
assembled(queue.chunks(), options.shell, options.transformHead),
|
|
696
|
+
options.onStream,
|
|
697
|
+
),
|
|
698
|
+
() => abort(),
|
|
699
|
+
),
|
|
700
|
+
);
|
|
605
701
|
},
|
|
606
702
|
onShellError(error: mixed) {
|
|
607
703
|
reject(error);
|
|
@@ -661,9 +757,15 @@ export function renderWithReadableStream(
|
|
|
661
757
|
const controller = new AbortController();
|
|
662
758
|
return render(node, { onError: options.onError, signal: controller.signal }).then(
|
|
663
759
|
(stream: ByteSource) =>
|
|
664
|
-
bodyOf(
|
|
665
|
-
|
|
666
|
-
|
|
760
|
+
bodyOf(
|
|
761
|
+
outgoing(
|
|
762
|
+
assembled(decoded(stream), options.shell, options.transformHead),
|
|
763
|
+
options.onStream,
|
|
764
|
+
),
|
|
765
|
+
() => {
|
|
766
|
+
controller.abort();
|
|
767
|
+
},
|
|
768
|
+
),
|
|
667
769
|
);
|
|
668
770
|
}
|
|
669
771
|
|
|
@@ -712,7 +814,9 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
712
814
|
typeof ReactDOMStatic.prerenderToNodeStream === "function"
|
|
713
815
|
? await ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
714
816
|
: await ReactDOMStatic.prerender(node, settings);
|
|
715
|
-
return bodyOf(
|
|
817
|
+
return bodyOf(
|
|
818
|
+
assembled(preludeChunks(result.prelude), options.shell, options.transformHead),
|
|
819
|
+
).text();
|
|
716
820
|
}
|
|
717
821
|
|
|
718
822
|
/**
|
package/middleware.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Middleware: what runs before a path answers, whatever answers it.
|
|
4
4
|
//
|
|
5
|
-
// `app/dashboard
|
|
5
|
+
// `app/dashboard/$middleware.js` guards `/dashboard` and everything under
|
|
6
6
|
// it — the pages, the route handlers, and the paths under it that match
|
|
7
7
|
// nothing at all. There is no `matcher` to write because the directory the
|
|
8
8
|
// file sits in *is* the matcher, which is the same composition rule layouts
|
|
9
9
|
// already use and the reason uf does not inherit Next's regular expressions.
|
|
10
10
|
//
|
|
11
|
-
// // app/dashboard
|
|
11
|
+
// // app/dashboard/$middleware.js
|
|
12
12
|
// // @flow
|
|
13
13
|
// import { cookies } from "@uniflowed/server";
|
|
14
14
|
//
|
|
@@ -167,7 +167,7 @@ export function createMiddlewareRunner(options: {|
|
|
|
167
167
|
*
|
|
168
168
|
* `default` or `middleware`, the same two spellings a page offers for its
|
|
169
169
|
* component. Anything else is an authoring mistake and throws rather than
|
|
170
|
-
* being skipped: a file named
|
|
170
|
+
* being skipped: a file named `$middleware.js` that the router quietly
|
|
171
171
|
* ignored is the bug this whole module exists to stop happening.
|
|
172
172
|
*/
|
|
173
173
|
function pick(module: MiddlewareModule, file: string): Middleware {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.20",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,14 +26,15 @@
|
|
|
26
26
|
"index.js",
|
|
27
27
|
"internal",
|
|
28
28
|
"middleware.js",
|
|
29
|
-
"server.js"
|
|
29
|
+
"server.js",
|
|
30
|
+
"!*.test.js"
|
|
30
31
|
],
|
|
31
32
|
"peerDependencies": {
|
|
32
33
|
"react": ">=19",
|
|
33
34
|
"react-dom": ">=19"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
37
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
37
|
+
"@uniflowed/hooks": "0.0.0-alpha.20",
|
|
38
|
+
"@uniflowed/server": "0.0.0-alpha.20"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/server.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// They were one function with `renderToString` behind it, which answered the
|
|
20
20
|
// first question by giving up on it: nothing streamed, so nothing could
|
|
21
|
-
// usefully suspend, so
|
|
21
|
+
// usefully suspend, so `$loading.js` had nothing to be. Making the split
|
|
22
22
|
// explicit is the point of ubugeeei-prod/uf#254 rather than a side effect —
|
|
23
23
|
// `internal/stream.js` holds the mechanics and says which React renderer serves
|
|
24
24
|
// which.
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
resolveMatch,
|
|
47
47
|
} from "./internal/runtime.js";
|
|
48
48
|
|
|
49
|
+
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
50
|
+
|
|
49
51
|
/** Asset URLs to reference from the document. */
|
|
50
52
|
export type RenderAssets = {|
|
|
51
53
|
readonly scripts: $ReadOnlyArray<string>,
|
|
@@ -112,6 +114,63 @@ export type RenderOptions = {|
|
|
|
112
114
|
* bytes. `uf dev` reports them in the terminal; a production host logs them.
|
|
113
115
|
*/
|
|
114
116
|
readonly onError?: (error: mixed) => void,
|
|
117
|
+
/**
|
|
118
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
119
|
+
* tag — before it goes out.
|
|
120
|
+
*
|
|
121
|
+
* For `uf dev` and nothing else. Vite's `transformIndexHtml` injects
|
|
122
|
+
* `/@vite/client` and the refresh preamble and rewrites asset URLs, and it
|
|
123
|
+
* is a *whole document* hook, so the development server used to collect the
|
|
124
|
+
* page and transform it at the end. That made the one place a developer
|
|
125
|
+
* would notice streaming the one place it did not happen: a slow page showed
|
|
126
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
127
|
+
* See ubugeeei-prod/uf#374.
|
|
128
|
+
*
|
|
129
|
+
* A production host passes nothing here and streams as it always did.
|
|
130
|
+
*
|
|
131
|
+
* # What a plugin that injects into the body gets
|
|
132
|
+
*
|
|
133
|
+
* `transformIndexHtml` is a whole-document hook and this hands it only the
|
|
134
|
+
* parseable opening of the document. Measured against Vite 8.2.2, injecting
|
|
135
|
+
* all four positions into a whole document and into the streamed opening:
|
|
136
|
+
*
|
|
137
|
+
* | `injectTo` | whole document | streamed |
|
|
138
|
+
* | -------------- | ------------------- | -------- |
|
|
139
|
+
* | `head-prepend` | after `<head>` | same |
|
|
140
|
+
* | `head` | before `</head>` | same |
|
|
141
|
+
* | `body-prepend` | after `<body>` | same |
|
|
142
|
+
* | `body` | before `</body>` | after `<body>` |
|
|
143
|
+
*
|
|
144
|
+
* Nothing is dropped — every tag still reaches the document — but a `body`
|
|
145
|
+
* tag lands at the top of the body rather than after the content, because
|
|
146
|
+
* the content is deliberately not passed to the hook. That keeps Vite's
|
|
147
|
+
* parser away from chunk boundaries that may sit inside an attribute.
|
|
148
|
+
*
|
|
149
|
+
* uf's own injections are `head` and `head-prepend`, and Vite's client is
|
|
150
|
+
* head-injected, so this is about a third-party plugin.
|
|
151
|
+
* `packages/vite/dev-head-transform.test.js` pins the table above, so the day
|
|
152
|
+
* it changes is a failing test rather than a surprise.
|
|
153
|
+
*/
|
|
154
|
+
readonly transformHead?: (html: string) => Promise<string>,
|
|
155
|
+
/**
|
|
156
|
+
* Told, in words, when a document streamed differently than it did last time.
|
|
157
|
+
*
|
|
158
|
+
* For `uf dev` and nothing else, like `transformHead` above. It answers the
|
|
159
|
+
* half of ubugeeei-prod/uf#520 that is about the wire — what arrived, in what
|
|
160
|
+
* order, and which part of the tree each chunk built — for the stream uf has
|
|
161
|
+
* today, which is a document whose Suspense boundaries resolve independently.
|
|
162
|
+
* `internal/inspector.js` is what it is and what it deliberately is not.
|
|
163
|
+
*
|
|
164
|
+
* A host that passes nothing here records nothing: no recorder is
|
|
165
|
+
* constructed, and the chunks a production stream yields are untouched.
|
|
166
|
+
*
|
|
167
|
+
* It is handed a message and its detail lines rather than the record they
|
|
168
|
+
* came from, because the caller is `@uniflowed/vite` — plain JavaScript, run
|
|
169
|
+
* by Vite before any Flow transform exists, which is why `DEVTOOLS_HOOK` and
|
|
170
|
+
* `DIAGNOSTIC_ENDPOINT` are spelled twice rather than imported. The
|
|
171
|
+
* vocabulary of the report belongs on this side of that line.
|
|
172
|
+
*/
|
|
173
|
+
readonly onStream?: (diagnostic: StreamDiagnostic) => void,
|
|
115
174
|
|};
|
|
116
175
|
|
|
117
176
|
/** The two ids the server writes and the client reads. */
|
|
@@ -263,6 +322,11 @@ export function createRenderer(options: {|
|
|
|
263
322
|
}
|
|
264
323
|
let resolved: ResolvedRoute = resolution.route;
|
|
265
324
|
const report = settings?.onError ?? (() => {});
|
|
325
|
+
// Built once and shared by both renders below, so a page that threw its
|
|
326
|
+
// shell away and rendered its error boundary instead reports the stream the
|
|
327
|
+
// browser was actually sent rather than the one that was abandoned.
|
|
328
|
+
const send = settings?.onStream;
|
|
329
|
+
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
266
330
|
|
|
267
331
|
// React reports an exception to `onError` *and*, if it was in the shell, to
|
|
268
332
|
// `onShellError` — so forwarding both would tell the host about one failure
|
|
@@ -285,6 +349,8 @@ export function createRenderer(options: {|
|
|
|
285
349
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
286
350
|
shell: shellFor(assets),
|
|
287
351
|
onError,
|
|
352
|
+
transformHead: settings?.transformHead,
|
|
353
|
+
onStream,
|
|
288
354
|
});
|
|
289
355
|
streaming = true;
|
|
290
356
|
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
@@ -315,6 +381,8 @@ export function createRenderer(options: {|
|
|
|
315
381
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
316
382
|
shell: shellFor(assets),
|
|
317
383
|
onError,
|
|
384
|
+
transformHead: settings?.transformHead,
|
|
385
|
+
onStream,
|
|
318
386
|
});
|
|
319
387
|
}
|
|
320
388
|
|
|
@@ -422,6 +490,36 @@ function redirectDocument(error: RedirectError): RenderResult {
|
|
|
422
490
|
* carried two of them, one in each place, and only one was where a browser
|
|
423
491
|
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
424
492
|
*/
|
|
493
|
+
/**
|
|
494
|
+
* The document a single-page build writes, and the only one it writes.
|
|
495
|
+
*
|
|
496
|
+
* `app.rendering.modes: ["csr"]` renders no route at build time: the client
|
|
497
|
+
* router resolves and renders every one of them in the browser, so what the
|
|
498
|
+
* build has to leave behind is the *chrome* — the stylesheets, the module
|
|
499
|
+
* script, and the empty root the client renders into. That is exactly
|
|
500
|
+
* [`shellFor`]'s three strings with nothing between them, which is why this is
|
|
501
|
+
* three concatenations rather than a fourth shape of document to keep in step
|
|
502
|
+
* with the other three.
|
|
503
|
+
*
|
|
504
|
+
* No React runs. There is nothing to render: no URL has been asked for, and
|
|
505
|
+
* whatever this document is served for is decided by the host rather than by
|
|
506
|
+
* this build.
|
|
507
|
+
*
|
|
508
|
+
* # What it costs, said here because it is not visible from the file
|
|
509
|
+
*
|
|
510
|
+
* The document has no `<title>`, no `<meta name="description">` and no content.
|
|
511
|
+
* A crawler that runs no JavaScript sees an empty page for **every** URL, and a
|
|
512
|
+
* reader sees nothing until the bundle has loaded and the route has resolved.
|
|
513
|
+
* That is what a single-page application is, and it is why `modes: ["csr"]` is
|
|
514
|
+
* a declaration a project makes rather than something a build falls back to.
|
|
515
|
+
* A project that wants a document per route has `ssg`, and one that wants a
|
|
516
|
+
* document per request has `ssr`.
|
|
517
|
+
*/
|
|
518
|
+
export function shellDocument(assets: RenderAssets): string {
|
|
519
|
+
const shell = shellFor(assets);
|
|
520
|
+
return `${shell.open}${shell.body}${shell.close}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
425
523
|
function shellFor(assets: RenderAssets): DocumentShell {
|
|
426
524
|
const head = headTags(assets);
|
|
427
525
|
return {
|