@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.37
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 +24 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +89 -19
- package/internal/server-route.js +6 -0
- package/internal/shell.js +107 -0
- package/internal/stream.js +67 -7
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +117 -0
- package/rsc-ssr.js +432 -0
- package/rsc.js +6 -0
- package/server-components.js +4 -0
- package/server.js +10 -483
package/internal/runtime.js
CHANGED
|
@@ -74,9 +74,9 @@ import {
|
|
|
74
74
|
import { BoundaryReporter } from "./boundaries.js";
|
|
75
75
|
import { routeBoundaries } from "./boundary-data.js";
|
|
76
76
|
import { composeRoute, pageComponent } from "./compose.js";
|
|
77
|
-
import { type FetchedFlight,
|
|
78
|
-
import { type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
77
|
+
import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
79
78
|
import { Head } from "./head.js";
|
|
79
|
+
import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
|
|
80
80
|
import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
|
|
81
81
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
82
82
|
import {
|
|
@@ -137,6 +137,11 @@ export type {
|
|
|
137
137
|
|
|
138
138
|
export { resolveFailure, resolveMatch } from "./resolve.js";
|
|
139
139
|
|
|
140
|
+
// `app.router.basePath` and `trailingSlash`, installed by the entry that starts
|
|
141
|
+
// the application; see `./base-path.js`.
|
|
142
|
+
export type { RoutingSettings, TrailingSlash } from "./base-path.js";
|
|
143
|
+
export { basePath, installRouting } from "./base-path.js";
|
|
144
|
+
|
|
140
145
|
// ---------------------------------------------------------------------------
|
|
141
146
|
// View transitions
|
|
142
147
|
// ---------------------------------------------------------------------------
|
|
@@ -397,6 +402,40 @@ export function navigationMode(): Navigation {
|
|
|
397
402
|
return installedNavigation;
|
|
398
403
|
}
|
|
399
404
|
|
|
405
|
+
/**
|
|
406
|
+
* How a page that React Server Components rendered fetches the next route's
|
|
407
|
+
* payload. `hydrateFlight` in `../rsc-client.js` installs it.
|
|
408
|
+
*
|
|
409
|
+
* Handed in rather than imported, because this module is in every
|
|
410
|
+
* application's bundle: one rendered from its modules, a single-page one, and
|
|
411
|
+
* the server's. The fetch reads its answer with React's Flight client,
|
|
412
|
+
* `react-server-dom-parcel`, which only an application that renders Server
|
|
413
|
+
* Components installs, and which needs React 19.3 while the rest of the router
|
|
414
|
+
* runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
|
|
415
|
+
* shown, whether or not anything calls it, so an import here would put that
|
|
416
|
+
* package in every one of those bundles, or fail the build where it is absent.
|
|
417
|
+
*/
|
|
418
|
+
let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
|
|
419
|
+
|
|
420
|
+
/** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
|
|
421
|
+
export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
|
|
422
|
+
installedFlightFetch = fetcher;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The next route's payload, through the fetch `hydrateFlight` installed. */
|
|
426
|
+
function fetchFlight(url: string): Promise<FetchedFlight> {
|
|
427
|
+
if (installedFlightFetch == null) {
|
|
428
|
+
return Promise.reject(
|
|
429
|
+
new Error(
|
|
430
|
+
"@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
|
|
431
|
+
"installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
|
|
432
|
+
"installs it before it hydrates, so an entry that hydrates a payload has to call that.",
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return installedFlightFetch(url);
|
|
437
|
+
}
|
|
438
|
+
|
|
400
439
|
/** Register the generated route table. Called once by the client and server entries. */
|
|
401
440
|
export function installRoutes(table: RouteTable): void {
|
|
402
441
|
installedTable = table;
|
|
@@ -585,8 +624,12 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
585
624
|
if (!isBrowser()) {
|
|
586
625
|
return;
|
|
587
626
|
}
|
|
588
|
-
const target = new URL(to, window.location.href);
|
|
589
|
-
|
|
627
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
628
|
+
// The application path the route table is asked about, and the address the
|
|
629
|
+
// history entry keeps: one URL, with and without `app.router.basePath`.
|
|
630
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
631
|
+
const next = (applicationPath ?? target.pathname) + target.search;
|
|
632
|
+
const address = target.pathname + target.search;
|
|
590
633
|
// The browser's job in this application. `assign` and `replace` rather
|
|
591
634
|
// than the history API, because the point is a document request: the
|
|
592
635
|
// history entry, the scroll position, the `Referer` and the unload
|
|
@@ -605,8 +648,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
605
648
|
// screen that intercepts the URL renders a page of its own, so whether the
|
|
606
649
|
// URL's ordinary page is in this bundle — the paragraph below — is not a
|
|
607
650
|
// question this navigation has to ask.
|
|
651
|
+
// An address outside the base path is not this application's to render.
|
|
652
|
+
if (applicationPath == null) {
|
|
653
|
+
window.location.assign(target.href);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
608
656
|
const origin = beneath(shown.current);
|
|
609
|
-
const intercepting = interceptingRoutes(origin.slots,
|
|
657
|
+
const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
|
|
610
658
|
// The half of the split that is not about bytes. A route whose page is not
|
|
611
659
|
// in this bundle is not a route this router can render, and pretending
|
|
612
660
|
// otherwise is the silent break: the navigation would resolve to nothing
|
|
@@ -615,7 +663,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
615
663
|
// link does when there is no JavaScript at all, and what the anchor
|
|
616
664
|
// `Link` renders would have done on its own.
|
|
617
665
|
if (!intercepting) {
|
|
618
|
-
const matched = matchRoute(routeTable().routes,
|
|
666
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
619
667
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
620
668
|
window.location.assign(target.href);
|
|
621
669
|
return;
|
|
@@ -631,9 +679,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
631
679
|
// entry is written the way it always was.
|
|
632
680
|
const state = historyStateFor(nextResolved);
|
|
633
681
|
if (options?.replace === true) {
|
|
634
|
-
window.history.replaceState(state, "",
|
|
682
|
+
window.history.replaceState(state, "", address + target.hash);
|
|
635
683
|
} else {
|
|
636
|
-
window.history.pushState(state, "",
|
|
684
|
+
window.history.pushState(state, "", address + target.hash);
|
|
637
685
|
}
|
|
638
686
|
const commit = () => {
|
|
639
687
|
show(nextResolved);
|
|
@@ -698,7 +746,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
698
746
|
});
|
|
699
747
|
};
|
|
700
748
|
const onPopState = () => {
|
|
701
|
-
const next =
|
|
749
|
+
const next =
|
|
750
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
751
|
+
window.location.search;
|
|
702
752
|
// Back or forward into an entry an interception wrote: the page it was
|
|
703
753
|
// intercepted from, with the interception over it again. That page is
|
|
704
754
|
// resolved afresh only when it is not already the one underneath, so
|
|
@@ -723,7 +773,10 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
723
773
|
// Back into a route this bundle has no page for. The history entry is
|
|
724
774
|
// already the browser's — it moved before this listener ran — so the
|
|
725
775
|
// document that belongs to it is what has to be fetched.
|
|
726
|
-
const matched = matchRoute(
|
|
776
|
+
const matched = matchRoute(
|
|
777
|
+
routeTable().routes,
|
|
778
|
+
applicationPathOf(window.location.pathname) ?? window.location.pathname,
|
|
779
|
+
);
|
|
727
780
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
728
781
|
window.location.reload();
|
|
729
782
|
return;
|
|
@@ -748,11 +801,15 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
748
801
|
if (!isBrowser() || navigation === "document") {
|
|
749
802
|
return;
|
|
750
803
|
}
|
|
751
|
-
const target = new URL(to, window.location.href);
|
|
804
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
805
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
806
|
+
if (applicationPath == null) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
752
809
|
// What the next render will need is decided the way the navigation will
|
|
753
810
|
// decide it: a URL a slot on screen intercepts renders that slot's page,
|
|
754
811
|
// so that is the module worth having, and the page the URL names is not.
|
|
755
|
-
const intercepting = interceptingRoutes(beneath(shown.current).slots,
|
|
812
|
+
const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
|
|
756
813
|
if (intercepting.length > 0) {
|
|
757
814
|
await Promise.all(
|
|
758
815
|
intercepting.flatMap((route) => [
|
|
@@ -762,7 +819,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
762
819
|
);
|
|
763
820
|
return;
|
|
764
821
|
}
|
|
765
|
-
const matched = matchRoute(routeTable().routes,
|
|
822
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
766
823
|
const load = matched?.route.page;
|
|
767
824
|
if (matched == null || load == null) {
|
|
768
825
|
return;
|
|
@@ -791,7 +848,11 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
791
848
|
const interception = shown.current.interception;
|
|
792
849
|
const nextResolved =
|
|
793
850
|
interception == null
|
|
794
|
-
? await resolveMatch(
|
|
851
|
+
? await resolveMatch(
|
|
852
|
+
routeTable(),
|
|
853
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
854
|
+
window.location.search,
|
|
855
|
+
)
|
|
795
856
|
: ((await resolveInterception(
|
|
796
857
|
routeTable(),
|
|
797
858
|
await resolveMatch(
|
|
@@ -859,7 +920,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
859
920
|
if (!isBrowser()) {
|
|
860
921
|
return;
|
|
861
922
|
}
|
|
862
|
-
|
|
923
|
+
// A payload URL is an address, so it keeps the base path; the server takes
|
|
924
|
+
// it off.
|
|
925
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
863
926
|
const next = target.pathname + target.search;
|
|
864
927
|
// The browser's job in this application; `ModuleRouter` has the argument.
|
|
865
928
|
if (navigation === "document") {
|
|
@@ -884,7 +947,11 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
884
947
|
const nextRoot = await payload;
|
|
885
948
|
// The URL the payload came from, which is a redirect's target when the
|
|
886
949
|
// route redirected: the history entry is where the visitor ended up.
|
|
887
|
-
|
|
950
|
+
// In the trailing-slash policy's spelling: a payload URL names its
|
|
951
|
+
// document without the slash, and the history entry should be the
|
|
952
|
+
// address the server answers without a redirect.
|
|
953
|
+
const arrived = new URL(fetched.url, window.location.href);
|
|
954
|
+
const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
|
|
888
955
|
if (options?.replace === true) {
|
|
889
956
|
window.history.replaceState(null, "", landed);
|
|
890
957
|
} else {
|
|
@@ -966,7 +1033,7 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
966
1033
|
if (!isBrowser() || navigation === "document") {
|
|
967
1034
|
return;
|
|
968
1035
|
}
|
|
969
|
-
const target = new URL(to, window.location.href);
|
|
1036
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
970
1037
|
if (target.origin !== window.location.origin) {
|
|
971
1038
|
return;
|
|
972
1039
|
}
|
|
@@ -1532,7 +1599,7 @@ export component Link(
|
|
|
1532
1599
|
router.push(to, { replace, transition }).catch((error) => {
|
|
1533
1600
|
// A failed navigation falls back to the browser doing it.
|
|
1534
1601
|
console.error(error);
|
|
1535
|
-
window.location.assign(to);
|
|
1602
|
+
window.location.assign(addressOf(to));
|
|
1536
1603
|
});
|
|
1537
1604
|
};
|
|
1538
1605
|
|
|
@@ -1543,7 +1610,10 @@ export component Link(
|
|
|
1543
1610
|
return (
|
|
1544
1611
|
<a
|
|
1545
1612
|
{...rest}
|
|
1546
|
-
|
|
1613
|
+
// `to` is an application path; the anchor is the address, with the base
|
|
1614
|
+
// path in front and the trailing-slash policy's spelling, so a link that
|
|
1615
|
+
// works before hydration and for a right click goes where this one does.
|
|
1616
|
+
href={addressOf(to)}
|
|
1547
1617
|
className={className}
|
|
1548
1618
|
onClick={drives ? handleClick : onClick}
|
|
1549
1619
|
onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
|
package/internal/server-route.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
24
|
|
|
25
25
|
import type { RouteState } from "./flight.js";
|
|
26
|
+
import { requireServerComponentsReact } from "./react-version.js";
|
|
26
27
|
|
|
27
28
|
const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
|
|
28
29
|
|
|
@@ -38,8 +39,13 @@ export function withServerRoute<T>(route: RouteState, body: () => T): T {
|
|
|
38
39
|
* route is to call a router hook from a server module that is not being
|
|
39
40
|
* rendered by the router — a script, a route handler, a module evaluated at
|
|
40
41
|
* import time — and each of those is a mistake worth a sentence.
|
|
42
|
+
*
|
|
43
|
+
* The React version is checked first. On a React older than 19.3 the Flight
|
|
44
|
+
* renderer that would have put the hook inside a route refuses to start, so the
|
|
45
|
+
* sentence worth reading is that one, not "outside a route".
|
|
41
46
|
*/
|
|
42
47
|
export function serverRoute(caller: string): RouteState {
|
|
48
|
+
requireServerComponentsReact(`${caller}()`);
|
|
43
49
|
const route = storage.getStore();
|
|
44
50
|
if (route == null) {
|
|
45
51
|
throw new Error(
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the documents an HTML renderer answers with
|
|
4
|
+
// that are not a route's own markup — uf's shell around that markup, and a
|
|
5
|
+
// redirect.
|
|
6
|
+
//
|
|
7
|
+
// `../server.js` renders a route from its modules, and `../rsc-ssr.js` renders
|
|
8
|
+
// one from the payload React Server Components wrote. They are two entries so
|
|
9
|
+
// that the second, which loads React's Flight client, stays out of every bundle
|
|
10
|
+
// that renders no Server Component (ubugeeei-prod/uf#992). Both write the same
|
|
11
|
+
// documents around what they render, which is why those documents live here and
|
|
12
|
+
// not in either entry.
|
|
13
|
+
|
|
14
|
+
import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
|
|
15
|
+
import { addressOf } from "./base-path.js";
|
|
16
|
+
import { ROOT_ID } from "./document.js";
|
|
17
|
+
import type { RedirectError } from "./routing.js";
|
|
18
|
+
import { type DocumentShell, bodyOfText } from "./stream.js";
|
|
19
|
+
|
|
20
|
+
/** A redirect, as the finished document `prerender` answers with. */
|
|
21
|
+
export async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
|
|
22
|
+
return {
|
|
23
|
+
status: document.status,
|
|
24
|
+
headers: document.headers,
|
|
25
|
+
html: await document.text(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redirectDocument(error: RedirectError): RenderResult {
|
|
30
|
+
// Under `app.router.basePath`, and in the trailing-slash policy's spelling:
|
|
31
|
+
// `redirect("/sign-in")` names an application path, and a browser follows
|
|
32
|
+
// an address.
|
|
33
|
+
const address = addressOf(error.to);
|
|
34
|
+
const target = escapeAttribute(address);
|
|
35
|
+
// A document rather than an empty body, because a redirect is still an answer
|
|
36
|
+
// a browser may be shown; it goes through the same three methods as a
|
|
37
|
+
// rendered one so that a host has one shape to write, not two.
|
|
38
|
+
const body = bodyOfText(
|
|
39
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
status: error.permanent ? 308 : 307,
|
|
43
|
+
headers: { Location: address },
|
|
44
|
+
pipe: body.pipe,
|
|
45
|
+
stream: body.stream,
|
|
46
|
+
text: body.text,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The document uf writes around the app's markup.
|
|
52
|
+
*
|
|
53
|
+
* The same two shapes `assemble` chose between, decided from the same evidence
|
|
54
|
+
* — whether the markup opens with `<html>` — but stated up front instead of
|
|
55
|
+
* afterwards, because a stream has no "afterwards" in which to splice a head.
|
|
56
|
+
* An app whose root layout renders `<html>` owns the whole document and the
|
|
57
|
+
* client hydrates `document`, so uf contributes only the tags that go before
|
|
58
|
+
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
59
|
+
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
60
|
+
*
|
|
61
|
+
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
62
|
+
* everything either shape is made of is here, so what a uf document contains is
|
|
63
|
+
* still readable in one place.
|
|
64
|
+
*
|
|
65
|
+
* # Why the shell is three strings and not one
|
|
66
|
+
*
|
|
67
|
+
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
68
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
69
|
+
* itself, and here it wrote none — so with one string this shell closed its
|
|
70
|
+
* head before the app had rendered a byte, and every `og:` tag and the
|
|
71
|
+
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
72
|
+
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
73
|
+
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
74
|
+
* app's own markup. See ubugeeei-prod/uf#547.
|
|
75
|
+
*
|
|
76
|
+
* That is also why no `<title>` is written here any more. It was, from
|
|
77
|
+
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
78
|
+
* carried two of them, one in each place, and only one was where a browser
|
|
79
|
+
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
80
|
+
*/
|
|
81
|
+
export function shellFor(assets: RenderAssets): DocumentShell {
|
|
82
|
+
const head = headTags(assets);
|
|
83
|
+
return {
|
|
84
|
+
head,
|
|
85
|
+
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
86
|
+
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
87
|
+
close: `</div></body></html>\n`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function headTags(assets: RenderAssets): string {
|
|
92
|
+
let tags = "";
|
|
93
|
+
for (const href of assets.styles) {
|
|
94
|
+
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
95
|
+
}
|
|
96
|
+
for (const href of assets.preloads) {
|
|
97
|
+
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
98
|
+
}
|
|
99
|
+
for (const src of assets.scripts) {
|
|
100
|
+
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
101
|
+
}
|
|
102
|
+
return tags;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeAttribute(value: string): string {
|
|
106
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
107
|
+
}
|
package/internal/stream.js
CHANGED
|
@@ -851,7 +851,7 @@ function withPayload(
|
|
|
851
851
|
* A document's chunks, with the Flight payload it was rendered from written
|
|
852
852
|
* into it as it arrives.
|
|
853
853
|
*
|
|
854
|
-
*
|
|
854
|
+
* Four rules, and each is the answer to a way the obvious version is wrong.
|
|
855
855
|
*
|
|
856
856
|
* **Nothing before the head.** The first chunk this is handed is the whole
|
|
857
857
|
* opening of the document — `assembled` does not let one go until the head is
|
|
@@ -862,9 +862,20 @@ function withPayload(
|
|
|
862
862
|
* **Written as soon as it exists.** A payload row usually exists before the
|
|
863
863
|
* HTML rendered from it — React's client reads the row, then the boundary
|
|
864
864
|
* renders — so waiting for the next HTML chunk would put the browser's copy
|
|
865
|
-
* behind the markup it hydrates. Each HTML chunk
|
|
866
|
-
* payload is waiting, and a payload that arrives while the
|
|
867
|
-
* written then, without a chunk of HTML to follow.
|
|
865
|
+
* behind the markup it hydrates. Each HTML chunk that ends between elements is
|
|
866
|
+
* followed by whatever payload is waiting, and a payload that arrives while the
|
|
867
|
+
* HTML is idle there is written then, without a chunk of HTML to follow.
|
|
868
|
+
*
|
|
869
|
+
* **Only between elements.** React hands its HTML on through a fixed-size
|
|
870
|
+
* buffer, so a chunk can end anywhere: inside a tag, an attribute's value, a
|
|
871
|
+
* comment, a character reference or an inline `<script>`. A payload element
|
|
872
|
+
* written after such a chunk is not an element. It is part of the attribute,
|
|
873
|
+
* the comment or the text it landed in, the browser never reads it, and React's
|
|
874
|
+
* client closes the payload with rows missing, which is the "Connection closed"
|
|
875
|
+
* a page reports instead of hydrating. So a waiting payload goes out only where
|
|
876
|
+
* the HTML written so far ends between elements, which [`advanced`] follows
|
|
877
|
+
* from chunk to chunk, and otherwise waits for the HTML that finishes what is
|
|
878
|
+
* open.
|
|
868
879
|
*
|
|
869
880
|
* **`</body></html>` waits for the end of the payload.** The HTML can finish
|
|
870
881
|
* first — the last boundary's markup is rendered from rows that are already
|
|
@@ -917,12 +928,13 @@ async function* interleaved(
|
|
|
917
928
|
}
|
|
918
929
|
})();
|
|
919
930
|
|
|
920
|
-
|
|
931
|
+
// Nothing written yet, and nothing may go before the head.
|
|
932
|
+
let boundary: Boundary = NOTHING_WRITTEN;
|
|
921
933
|
let closing = "";
|
|
922
934
|
try {
|
|
923
935
|
let next = chunks.next();
|
|
924
936
|
while (true) {
|
|
925
|
-
if (
|
|
937
|
+
if (boundary.safe && written !== "") {
|
|
926
938
|
const out = written;
|
|
927
939
|
written = "";
|
|
928
940
|
yield out;
|
|
@@ -945,8 +957,8 @@ async function* interleaved(
|
|
|
945
957
|
}
|
|
946
958
|
if (text !== "") {
|
|
947
959
|
yield text;
|
|
960
|
+
boundary = advanced(boundary, text);
|
|
948
961
|
}
|
|
949
|
-
opened = true;
|
|
950
962
|
next = chunks.next();
|
|
951
963
|
}
|
|
952
964
|
while (!ended || written !== "") {
|
|
@@ -972,6 +984,54 @@ async function* interleaved(
|
|
|
972
984
|
}
|
|
973
985
|
}
|
|
974
986
|
|
|
987
|
+
/** Where the HTML written so far leaves the next payload element. */
|
|
988
|
+
type Boundary = {|
|
|
989
|
+
/** Whether a payload element may be written now. */
|
|
990
|
+
readonly safe: boolean,
|
|
991
|
+
/** The `script` or `style` element the HTML is inside, if it is inside one. */
|
|
992
|
+
readonly rawText: string | null,
|
|
993
|
+
/** A tag, or a comment, the HTML has started and not yet finished. */
|
|
994
|
+
readonly open: string,
|
|
995
|
+
|};
|
|
996
|
+
|
|
997
|
+
const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* `boundary`, once `html` has been written after it.
|
|
1001
|
+
*
|
|
1002
|
+
* A payload element may follow HTML that ends with a `>` outside a `<script>`
|
|
1003
|
+
* and a `<style>`. The test can be that short because React wrote the HTML: it
|
|
1004
|
+
* escapes `<` and `>` in text and in attribute values, so a `>` it wrote closes
|
|
1005
|
+
* a tag or a comment, and HTML that ends any other way ends inside one, or
|
|
1006
|
+
* inside a character reference or a run of text. What React does not escape is
|
|
1007
|
+
* the content of an inline script or stylesheet, where a `>` is code, so an
|
|
1008
|
+
* opening `<script>` or `<style>` is followed to its closing tag. A tag split
|
|
1009
|
+
* across two chunks is carried in `open` and read whole with the next one.
|
|
1010
|
+
*/
|
|
1011
|
+
function advanced(boundary: Boundary, html: string): Boundary {
|
|
1012
|
+
const text = boundary.open + html;
|
|
1013
|
+
let rawText = boundary.rawText;
|
|
1014
|
+
const tags = /<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
|
|
1015
|
+
let tag = tags.exec(text);
|
|
1016
|
+
while (tag != null) {
|
|
1017
|
+
const name = tag[2].toLowerCase();
|
|
1018
|
+
if (tag[1] === "/") {
|
|
1019
|
+
if (rawText === name) {
|
|
1020
|
+
rawText = null;
|
|
1021
|
+
}
|
|
1022
|
+
} else if (rawText == null) {
|
|
1023
|
+
rawText = name;
|
|
1024
|
+
}
|
|
1025
|
+
tag = tags.exec(text);
|
|
1026
|
+
}
|
|
1027
|
+
const start = text.lastIndexOf("<");
|
|
1028
|
+
return {
|
|
1029
|
+
safe: rawText == null && text.endsWith(">"),
|
|
1030
|
+
rawText,
|
|
1031
|
+
open: start > text.lastIndexOf(">") ? text.slice(start) : "",
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
975
1035
|
/**
|
|
976
1036
|
* The prelude of a static prerender, whichever stream this build produced.
|
|
977
1037
|
*
|