@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.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1597 -1329
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. 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
+ }