@uniflowed/router 0.0.0-alpha.8 → 0.1.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/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1597 -1329
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: `app.router.basePath` and
|
|
4
|
+
// `app.router.trailingSlash`, as the router reads them.
|
|
5
|
+
//
|
|
6
|
+
// The route table has no base in it — `/guide` is `/guide` whether the site is
|
|
7
|
+
// served at `/` or at `/docs` — and the browser's address bar has one. This
|
|
8
|
+
// module is the one place those two spellings meet: a `Link` and a navigation
|
|
9
|
+
// turn an application path into the address, and hydration, `popstate` and a
|
|
10
|
+
// refresh turn the address back into an application path before the table is
|
|
11
|
+
// asked. Every other part of the router speaks application paths.
|
|
12
|
+
//
|
|
13
|
+
// The policy decides the spelling of a path a link writes, so that a page is
|
|
14
|
+
// linked by the address its server answers without a redirect. The server's
|
|
15
|
+
// half — the `308` for the other spelling, and the `404` outside the base — is
|
|
16
|
+
// `@uniflowed/server`'s `internal/routing.js`.
|
|
17
|
+
//
|
|
18
|
+
// # Installed, like navigation
|
|
19
|
+
//
|
|
20
|
+
// Module state beside `installNavigation`, set once by the entry that started
|
|
21
|
+
// the application: `virtual:uf/client` in the browser and `virtual:uf/server`
|
|
22
|
+
// in the graph that renders documents. `@uniflowed/vite` writes both calls from
|
|
23
|
+
// `uf.config.js`, so a dev server and a build link the same way. A test that
|
|
24
|
+
// installs nothing gets the root and `"ignore"`, which is what every
|
|
25
|
+
// application had before the setting existed.
|
|
26
|
+
|
|
27
|
+
/** Which spelling of a path is the page; see `app.router.trailingSlash`. */
|
|
28
|
+
export type TrailingSlash = "never" | "always" | "ignore";
|
|
29
|
+
|
|
30
|
+
/** What an entry installs. */
|
|
31
|
+
export type RoutingSettings = {|
|
|
32
|
+
readonly basePath?: string,
|
|
33
|
+
readonly trailingSlash?: TrailingSlash,
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
let installedBase: string = "";
|
|
37
|
+
let installedSlash: TrailingSlash = "ignore";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Say where this application is served and how its paths are spelled. Called
|
|
41
|
+
* once, by the entry that starts it.
|
|
42
|
+
*/
|
|
43
|
+
export function installRouting(settings: RoutingSettings): void {
|
|
44
|
+
installedBase = normalizeBase(settings.basePath ?? "");
|
|
45
|
+
installedSlash = settings.trailingSlash ?? "ignore";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The path this application is served under: `""` at the root, `"/docs"`
|
|
50
|
+
* otherwise.
|
|
51
|
+
*
|
|
52
|
+
* For code that builds an absolute address the router did not build for it —
|
|
53
|
+
* a middleware's `Response.redirect(new URL(`${basePath()}/sign-in`, request.url))`
|
|
54
|
+
* — because a route handler and a middleware are handed the application path,
|
|
55
|
+
* and an address built from `/sign-in` alone leaves the base behind.
|
|
56
|
+
*/
|
|
57
|
+
export function basePath(): string {
|
|
58
|
+
return installedBase;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How this application spells a path. */
|
|
62
|
+
export function trailingSlash(): TrailingSlash {
|
|
63
|
+
return installedSlash;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The address for an application path: the base in front, the policy's
|
|
68
|
+
* spelling, the query and the fragment kept.
|
|
69
|
+
*
|
|
70
|
+
* Only a path that starts with a single `/` is an application path. Anything
|
|
71
|
+
* else — `https://…`, `//cdn…`, `mailto:`, `?page=2`, `#top`, `../up` — is
|
|
72
|
+
* returned as it was written, because the browser resolves it against the
|
|
73
|
+
* address that is already there.
|
|
74
|
+
*/
|
|
75
|
+
export function addressOf(to: string): string {
|
|
76
|
+
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
77
|
+
return to;
|
|
78
|
+
}
|
|
79
|
+
const { path, rest } = splitPath(to);
|
|
80
|
+
return `${installedBase}${spellPath(path, installedSlash, installedBase !== "")}${rest}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The application path for an address's pathname, or `null` when the address
|
|
85
|
+
* is outside the base.
|
|
86
|
+
*
|
|
87
|
+
* `/docs/guide` is `/guide` under `/docs`, and `/docs` and `/docs/` are both
|
|
88
|
+
* `/`. `/docsx` is not under `/docs`: a base is whole segments.
|
|
89
|
+
*/
|
|
90
|
+
export function applicationPathOf(pathname: string): string | null {
|
|
91
|
+
if (installedBase === "") {
|
|
92
|
+
return pathname;
|
|
93
|
+
}
|
|
94
|
+
if (pathname === installedBase) {
|
|
95
|
+
return "/";
|
|
96
|
+
}
|
|
97
|
+
if (pathname.startsWith(`${installedBase}/`)) {
|
|
98
|
+
return pathname.slice(installedBase.length);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A browser pathname in this application's spelling: the base kept, the
|
|
105
|
+
* application path under it spelled by the policy. A pathname outside the
|
|
106
|
+
* base is returned as it was.
|
|
107
|
+
*
|
|
108
|
+
* For an address the router read back rather than wrote — the document path a
|
|
109
|
+
* payload URL names has lost its trailing slash, and the history entry a
|
|
110
|
+
* navigation writes should be the address the server answers without a
|
|
111
|
+
* redirect.
|
|
112
|
+
*/
|
|
113
|
+
export function canonicalAddress(pathname: string): string {
|
|
114
|
+
const application = applicationPathOf(pathname);
|
|
115
|
+
if (application == null) {
|
|
116
|
+
return pathname;
|
|
117
|
+
}
|
|
118
|
+
const spelled = spellPath(application, installedSlash, installedBase !== "");
|
|
119
|
+
return `${installedBase}${spelled}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `path` in `policy`'s spelling.
|
|
124
|
+
*
|
|
125
|
+
* The root of an application at the root is `/` whatever the policy says. The
|
|
126
|
+
* root of an application under a base is the base itself — `/docs` — unless
|
|
127
|
+
* the policy is `"always"`, which spells it `/docs/`. A path whose last segment
|
|
128
|
+
* looks like a file keeps what it was written with, because `/robots.txt/` is
|
|
129
|
+
* not a page.
|
|
130
|
+
*/
|
|
131
|
+
export function spellPath(path: string, policy: TrailingSlash, underBase: boolean): string {
|
|
132
|
+
let end = path.length;
|
|
133
|
+
while (end > 0 && path.charCodeAt(end - 1) === 47) {
|
|
134
|
+
end -= 1;
|
|
135
|
+
}
|
|
136
|
+
const trimmed = path.slice(0, end);
|
|
137
|
+
if (trimmed === "") {
|
|
138
|
+
return policy === "always" || !underBase ? "/" : "";
|
|
139
|
+
}
|
|
140
|
+
if (policy === "ignore" || looksLikeAFile(trimmed)) {
|
|
141
|
+
return path;
|
|
142
|
+
}
|
|
143
|
+
return policy === "always" ? `${trimmed}/` : trimmed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether a path's last segment has an extension. */
|
|
147
|
+
function looksLikeAFile(path: string): boolean {
|
|
148
|
+
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
149
|
+
return last.includes(".");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function splitPath(to: string): {| readonly path: string, readonly rest: string |} {
|
|
153
|
+
let end = to.length;
|
|
154
|
+
for (let index = 0; index < to.length; index += 1) {
|
|
155
|
+
const code = to.charCodeAt(index);
|
|
156
|
+
// `?` and `#`.
|
|
157
|
+
if (code === 63 || code === 35) {
|
|
158
|
+
end = index;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { path: to.slice(0, end), rest: to.slice(end) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A base as `uf_config` accepts one, with a trailing slash forgiven: `""`,
|
|
167
|
+
* `"/"` and absent are the root.
|
|
168
|
+
*/
|
|
169
|
+
function normalizeBase(base: string): string {
|
|
170
|
+
let end = base.length;
|
|
171
|
+
while (end > 0 && base.charCodeAt(end - 1) === 47) {
|
|
172
|
+
end -= 1;
|
|
173
|
+
}
|
|
174
|
+
return base.slice(0, end);
|
|
175
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: which DOM subtree each boundary owns.
|
|
4
|
+
//
|
|
5
|
+
// An application has three boundaries a reader cannot see — Suspense, error,
|
|
6
|
+
// and client/server — and all three are decisions the build already made.
|
|
7
|
+
// ubugeeei-prod/uf#636 answered the third: `uf dev` says why a module is in the
|
|
8
|
+
// client bundle, out loud, at the moment the answer changes. This is the other
|
|
9
|
+
// two, and the gap it fills was named in that issue's triage: *the route table
|
|
10
|
+
// already has the data*. `$loading.js` nests and carries the number of
|
|
11
|
+
// layouts outside it, `$error.js` binds to the nearest ancestor, and
|
|
12
|
+
// `RouteView` threads both into one stack. What did not exist is a way to point
|
|
13
|
+
// at the **DOM subtree** each of them owns.
|
|
14
|
+
//
|
|
15
|
+
// That cannot be read off the table, and it cannot be read off the page either.
|
|
16
|
+
// A `<Suspense>` renders no element of its own; nor does a class boundary. What
|
|
17
|
+
// is on the page is a run of nodes, in a parent that also holds whatever the
|
|
18
|
+
// layout above put beside them — a `<nav>` before, a `<footer>` after — and
|
|
19
|
+
// nothing distinguishes the run from its neighbours. So the render has to say
|
|
20
|
+
// so, which is what this module is.
|
|
21
|
+
//
|
|
22
|
+
// # The mechanism: a pair of inert marks, and why a pair
|
|
23
|
+
//
|
|
24
|
+
// Each boundary `RouteView` renders wraps its children between two
|
|
25
|
+
// `<span hidden data-uf-boundary>` elements. The `hidden` attribute keeps the
|
|
26
|
+
// marks out of layout and accessibility trees; the pair are siblings of the nodes between them,
|
|
27
|
+
// because React fragments create no element, so "what this boundary owns" is
|
|
28
|
+
// `open.nextElementSibling` up to `close`.
|
|
29
|
+
//
|
|
30
|
+
// One mark would have been cheaper and would have been wrong. A boundary's run
|
|
31
|
+
// ends where the enclosing layout's own trailing nodes begin, and from the
|
|
32
|
+
// opening mark alone those are indistinguishable — the walk would hand a
|
|
33
|
+
// boundary the footer underneath it. Two marks are the smallest thing that
|
|
34
|
+
// closes.
|
|
35
|
+
//
|
|
36
|
+
// A wrapper element was the other candidate: one node instead of two, and
|
|
37
|
+
// `wrapper.children` with no walk at all. It loses on the thing that matters
|
|
38
|
+
// here — a wrapper has to exist from the first render, because introducing one
|
|
39
|
+
// later moves the subtree into a new parent and React answers that by
|
|
40
|
+
// unmounting and rebuilding everything under it. Marks are siblings, so they
|
|
41
|
+
// can arrive after the page has settled, which is what the section below is
|
|
42
|
+
// about.
|
|
43
|
+
//
|
|
44
|
+
// # They arrive after hydration, which is what makes them free of it
|
|
45
|
+
//
|
|
46
|
+
// Every edge renders `null` until it has mounted. So the tree React hydrates
|
|
47
|
+
// against the server's markup contains no mark, the server's markup contains no
|
|
48
|
+
// mark, and the two agree whatever either side believed about being in
|
|
49
|
+
// development — the gate is allowed to answer differently in the two processes
|
|
50
|
+
// because by the time it has any effect, hydration is over. `reportDevtools`
|
|
51
|
+
// asks its question on the line after hydration for the same reason: a
|
|
52
|
+
// development affordance that can turn a working page into a mismatch is worse
|
|
53
|
+
// than no affordance.
|
|
54
|
+
//
|
|
55
|
+
// `marksAreLive` is what keeps that from costing a second commit forever. It is
|
|
56
|
+
// latched by the first edge to mount, and every edge mounted afterwards — a
|
|
57
|
+
// navigation, or a `$loading.js` that HMR has just added — starts live and
|
|
58
|
+
// is in the DOM in the same commit that created it. That matters for the report
|
|
59
|
+
// below, which reads the DOM in the commit where the boundaries changed.
|
|
60
|
+
//
|
|
61
|
+
// # Where the report goes
|
|
62
|
+
//
|
|
63
|
+
// The terminal, on `./diagnostics.js`, which is the channel #583 established
|
|
64
|
+
// and #636 argued for again: a diagnostic that exists only in a browser window
|
|
65
|
+
// has to be noticed by somebody who does not know to look. And it is quiet
|
|
66
|
+
// unless the answer *changed* — the first sighting of a route says nothing, the
|
|
67
|
+
// same boundaries on the same route say nothing, and adding an `$error.js`
|
|
68
|
+
// says where it landed and what it took over. A boundary map printed on every
|
|
69
|
+
// reload is the banner nobody reads.
|
|
70
|
+
//
|
|
71
|
+
// The marks themselves are the other half of "see it", and the cheaper half:
|
|
72
|
+
// they are in the document, so the element inspector already shows where each
|
|
73
|
+
// boundary opens and closes with no panel to open, and
|
|
74
|
+
// `document.querySelectorAll("[data-uf-boundary]")` is the whole API. For the
|
|
75
|
+
// page you are looking at right now there is `__ufBoundaries()`, which prints
|
|
76
|
+
// the same report on demand.
|
|
77
|
+
//
|
|
78
|
+
// # None of it is in a build
|
|
79
|
+
//
|
|
80
|
+
// Nothing here is reachable from a production bundle: `runtime.js` guards every
|
|
81
|
+
// reference with `BOUNDARY_MARKS`, which is `import.meta.hot != null` — the
|
|
82
|
+
// gate `client.js` already uses, replaced by `undefined` in a build — and this
|
|
83
|
+
// package is `sideEffects: false`, so with the references folded away the
|
|
84
|
+
// module is dropped rather than merely unused.
|
|
85
|
+
|
|
86
|
+
"use client";
|
|
87
|
+
|
|
88
|
+
import * as React from "react";
|
|
89
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
90
|
+
|
|
91
|
+
import { SYNTHESISED_SOURCE } from "./boundary-data.js";
|
|
92
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
93
|
+
import type { RouteBoundary } from "./boundary-data.js";
|
|
94
|
+
|
|
95
|
+
export type { BoundaryKind, RouteBoundary } from "./boundary-data.js";
|
|
96
|
+
export {
|
|
97
|
+
ROOT_ERROR_ID,
|
|
98
|
+
ROUTE_ERROR_ID,
|
|
99
|
+
SYNTHESISED_SOURCE,
|
|
100
|
+
routeBoundaries,
|
|
101
|
+
suspenseId,
|
|
102
|
+
} from "./boundary-data.js";
|
|
103
|
+
|
|
104
|
+
/** The attribute a mark carries its boundary's id in. */
|
|
105
|
+
export const BOUNDARY_ATTRIBUTE: string = "data-uf-boundary";
|
|
106
|
+
|
|
107
|
+
/** The attribute telling the two marks of one boundary apart. */
|
|
108
|
+
export const EDGE_ATTRIBUTE: string = "data-uf-boundary-edge";
|
|
109
|
+
|
|
110
|
+
/** The attribute naming the file a boundary was declared in, when one is known. */
|
|
111
|
+
export const SOURCE_ATTRIBUTE: string = "data-uf-boundary-source";
|
|
112
|
+
|
|
113
|
+
/** The name `uf dev` installs the on-demand report under. */
|
|
114
|
+
export const BOUNDARY_GLOBAL: string = "__ufBoundaries";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether an edge that mounts now should be in the DOM immediately.
|
|
118
|
+
*
|
|
119
|
+
* Latched by the first edge to mount and never cleared, and read through
|
|
120
|
+
* `useSyncExternalStore` rather than during the render body, which is the
|
|
121
|
+
* difference between "a value React asked for" and "a module variable a
|
|
122
|
+
* memoising compiler is entitled to hold on to".
|
|
123
|
+
*/
|
|
124
|
+
let marksAreLive = false;
|
|
125
|
+
|
|
126
|
+
/** The edges waiting to hear that marks have gone live. */
|
|
127
|
+
const liveListeners: Set<() => void> = new Set();
|
|
128
|
+
|
|
129
|
+
function subscribeToLiveMarks(listener: () => void): () => void {
|
|
130
|
+
liveListeners.add(listener);
|
|
131
|
+
return () => {
|
|
132
|
+
liveListeners.delete(listener);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function marksAreLiveNow(): boolean {
|
|
137
|
+
return marksAreLive;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a server rendered, and so what every hydrating edge renders: nothing. */
|
|
141
|
+
function noMarksOnTheServer(): boolean {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One end of one boundary.
|
|
147
|
+
*
|
|
148
|
+
* A hidden element and not a comment node, because React renders elements.
|
|
149
|
+
* This used to be a `<template>`, but React 19.3 reports template insertion
|
|
150
|
+
* during document-root hydration as a browser error. A `span hidden` carries
|
|
151
|
+
* the same marker data without entering layout or the accessibility tree.
|
|
152
|
+
*
|
|
153
|
+
* # Why the server snapshot, and not a first state
|
|
154
|
+
*
|
|
155
|
+
* An edge used to take `marksAreLive` as its first state, which is right only
|
|
156
|
+
* if every edge on a page hydrates in the same pass. Under React Server
|
|
157
|
+
* Components they do not: a client reference loads when the payload names it,
|
|
158
|
+
* so the part of the tree above it hydrates, commits and runs this effect
|
|
159
|
+
* first, and an edge that hydrates afterwards read `true` and rendered a mark
|
|
160
|
+
* the server never wrote — a hydration mismatch on every page with a boundary
|
|
161
|
+
* below a client component, under `uf dev` only. `useSyncExternalStore` hands a
|
|
162
|
+
* hydrating edge the server's answer whenever it hydrates, and an edge mounted
|
|
163
|
+
* by a navigation or by HMR the live one, in its own commit, as before.
|
|
164
|
+
*/
|
|
165
|
+
export component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
|
|
166
|
+
const live = useSyncExternalStore(subscribeToLiveMarks, marksAreLiveNow, noMarksOnTheServer);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (marksAreLive) return;
|
|
169
|
+
marksAreLive = true;
|
|
170
|
+
for (const listener of [...liveListeners]) listener();
|
|
171
|
+
}, []);
|
|
172
|
+
if (!live) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (edge === "close") {
|
|
176
|
+
return <span hidden data-uf-boundary={boundary.id} data-uf-boundary-edge="close" />;
|
|
177
|
+
}
|
|
178
|
+
return (
|
|
179
|
+
<span
|
|
180
|
+
hidden
|
|
181
|
+
data-uf-boundary={boundary.id}
|
|
182
|
+
data-uf-boundary-edge="open"
|
|
183
|
+
data-uf-boundary-source={boundary.source ?? undefined}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `children`, between the two marks of `boundary`.
|
|
190
|
+
*
|
|
191
|
+
* Kept importable from here, where the marks are, and written in
|
|
192
|
+
* `./compose.js`, where they are placed. This module is a client module — an
|
|
193
|
+
* edge has state and an effect — and a server composing a tree for React Server
|
|
194
|
+
* Components calls the factory rather than rendering it, so the factory has to
|
|
195
|
+
* live in a module that graph evaluates while the edges it places stay
|
|
196
|
+
* references to this one. See ubugeeei-prod/uf#519.
|
|
197
|
+
*/
|
|
198
|
+
export { insideBoundary } from "./compose.js";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* How far a walk between two marks will go before giving up.
|
|
202
|
+
*
|
|
203
|
+
* A closing mark is a sibling of its opening one and the run between them is a
|
|
204
|
+
* route's rendered output, so this is never reached by a page that is behaving.
|
|
205
|
+
* It is here because `docs/security.md` asks that a report have no unbounded
|
|
206
|
+
* anything in it, and because a DOM somebody else's script has been editing is
|
|
207
|
+
* exactly where an unbounded walk would be found.
|
|
208
|
+
*/
|
|
209
|
+
const WALK_LIMIT = 512;
|
|
210
|
+
|
|
211
|
+
/** How many owned elements one line of the report names before it counts them. */
|
|
212
|
+
const NAMED_LIMIT = 3;
|
|
213
|
+
|
|
214
|
+
/** One boundary, as the page has it. */
|
|
215
|
+
export type BoundaryFinding = {|
|
|
216
|
+
readonly boundary: RouteBoundary,
|
|
217
|
+
/** The top-level elements between its marks, as short selectors. */
|
|
218
|
+
readonly owns: $ReadOnlyArray<string>,
|
|
219
|
+
/** How many more there were than [`NAMED_LIMIT`]. */
|
|
220
|
+
readonly more: number,
|
|
221
|
+
/** False when the boundary rendered no marks — it is showing its fallback. */
|
|
222
|
+
readonly rendered: boolean,
|
|
223
|
+
|};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What each boundary owns on the page right now.
|
|
227
|
+
*
|
|
228
|
+
* Takes the document rather than reaching for a global, so a test can build one
|
|
229
|
+
* and ask — the same shape `hydrationReport` has, and for the same reason: the
|
|
230
|
+
* analysis worth checking is the one that runs in a browser, so the test has to
|
|
231
|
+
* be able to call exactly it.
|
|
232
|
+
*
|
|
233
|
+
* A boundary with no marks in the document is not missing, it is *suspended*:
|
|
234
|
+
* React removes a boundary's content while its fallback is up, and its marks
|
|
235
|
+
* are part of that content. Saying so is more useful than leaving it out.
|
|
236
|
+
*/
|
|
237
|
+
export function boundaryFindings(
|
|
238
|
+
boundaries: Map<string, RouteBoundary>,
|
|
239
|
+
document: Document,
|
|
240
|
+
): $ReadOnlyArray<BoundaryFinding> {
|
|
241
|
+
const findings: Array<BoundaryFinding> = [];
|
|
242
|
+
for (const boundary of boundaries.values()) {
|
|
243
|
+
const open = document.querySelector(
|
|
244
|
+
`[${BOUNDARY_ATTRIBUTE}="${boundary.id}"][${EDGE_ATTRIBUTE}="open"]`,
|
|
245
|
+
);
|
|
246
|
+
if (open == null) {
|
|
247
|
+
findings.push({ boundary, owns: [], more: 0, rendered: false });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const owned: Array<string> = [];
|
|
251
|
+
let steps = 0;
|
|
252
|
+
let node = open.nextElementSibling;
|
|
253
|
+
while (node != null && steps < WALK_LIMIT && !closes(node, boundary.id)) {
|
|
254
|
+
if (node.getAttribute(BOUNDARY_ATTRIBUTE) == null) {
|
|
255
|
+
owned.push(describeElement(node));
|
|
256
|
+
}
|
|
257
|
+
node = node.nextElementSibling;
|
|
258
|
+
steps += 1;
|
|
259
|
+
}
|
|
260
|
+
findings.push({
|
|
261
|
+
boundary,
|
|
262
|
+
owns: owned.slice(0, NAMED_LIMIT),
|
|
263
|
+
more: Math.max(0, owned.length - NAMED_LIMIT),
|
|
264
|
+
rendered: true,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return findings;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Whether `element` is the closing mark of `id`. */
|
|
271
|
+
function closes(element: Element, id: string): boolean {
|
|
272
|
+
return (
|
|
273
|
+
element.getAttribute(BOUNDARY_ATTRIBUTE) === id &&
|
|
274
|
+
element.getAttribute(EDGE_ATTRIBUTE) === "close"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* One element, short enough to sit in a line of a report.
|
|
280
|
+
*
|
|
281
|
+
* A CSS selector rather than a tag name, because a page has eleven `<div>`s and
|
|
282
|
+
* the one being named has to be findable: the id if it has one, and otherwise
|
|
283
|
+
* the first class, which is what a person would type into the inspector's
|
|
284
|
+
* search box. Nothing more — the report says which subtree, and the page says
|
|
285
|
+
* what is in it.
|
|
286
|
+
*/
|
|
287
|
+
export function describeElement(element: Element): string {
|
|
288
|
+
const tag = element.tagName.toLowerCase();
|
|
289
|
+
const id = element.getAttribute("id");
|
|
290
|
+
if (id != null && id !== "") {
|
|
291
|
+
return `${tag}#${id}`;
|
|
292
|
+
}
|
|
293
|
+
const className = element.getAttribute("class");
|
|
294
|
+
const first = className == null ? "" : className.trim().split(/\s+/)[0];
|
|
295
|
+
return first === "" ? tag : `${tag}.${first}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The report, as the terminal will print it.
|
|
300
|
+
*
|
|
301
|
+
* Separated from the sending so that a test can pin the wording, which is the
|
|
302
|
+
* part worth pinning: somebody reading this in a terminal has to be able to act
|
|
303
|
+
* on it without opening this file.
|
|
304
|
+
*/
|
|
305
|
+
export function formatBoundaries(
|
|
306
|
+
path: string,
|
|
307
|
+
findings: $ReadOnlyArray<BoundaryFinding>,
|
|
308
|
+
): {| readonly message: string, readonly detail: $ReadOnlyArray<string> |} {
|
|
309
|
+
const count = findings.length;
|
|
310
|
+
return {
|
|
311
|
+
message: `${count} ${count === 1 ? "boundary renders" : "boundaries render"} ${path}`,
|
|
312
|
+
detail: findings.map(describeFinding),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One boundary as one line: what it is, where it sits, and what it owns. */
|
|
317
|
+
function describeFinding(finding: BoundaryFinding): string {
|
|
318
|
+
const { boundary } = finding;
|
|
319
|
+
const kind = boundary.kind === "error" ? "error" : "suspense";
|
|
320
|
+
const source =
|
|
321
|
+
boundary.source == null
|
|
322
|
+
? ""
|
|
323
|
+
: boundary.source === SYNTHESISED_SOURCE
|
|
324
|
+
? " (uf's own error page)"
|
|
325
|
+
: ` (${boundary.source})`;
|
|
326
|
+
const where =
|
|
327
|
+
boundary.above === 0
|
|
328
|
+
? "outside every layout"
|
|
329
|
+
: `inside ${boundary.above} ${boundary.above === 1 ? "layout" : "layouts"}`;
|
|
330
|
+
if (!finding.rendered) {
|
|
331
|
+
return `${kind}${source}, ${where} — showing its fallback`;
|
|
332
|
+
}
|
|
333
|
+
if (finding.owns.length === 0) {
|
|
334
|
+
return `${kind}${source}, ${where} — owns no element of its own`;
|
|
335
|
+
}
|
|
336
|
+
const named = finding.owns.join(", ");
|
|
337
|
+
const more = finding.more === 0 ? "" : ` and ${finding.more} more`;
|
|
338
|
+
return `${kind}${source}, ${where} — owns ${named}${more}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* How many routes the "has this changed" memory keeps.
|
|
343
|
+
*
|
|
344
|
+
* A route table is finite and this is larger than any project's hot set, so the
|
|
345
|
+
* ceiling is `docs/security.md`'s rule rather than a policy about routes: a map
|
|
346
|
+
* a page can grow by navigating is a map with a bound.
|
|
347
|
+
*/
|
|
348
|
+
const MEMORY_LIMIT = 64;
|
|
349
|
+
|
|
350
|
+
/** The last boundary set seen for each route path. */
|
|
351
|
+
const seen: Map<string, string> = new Map();
|
|
352
|
+
|
|
353
|
+
/** What the on-demand report reads; the reporter keeps it current. */
|
|
354
|
+
let current: {| readonly path: string, readonly boundaries: Map<string, RouteBoundary> |} | null =
|
|
355
|
+
null;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The boundary set as one comparable string.
|
|
359
|
+
*
|
|
360
|
+
* Kind, depth and source — everything a reader would notice — and not what the
|
|
361
|
+
* page currently owns: a boundary whose subtree changed because the route's
|
|
362
|
+
* data changed has not changed, and reporting it would make this fire on every
|
|
363
|
+
* keystroke behind a search box.
|
|
364
|
+
*/
|
|
365
|
+
function signature(boundaries: Map<string, RouteBoundary>): string {
|
|
366
|
+
return [...boundaries.values()].map((it) => `${it.id}@${it.above}:${it.source ?? ""}`).join("|");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Send the report for whatever is on the page now.
|
|
371
|
+
*
|
|
372
|
+
* Exported for [`BOUNDARY_GLOBAL`] and used by the reporter, so the on-demand
|
|
373
|
+
* answer and the automatic one are the same sentence about the same page.
|
|
374
|
+
*/
|
|
375
|
+
export function reportBoundaries(
|
|
376
|
+
path: string,
|
|
377
|
+
boundaries: Map<string, RouteBoundary>,
|
|
378
|
+
document: Document,
|
|
379
|
+
): $ReadOnlyArray<BoundaryFinding> {
|
|
380
|
+
const findings = boundaryFindings(boundaries, document);
|
|
381
|
+
const { message, detail } = formatBoundaries(path, findings);
|
|
382
|
+
reportDiagnostic({ severity: "info", message, detail });
|
|
383
|
+
return findings;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Watches the boundary set and says when it changed.
|
|
388
|
+
*
|
|
389
|
+
* Renders nothing, and its effect has no dependency list on purpose: the
|
|
390
|
+
* question is asked after every commit, and the answer is a string comparison
|
|
391
|
+
* over a handful of entries before anything touches the DOM. The document is
|
|
392
|
+
* read only in the commit that is about to be reported, which is also the
|
|
393
|
+
* commit the marks are in — every edge mounted after the first one starts live,
|
|
394
|
+
* so a boundary that has just appeared is in the page by the time this runs.
|
|
395
|
+
*
|
|
396
|
+
* Quiet on the first sighting of a route, for the reason ubugeeei-prod/uf#636
|
|
397
|
+
* is quiet on the first scan: a listing of everything, at the moment somebody
|
|
398
|
+
* loaded a page, is not a thing anybody asked.
|
|
399
|
+
*/
|
|
400
|
+
export component BoundaryReporter(path: string, boundaries: Map<string, RouteBoundary>) {
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
current = { path, boundaries };
|
|
403
|
+
installOnDemand();
|
|
404
|
+
const next = signature(boundaries);
|
|
405
|
+
const previous = seen.get(path);
|
|
406
|
+
if (seen.size >= MEMORY_LIMIT && previous === undefined) {
|
|
407
|
+
const oldest = seen.keys().next();
|
|
408
|
+
if (!oldest.done) {
|
|
409
|
+
seen.delete(oldest.value);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
seen.set(path, next);
|
|
413
|
+
if (previous === undefined || previous === next) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const document = globalThis.document;
|
|
417
|
+
if (document == null) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
reportBoundaries(path, boundaries, document);
|
|
421
|
+
});
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The global object, under the one description this module has of it.
|
|
427
|
+
*
|
|
428
|
+
* A read-only indexer, which is what makes the annotation assignable at all:
|
|
429
|
+
* `globalThis` is a namespace to the checker, and every one of its members is
|
|
430
|
+
* read-only, so a writable indexer disagrees with all of them at once. The same
|
|
431
|
+
* shape `@uniflowed/react-testing`'s `internal/dom.js` reads globals through,
|
|
432
|
+
* and for the same reason — a name in, and no claim about what comes out.
|
|
433
|
+
*/
|
|
434
|
+
type Globals = { readonly [string]: mixed };
|
|
435
|
+
|
|
436
|
+
/** The global object, for reading. */
|
|
437
|
+
const globals: Globals = globalThis;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Install `__ufBoundaries()`, once.
|
|
441
|
+
*
|
|
442
|
+
* The answer to "and how do I see the page I am looking at *now*", which the
|
|
443
|
+
* change-driven report deliberately does not give. A function on the global
|
|
444
|
+
* rather than a key binding or a panel: there is nothing to discover by
|
|
445
|
+
* accident, nothing to intercept a page's own keystrokes, and the console is
|
|
446
|
+
* already open in the window this is about. It returns the findings as well as
|
|
447
|
+
* printing them, so the browser shows the tree and the terminal keeps the line.
|
|
448
|
+
*
|
|
449
|
+
* Defined rather than assigned, for the reason `internal/dom.js` gives about
|
|
450
|
+
* `navigator`: a name the host declared as an accessor cannot be assigned to,
|
|
451
|
+
* and a development affordance must not be able to throw on a page.
|
|
452
|
+
*/
|
|
453
|
+
function installOnDemand(): void {
|
|
454
|
+
if (globals[BOUNDARY_GLOBAL] != null) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
Object.defineProperty(globalThis, BOUNDARY_GLOBAL, {
|
|
458
|
+
value: () => {
|
|
459
|
+
const live = current;
|
|
460
|
+
const document = globalThis.document;
|
|
461
|
+
if (live == null || document == null) {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
return reportBoundaries(live.path, live.boundaries, document);
|
|
465
|
+
},
|
|
466
|
+
writable: true,
|
|
467
|
+
configurable: true,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Forget every route this module has seen.
|
|
473
|
+
*
|
|
474
|
+
* For tests, which share one module registry across files and would otherwise
|
|
475
|
+
* inherit a route's history from whichever file rendered it first.
|
|
476
|
+
*/
|
|
477
|
+
export function forgetBoundaries(): void {
|
|
478
|
+
seen.clear();
|
|
479
|
+
current = null;
|
|
480
|
+
marksAreLive = false;
|
|
481
|
+
}
|