@uniflowed/router 0.0.0-alpha.18 → 0.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +187 -5
- package/handler.js +15 -6
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +87 -17
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +72 -17
|
@@ -0,0 +1,557 @@
|
|
|
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
|
+
import * as React from "react";
|
|
87
|
+
import { useEffect, useState } from "react";
|
|
88
|
+
|
|
89
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
90
|
+
|
|
91
|
+
/** The attribute a mark carries its boundary's id in. */
|
|
92
|
+
export const BOUNDARY_ATTRIBUTE: string = "data-uf-boundary";
|
|
93
|
+
|
|
94
|
+
/** The attribute telling the two marks of one boundary apart. */
|
|
95
|
+
export const EDGE_ATTRIBUTE: string = "data-uf-boundary-edge";
|
|
96
|
+
|
|
97
|
+
/** The attribute naming the file a boundary was declared in, when one is known. */
|
|
98
|
+
export const SOURCE_ATTRIBUTE: string = "data-uf-boundary-source";
|
|
99
|
+
|
|
100
|
+
/** The name `uf dev` installs the on-demand report under. */
|
|
101
|
+
export const BOUNDARY_GLOBAL: string = "__ufBoundaries";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What `file` says for the error boundary the build synthesises.
|
|
105
|
+
*
|
|
106
|
+
* `routesModuleSource` writes this string where a declared boundary has a path,
|
|
107
|
+
* because the record has no module to name — the framework's own error page
|
|
108
|
+
* renders in its place. Written out again rather than imported for the reason
|
|
109
|
+
* `./devtools.js` gives about `DEVTOOLS_HOOK`: `@uniflowed/vite` is plain
|
|
110
|
+
* JavaScript loaded by Vite before any Flow transform exists, so the import
|
|
111
|
+
* cannot go either way. `boundaries.test.js` holds the two spellings together.
|
|
112
|
+
*/
|
|
113
|
+
export const SYNTHESISED_SOURCE: string = "@uniflowed/router";
|
|
114
|
+
|
|
115
|
+
/** Which kind of boundary a mark belongs to. */
|
|
116
|
+
export type BoundaryKind = "suspense" | "error";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One boundary of a resolved route, as the marks and the report name it.
|
|
120
|
+
*
|
|
121
|
+
* `id` pairs the two marks and is stable for a boundary across renders, so a
|
|
122
|
+
* navigation that keeps a boundary keeps its marks mounted. `above` is how many
|
|
123
|
+
* of the route's layouts are outside it — the same number, spelled the same
|
|
124
|
+
* way, that `ResolvedRoute["errorBoundary"].above` and every `loading` entry
|
|
125
|
+
* carry, because there is no second vocabulary for where a thing sits in the
|
|
126
|
+
* stack.
|
|
127
|
+
*
|
|
128
|
+
* `source` is the file it was declared in, and is `null` for every `<Suspense>`
|
|
129
|
+
* boundary. That asymmetry is the route table's rather than this module's: an
|
|
130
|
+
* error boundary is matched by path, so the table carries its `file`, while a
|
|
131
|
+
* `$loading.js` is carried by depth alone. Adding a path to the loading
|
|
132
|
+
* records would put one in every visitor's bundle to serve a report only
|
|
133
|
+
* `uf dev` reads.
|
|
134
|
+
*/
|
|
135
|
+
export type RouteBoundary = {|
|
|
136
|
+
readonly id: string,
|
|
137
|
+
readonly kind: BoundaryKind,
|
|
138
|
+
readonly above: number,
|
|
139
|
+
readonly source: ?string,
|
|
140
|
+
|};
|
|
141
|
+
|
|
142
|
+
/** The id of the `<Suspense>` boundary at `index` of a route's `loading`. */
|
|
143
|
+
export function suspenseId(index: number): string {
|
|
144
|
+
return `suspense:${index}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The id of the boundary a route's own `$error.js` renders. */
|
|
148
|
+
export const ROUTE_ERROR_ID: string = "error:route";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The id of the boundary that stands outside every layout.
|
|
152
|
+
*
|
|
153
|
+
* It has no module and renders the framework's page; it is what is between a
|
|
154
|
+
* throw in a root layout, or in the error component itself, and an unmounted
|
|
155
|
+
* document. Marked like any other, because "which subtree does the last resort
|
|
156
|
+
* own" is exactly as unanswerable from the page as the rest.
|
|
157
|
+
*/
|
|
158
|
+
export const ROOT_ERROR_ID: string = "error:root";
|
|
159
|
+
|
|
160
|
+
/** The part of a resolved route this module reads. */
|
|
161
|
+
type BoundedRoute = {
|
|
162
|
+
readonly errorBoundary: { readonly above: number, ... },
|
|
163
|
+
readonly loading: $ReadOnlyArray<{ readonly above: number, ... }>,
|
|
164
|
+
readonly error: mixed,
|
|
165
|
+
...
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Every boundary a resolved route renders, in the order they nest.
|
|
170
|
+
*
|
|
171
|
+
* A `Map` rather than a list because it is read both ways: `RouteView` asks for
|
|
172
|
+
* one by id as it builds the stack, and the report walks the values. One
|
|
173
|
+
* function answering both is the point — an id `RouteView` marks and the report
|
|
174
|
+
* cannot find is a boundary that silently disappears from the map, and
|
|
175
|
+
* ubugeeei-prod/uf#636 made the same argument about an explanation that can
|
|
176
|
+
* disagree with the thing it explains.
|
|
177
|
+
*
|
|
178
|
+
* The route's own error boundary is absent when the route *is* its error page,
|
|
179
|
+
* which is exactly when `RouteView` does not render one: wrapping that page in
|
|
180
|
+
* the boundary whose component it is would answer a throw inside it with
|
|
181
|
+
* itself.
|
|
182
|
+
*
|
|
183
|
+
* @param resolved the route being rendered
|
|
184
|
+
* @param errorSource the `file` of the nearest `$error.js`, when the table
|
|
185
|
+
* has one; `null` leaves the boundary named by its depth alone
|
|
186
|
+
*/
|
|
187
|
+
export function routeBoundaries(
|
|
188
|
+
resolved: BoundedRoute,
|
|
189
|
+
errorSource: ?string,
|
|
190
|
+
): Map<string, RouteBoundary> {
|
|
191
|
+
const found: Map<string, RouteBoundary> = new Map();
|
|
192
|
+
found.set(ROOT_ERROR_ID, {
|
|
193
|
+
id: ROOT_ERROR_ID,
|
|
194
|
+
kind: "error",
|
|
195
|
+
above: 0,
|
|
196
|
+
source: SYNTHESISED_SOURCE,
|
|
197
|
+
});
|
|
198
|
+
if (resolved.error == null) {
|
|
199
|
+
found.set(ROUTE_ERROR_ID, {
|
|
200
|
+
id: ROUTE_ERROR_ID,
|
|
201
|
+
kind: "error",
|
|
202
|
+
above: resolved.errorBoundary.above,
|
|
203
|
+
source: errorSource,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
resolved.loading.forEach((boundary, index) => {
|
|
207
|
+
const id = suspenseId(index);
|
|
208
|
+
found.set(id, { id, kind: "suspense", above: boundary.above, source: null });
|
|
209
|
+
});
|
|
210
|
+
return found;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether an edge that mounts now should be in the DOM immediately.
|
|
215
|
+
*
|
|
216
|
+
* Latched by the first edge to mount and never cleared. Read through
|
|
217
|
+
* `useState`'s initialiser rather than during the render body, which is the
|
|
218
|
+
* difference between "this component's first state" and "a module variable a
|
|
219
|
+
* memoising compiler is entitled to hold on to".
|
|
220
|
+
*/
|
|
221
|
+
let marksAreLive = false;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One end of one boundary.
|
|
225
|
+
*
|
|
226
|
+
* A hidden element and not a comment node, because React renders elements.
|
|
227
|
+
* This used to be a `<template>`, but React 19.3 reports template insertion
|
|
228
|
+
* during document-root hydration as a browser error. A `span hidden` carries
|
|
229
|
+
* the same marker data without entering layout or the accessibility tree.
|
|
230
|
+
*/
|
|
231
|
+
component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
|
|
232
|
+
const [live, setLive] = useState<boolean>(() => marksAreLive);
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
marksAreLive = true;
|
|
235
|
+
setLive(true);
|
|
236
|
+
}, []);
|
|
237
|
+
if (!live) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (edge === "close") {
|
|
241
|
+
return <span hidden data-uf-boundary={boundary.id} data-uf-boundary-edge="close" />;
|
|
242
|
+
}
|
|
243
|
+
return (
|
|
244
|
+
<span
|
|
245
|
+
hidden
|
|
246
|
+
data-uf-boundary={boundary.id}
|
|
247
|
+
data-uf-boundary-edge="open"
|
|
248
|
+
data-uf-boundary-source={boundary.source ?? undefined}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `children`, between the two marks of `boundary`.
|
|
255
|
+
*
|
|
256
|
+
* `children` unchanged when there is no boundary to mark, so a caller never has
|
|
257
|
+
* to ask twice. The marks are the first and last children of a fragment rather
|
|
258
|
+
* than a wrapper's, so the nodes between them are siblings of them, and every
|
|
259
|
+
* position in the fragment is fixed — an edge going from `null` to a hidden
|
|
260
|
+
* mark after mount is an insertion beside `children` and not around it,
|
|
261
|
+
* which is why it costs no remount.
|
|
262
|
+
*/
|
|
263
|
+
export function insideBoundary(boundary: ?RouteBoundary, children: React.Node): React.Node {
|
|
264
|
+
if (boundary == null) {
|
|
265
|
+
return children;
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
<>
|
|
269
|
+
<BoundaryEdge boundary={boundary} edge="open" />
|
|
270
|
+
{children}
|
|
271
|
+
<BoundaryEdge boundary={boundary} edge="close" />
|
|
272
|
+
</>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* How far a walk between two marks will go before giving up.
|
|
278
|
+
*
|
|
279
|
+
* A closing mark is a sibling of its opening one and the run between them is a
|
|
280
|
+
* route's rendered output, so this is never reached by a page that is behaving.
|
|
281
|
+
* It is here because `docs/security.md` asks that a report have no unbounded
|
|
282
|
+
* anything in it, and because a DOM somebody else's script has been editing is
|
|
283
|
+
* exactly where an unbounded walk would be found.
|
|
284
|
+
*/
|
|
285
|
+
const WALK_LIMIT = 512;
|
|
286
|
+
|
|
287
|
+
/** How many owned elements one line of the report names before it counts them. */
|
|
288
|
+
const NAMED_LIMIT = 3;
|
|
289
|
+
|
|
290
|
+
/** One boundary, as the page has it. */
|
|
291
|
+
export type BoundaryFinding = {|
|
|
292
|
+
readonly boundary: RouteBoundary,
|
|
293
|
+
/** The top-level elements between its marks, as short selectors. */
|
|
294
|
+
readonly owns: $ReadOnlyArray<string>,
|
|
295
|
+
/** How many more there were than [`NAMED_LIMIT`]. */
|
|
296
|
+
readonly more: number,
|
|
297
|
+
/** False when the boundary rendered no marks — it is showing its fallback. */
|
|
298
|
+
readonly rendered: boolean,
|
|
299
|
+
|};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* What each boundary owns on the page right now.
|
|
303
|
+
*
|
|
304
|
+
* Takes the document rather than reaching for a global, so a test can build one
|
|
305
|
+
* and ask — the same shape `hydrationReport` has, and for the same reason: the
|
|
306
|
+
* analysis worth checking is the one that runs in a browser, so the test has to
|
|
307
|
+
* be able to call exactly it.
|
|
308
|
+
*
|
|
309
|
+
* A boundary with no marks in the document is not missing, it is *suspended*:
|
|
310
|
+
* React removes a boundary's content while its fallback is up, and its marks
|
|
311
|
+
* are part of that content. Saying so is more useful than leaving it out.
|
|
312
|
+
*/
|
|
313
|
+
export function boundaryFindings(
|
|
314
|
+
boundaries: Map<string, RouteBoundary>,
|
|
315
|
+
document: Document,
|
|
316
|
+
): $ReadOnlyArray<BoundaryFinding> {
|
|
317
|
+
const findings: Array<BoundaryFinding> = [];
|
|
318
|
+
for (const boundary of boundaries.values()) {
|
|
319
|
+
const open = document.querySelector(
|
|
320
|
+
`[${BOUNDARY_ATTRIBUTE}="${boundary.id}"][${EDGE_ATTRIBUTE}="open"]`,
|
|
321
|
+
);
|
|
322
|
+
if (open == null) {
|
|
323
|
+
findings.push({ boundary, owns: [], more: 0, rendered: false });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const owned: Array<string> = [];
|
|
327
|
+
let steps = 0;
|
|
328
|
+
let node = open.nextElementSibling;
|
|
329
|
+
while (node != null && steps < WALK_LIMIT && !closes(node, boundary.id)) {
|
|
330
|
+
if (node.getAttribute(BOUNDARY_ATTRIBUTE) == null) {
|
|
331
|
+
owned.push(describeElement(node));
|
|
332
|
+
}
|
|
333
|
+
node = node.nextElementSibling;
|
|
334
|
+
steps += 1;
|
|
335
|
+
}
|
|
336
|
+
findings.push({
|
|
337
|
+
boundary,
|
|
338
|
+
owns: owned.slice(0, NAMED_LIMIT),
|
|
339
|
+
more: Math.max(0, owned.length - NAMED_LIMIT),
|
|
340
|
+
rendered: true,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return findings;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Whether `element` is the closing mark of `id`. */
|
|
347
|
+
function closes(element: Element, id: string): boolean {
|
|
348
|
+
return (
|
|
349
|
+
element.getAttribute(BOUNDARY_ATTRIBUTE) === id &&
|
|
350
|
+
element.getAttribute(EDGE_ATTRIBUTE) === "close"
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One element, short enough to sit in a line of a report.
|
|
356
|
+
*
|
|
357
|
+
* A CSS selector rather than a tag name, because a page has eleven `<div>`s and
|
|
358
|
+
* the one being named has to be findable: the id if it has one, and otherwise
|
|
359
|
+
* the first class, which is what a person would type into the inspector's
|
|
360
|
+
* search box. Nothing more — the report says which subtree, and the page says
|
|
361
|
+
* what is in it.
|
|
362
|
+
*/
|
|
363
|
+
export function describeElement(element: Element): string {
|
|
364
|
+
const tag = element.tagName.toLowerCase();
|
|
365
|
+
const id = element.getAttribute("id");
|
|
366
|
+
if (id != null && id !== "") {
|
|
367
|
+
return `${tag}#${id}`;
|
|
368
|
+
}
|
|
369
|
+
const className = element.getAttribute("class");
|
|
370
|
+
const first = className == null ? "" : className.trim().split(/\s+/)[0];
|
|
371
|
+
return first === "" ? tag : `${tag}.${first}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The report, as the terminal will print it.
|
|
376
|
+
*
|
|
377
|
+
* Separated from the sending so that a test can pin the wording, which is the
|
|
378
|
+
* part worth pinning: somebody reading this in a terminal has to be able to act
|
|
379
|
+
* on it without opening this file.
|
|
380
|
+
*/
|
|
381
|
+
export function formatBoundaries(
|
|
382
|
+
path: string,
|
|
383
|
+
findings: $ReadOnlyArray<BoundaryFinding>,
|
|
384
|
+
): {| readonly message: string, readonly detail: $ReadOnlyArray<string> |} {
|
|
385
|
+
const count = findings.length;
|
|
386
|
+
return {
|
|
387
|
+
message: `${count} ${count === 1 ? "boundary renders" : "boundaries render"} ${path}`,
|
|
388
|
+
detail: findings.map(describeFinding),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** One boundary as one line: what it is, where it sits, and what it owns. */
|
|
393
|
+
function describeFinding(finding: BoundaryFinding): string {
|
|
394
|
+
const { boundary } = finding;
|
|
395
|
+
const kind = boundary.kind === "error" ? "error" : "suspense";
|
|
396
|
+
const source =
|
|
397
|
+
boundary.source == null
|
|
398
|
+
? ""
|
|
399
|
+
: boundary.source === SYNTHESISED_SOURCE
|
|
400
|
+
? " (uf's own error page)"
|
|
401
|
+
: ` (${boundary.source})`;
|
|
402
|
+
const where =
|
|
403
|
+
boundary.above === 0
|
|
404
|
+
? "outside every layout"
|
|
405
|
+
: `inside ${boundary.above} ${boundary.above === 1 ? "layout" : "layouts"}`;
|
|
406
|
+
if (!finding.rendered) {
|
|
407
|
+
return `${kind}${source}, ${where} — showing its fallback`;
|
|
408
|
+
}
|
|
409
|
+
if (finding.owns.length === 0) {
|
|
410
|
+
return `${kind}${source}, ${where} — owns no element of its own`;
|
|
411
|
+
}
|
|
412
|
+
const named = finding.owns.join(", ");
|
|
413
|
+
const more = finding.more === 0 ? "" : ` and ${finding.more} more`;
|
|
414
|
+
return `${kind}${source}, ${where} — owns ${named}${more}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* How many routes the "has this changed" memory keeps.
|
|
419
|
+
*
|
|
420
|
+
* A route table is finite and this is larger than any project's hot set, so the
|
|
421
|
+
* ceiling is `docs/security.md`'s rule rather than a policy about routes: a map
|
|
422
|
+
* a page can grow by navigating is a map with a bound.
|
|
423
|
+
*/
|
|
424
|
+
const MEMORY_LIMIT = 64;
|
|
425
|
+
|
|
426
|
+
/** The last boundary set seen for each route path. */
|
|
427
|
+
const seen: Map<string, string> = new Map();
|
|
428
|
+
|
|
429
|
+
/** What the on-demand report reads; the reporter keeps it current. */
|
|
430
|
+
let current: {| readonly path: string, readonly boundaries: Map<string, RouteBoundary> |} | null =
|
|
431
|
+
null;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The boundary set as one comparable string.
|
|
435
|
+
*
|
|
436
|
+
* Kind, depth and source — everything a reader would notice — and not what the
|
|
437
|
+
* page currently owns: a boundary whose subtree changed because the route's
|
|
438
|
+
* data changed has not changed, and reporting it would make this fire on every
|
|
439
|
+
* keystroke behind a search box.
|
|
440
|
+
*/
|
|
441
|
+
function signature(boundaries: Map<string, RouteBoundary>): string {
|
|
442
|
+
return [...boundaries.values()].map((it) => `${it.id}@${it.above}:${it.source ?? ""}`).join("|");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Send the report for whatever is on the page now.
|
|
447
|
+
*
|
|
448
|
+
* Exported for [`BOUNDARY_GLOBAL`] and used by the reporter, so the on-demand
|
|
449
|
+
* answer and the automatic one are the same sentence about the same page.
|
|
450
|
+
*/
|
|
451
|
+
export function reportBoundaries(
|
|
452
|
+
path: string,
|
|
453
|
+
boundaries: Map<string, RouteBoundary>,
|
|
454
|
+
document: Document,
|
|
455
|
+
): $ReadOnlyArray<BoundaryFinding> {
|
|
456
|
+
const findings = boundaryFindings(boundaries, document);
|
|
457
|
+
const { message, detail } = formatBoundaries(path, findings);
|
|
458
|
+
reportDiagnostic({ severity: "info", message, detail });
|
|
459
|
+
return findings;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Watches the boundary set and says when it changed.
|
|
464
|
+
*
|
|
465
|
+
* Renders nothing, and its effect has no dependency list on purpose: the
|
|
466
|
+
* question is asked after every commit, and the answer is a string comparison
|
|
467
|
+
* over a handful of entries before anything touches the DOM. The document is
|
|
468
|
+
* read only in the commit that is about to be reported, which is also the
|
|
469
|
+
* commit the marks are in — every edge mounted after the first one starts live,
|
|
470
|
+
* so a boundary that has just appeared is in the page by the time this runs.
|
|
471
|
+
*
|
|
472
|
+
* Quiet on the first sighting of a route, for the reason ubugeeei-prod/uf#636
|
|
473
|
+
* is quiet on the first scan: a listing of everything, at the moment somebody
|
|
474
|
+
* loaded a page, is not a thing anybody asked.
|
|
475
|
+
*/
|
|
476
|
+
export component BoundaryReporter(path: string, boundaries: Map<string, RouteBoundary>) {
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
current = { path, boundaries };
|
|
479
|
+
installOnDemand();
|
|
480
|
+
const next = signature(boundaries);
|
|
481
|
+
const previous = seen.get(path);
|
|
482
|
+
if (seen.size >= MEMORY_LIMIT && previous === undefined) {
|
|
483
|
+
const oldest = seen.keys().next();
|
|
484
|
+
if (!oldest.done) {
|
|
485
|
+
seen.delete(oldest.value);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
seen.set(path, next);
|
|
489
|
+
if (previous === undefined || previous === next) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const document = globalThis.document;
|
|
493
|
+
if (document == null) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
reportBoundaries(path, boundaries, document);
|
|
497
|
+
});
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The global object, under the one description this module has of it.
|
|
503
|
+
*
|
|
504
|
+
* A read-only indexer, which is what makes the annotation assignable at all:
|
|
505
|
+
* `globalThis` is a namespace to the checker, and every one of its members is
|
|
506
|
+
* read-only, so a writable indexer disagrees with all of them at once. The same
|
|
507
|
+
* shape `@uniflowed/react-testing`'s `internal/dom.js` reads globals through,
|
|
508
|
+
* and for the same reason — a name in, and no claim about what comes out.
|
|
509
|
+
*/
|
|
510
|
+
type Globals = { readonly [string]: mixed };
|
|
511
|
+
|
|
512
|
+
/** The global object, for reading. */
|
|
513
|
+
const globals: Globals = globalThis;
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Install `__ufBoundaries()`, once.
|
|
517
|
+
*
|
|
518
|
+
* The answer to "and how do I see the page I am looking at *now*", which the
|
|
519
|
+
* change-driven report deliberately does not give. A function on the global
|
|
520
|
+
* rather than a key binding or a panel: there is nothing to discover by
|
|
521
|
+
* accident, nothing to intercept a page's own keystrokes, and the console is
|
|
522
|
+
* already open in the window this is about. It returns the findings as well as
|
|
523
|
+
* printing them, so the browser shows the tree and the terminal keeps the line.
|
|
524
|
+
*
|
|
525
|
+
* Defined rather than assigned, for the reason `internal/dom.js` gives about
|
|
526
|
+
* `navigator`: a name the host declared as an accessor cannot be assigned to,
|
|
527
|
+
* and a development affordance must not be able to throw on a page.
|
|
528
|
+
*/
|
|
529
|
+
function installOnDemand(): void {
|
|
530
|
+
if (globals[BOUNDARY_GLOBAL] != null) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
Object.defineProperty(globalThis, BOUNDARY_GLOBAL, {
|
|
534
|
+
value: () => {
|
|
535
|
+
const live = current;
|
|
536
|
+
const document = globalThis.document;
|
|
537
|
+
if (live == null || document == null) {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
return reportBoundaries(live.path, live.boundaries, document);
|
|
541
|
+
},
|
|
542
|
+
writable: true,
|
|
543
|
+
configurable: true,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Forget every route this module has seen.
|
|
549
|
+
*
|
|
550
|
+
* For tests, which share one module registry across files and would otherwise
|
|
551
|
+
* inherit a route's history from whichever file rendered it first.
|
|
552
|
+
*/
|
|
553
|
+
export function forgetBoundaries(): void {
|
|
554
|
+
seen.clear();
|
|
555
|
+
current = null;
|
|
556
|
+
marksAreLive = false;
|
|
557
|
+
}
|
package/internal/devtools.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// panel props, hooks and source positions — is not checked here because a
|
|
30
30
|
// browser cannot tell the difference from the outside, and because uf owns it
|
|
31
31
|
// end to end: `mode` is `development` and `uf transform` is called with
|
|
32
|
-
// `development: true`, both asserted in `
|
|
32
|
+
// `development: true`, both asserted in `packages/vite/devtools.test.js`
|
|
33
33
|
// against the plugin rather than against a page. What is left is what only a
|
|
34
34
|
// running page knows.
|
|
35
35
|
//
|
|
@@ -53,7 +53,7 @@ import { reportDiagnostic } from "./diagnostics.js";
|
|
|
53
53
|
* that file's neighbour `internal/diagnostics.js` gives about the endpoint
|
|
54
54
|
* paths: `@uniflowed/vite` is loaded by Vite before any Flow transform exists
|
|
55
55
|
* and this module is Flow, so the import cannot go either way.
|
|
56
|
-
* `
|
|
56
|
+
* `packages/vite/devtools.test.js` asserts the two spellings agree, which is
|
|
57
57
|
* what makes a duplicated constant honest.
|
|
58
58
|
*/
|
|
59
59
|
export const DEVTOOLS_HOOK: string = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
|
package/internal/diagnostics.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// published package that depends on an unpublished one, because the tarball
|
|
30
30
|
// would name a version the registry does not have; `@uniflowed/router` is on
|
|
31
31
|
// npm and `@uniflowed/hmr` is a declaration package that is not. What the two
|
|
32
|
-
// posters do share is the contract, and `
|
|
32
|
+
// posters do share is the contract, and `packages/vite/dev-channel.test.js`
|
|
33
33
|
// asserts that every spelling of these paths agrees — a duplicated constant
|
|
34
34
|
// with a test on it is honest, and one without is how a browser ends up
|
|
35
35
|
// posting to a path nothing serves.
|