@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,160 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: which build this page is, and what it does
|
|
4
|
+
// when the server is on another one.
|
|
5
|
+
//
|
|
6
|
+
// A tab stays open across a deploy. The page in it was built by build N — its
|
|
7
|
+
// action ids, its chunk names, its copy of React — and after build N+1 goes
|
|
8
|
+
// live the server it talks to is N+1's. Three things can then go wrong, and
|
|
9
|
+
// this module is the browser's half of the answer to each:
|
|
10
|
+
//
|
|
11
|
+
// 1. **An action call.** N's action ids name nothing in N+1 (an id is an HMAC
|
|
12
|
+
// over a per-build secret), so the call cannot be answered — and must not
|
|
13
|
+
// be answered by guessing. The call names its build in the
|
|
14
|
+
// [`DEPLOYMENT_HEADER`]; a server on another build answers `409` without
|
|
15
|
+
// running anything; and the reference makes a hard navigation instead of
|
|
16
|
+
// throwing, so the reader lands on N+1's page and presses the button again
|
|
17
|
+
// on a page whose ids are right.
|
|
18
|
+
// 2. **A navigation.** N+1's payload names N+1's client chunks, and loading
|
|
19
|
+
// them into N's page would put a second React beside the first. A payload
|
|
20
|
+
// request names its build the same way and is refused the same way, and
|
|
21
|
+
// `./flight-browser.js` already turns any answer that is not a payload into
|
|
22
|
+
// a document load. A payload the build *prerendered* is a file, answered by
|
|
23
|
+
// a host that runs nothing, so the payload also says which build rendered
|
|
24
|
+
// it (`FlightRoot.deployment`), and the router loads the document instead
|
|
25
|
+
// of rendering one from another build ([`fromAnotherDeployment`]).
|
|
26
|
+
// 3. **A chunk.** `uf build` keeps the previous build's hashed files in the
|
|
27
|
+
// output directory for one more build, so a lazy chunk of N's is still
|
|
28
|
+
// there while N+1 is live and the page keeps working. Past that window — or
|
|
29
|
+
// on a host that did not keep them — the `import()` fails, and a
|
|
30
|
+
// navigation whose module will not load becomes a document load of the
|
|
31
|
+
// same URL rather than an error page ([`isChunkLoadFailure`]).
|
|
32
|
+
//
|
|
33
|
+
// The id is `<meta name="uf:deployment">` in the document's head, which
|
|
34
|
+
// `./shell.js` writes. `uf dev` writes none, and a page without one sends no
|
|
35
|
+
// header and compares nothing: there is no other build to be skewed against.
|
|
36
|
+
|
|
37
|
+
/** The request header a page names its build in, and a refusal names the server's in. */
|
|
38
|
+
export const DEPLOYMENT_HEADER: string = "uf-deployment";
|
|
39
|
+
|
|
40
|
+
/** The `<meta name>` the document carries the id under. */
|
|
41
|
+
export const DEPLOYMENT_META: string = "uf:deployment";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What this page has read, once.
|
|
45
|
+
*
|
|
46
|
+
* `undefined` is "not read yet"; `null` is "read, and there is none". Read
|
|
47
|
+
* before hydration by [`rememberDeployment`], because an application that owns
|
|
48
|
+
* `<html>` hands its head to React, and the id is a fact about the document
|
|
49
|
+
* that arrived rather than about whatever the head holds later.
|
|
50
|
+
*/
|
|
51
|
+
let remembered: string | null | void;
|
|
52
|
+
|
|
53
|
+
/** The parts of a `Document` read here. */
|
|
54
|
+
type HeadLike = interface {
|
|
55
|
+
readonly querySelector: (selector: string) => ?interface {
|
|
56
|
+
readonly getAttribute: (name: string) => ?string,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the page's build from its document and keep it.
|
|
62
|
+
*
|
|
63
|
+
* Called by both client entries before `hydrateRoot`, through
|
|
64
|
+
* `./prepare-document.js`.
|
|
65
|
+
*/
|
|
66
|
+
export function rememberDeployment(document: ?HeadLike): void {
|
|
67
|
+
const meta = document?.querySelector(`meta[name="${DEPLOYMENT_META}"]`);
|
|
68
|
+
const content = meta?.getAttribute("content");
|
|
69
|
+
remembered = content == null || content === "" ? null : content;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The build this page is, or `null` for a page that does not say. */
|
|
73
|
+
export function currentDeployment(): string | null {
|
|
74
|
+
if (remembered === undefined) {
|
|
75
|
+
rememberDeployment(typeof window === "undefined" ? null : window.document);
|
|
76
|
+
}
|
|
77
|
+
return remembered ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Forget what was read; for a test that builds a second page in one process. */
|
|
81
|
+
export function forgetDeployment(): void {
|
|
82
|
+
remembered = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Name this page's build on an outgoing request's headers, when it has one. */
|
|
86
|
+
export function withDeployment(headers: { [string]: string }): { [string]: string } {
|
|
87
|
+
const deployment = currentDeployment();
|
|
88
|
+
if (deployment != null) {
|
|
89
|
+
headers[DEPLOYMENT_HEADER] = deployment;
|
|
90
|
+
}
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether `response` is a server on another build refusing this page.
|
|
96
|
+
*
|
|
97
|
+
* The status and the header together: a `409` alone is an application's own
|
|
98
|
+
* answer to something, and only a front door refusing a build names the build
|
|
99
|
+
* it *is* on.
|
|
100
|
+
*/
|
|
101
|
+
export function refusedAsAnotherDeployment(response: Response): boolean {
|
|
102
|
+
if (response.status !== 409) return false;
|
|
103
|
+
const serving = response.headers.get(DEPLOYMENT_HEADER);
|
|
104
|
+
return serving != null && serving !== currentDeployment();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether a payload was rendered by another build than this page's.
|
|
109
|
+
*
|
|
110
|
+
* Only when both say: a payload from a server with no id (`uf dev`) and a page
|
|
111
|
+
* with none are the same build as far as anyone can tell.
|
|
112
|
+
*/
|
|
113
|
+
export function fromAnotherDeployment(rendered: ?string): boolean {
|
|
114
|
+
const current = currentDeployment();
|
|
115
|
+
return current != null && rendered != null && rendered !== "" && rendered !== current;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether `error` is a module that could not be fetched, rather than one that
|
|
120
|
+
* threw while it ran.
|
|
121
|
+
*
|
|
122
|
+
* An `import()` of a file the server no longer has rejects with a `TypeError`
|
|
123
|
+
* whose message is the browser's own and differs per engine; these are the
|
|
124
|
+
* three that ship, plus Vite's own message for a stylesheet its preload could
|
|
125
|
+
* not load. A module that threw while evaluating rejects with whatever it
|
|
126
|
+
* threw, and is an error for the route's boundary to show — reloading the page
|
|
127
|
+
* would only throw it again.
|
|
128
|
+
*/
|
|
129
|
+
export function isChunkLoadFailure(error: mixed): boolean {
|
|
130
|
+
if (!(error instanceof Error)) return false;
|
|
131
|
+
const message = error.message;
|
|
132
|
+
return (
|
|
133
|
+
message.includes("Failed to fetch dynamically imported module") ||
|
|
134
|
+
message.includes("error loading dynamically imported module") ||
|
|
135
|
+
message.includes("Importing a module script failed") ||
|
|
136
|
+
message.includes("Unable to preload CSS")
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a route resolved to an error because one of its modules could not be
|
|
142
|
+
* fetched — `isChunkLoadFailure` applied to a resolution's error, which is where
|
|
143
|
+
* a navigation meets it. A route whose module threw is not one of these.
|
|
144
|
+
*/
|
|
145
|
+
export function failedOnAMissingChunk(
|
|
146
|
+
failed: ?{ readonly kind: string, readonly error?: mixed, ... },
|
|
147
|
+
): boolean {
|
|
148
|
+
return failed?.kind === "thrown" && isChunkLoadFailure(failed.error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Leave this page for `href` by loading its document.
|
|
153
|
+
*
|
|
154
|
+
* `assign` rather than the history API, because the point is to stop running
|
|
155
|
+
* this build's code: the document that arrives is the current build's, with
|
|
156
|
+
* the current build's scripts.
|
|
157
|
+
*/
|
|
158
|
+
export function loadDocument(href: string): void {
|
|
159
|
+
window.location.assign(href);
|
|
160
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: whether React DevTools can actually attach
|
|
4
|
+
// to the page this browser just hydrated.
|
|
5
|
+
//
|
|
6
|
+
// `@uniflowed/vite` installs the hook DevTools attaches through, as a classic
|
|
7
|
+
// script above every module — see `packages/vite/internal/devtools.js`, which
|
|
8
|
+
// has the argument. That is uf saying what it intends. This is the half that
|
|
9
|
+
// checks it happened, and the two are not the same claim: the preamble is
|
|
10
|
+
// injected by a `transformIndexHtml` hook, into a document a project's own Vite
|
|
11
|
+
// plugins also write to, and the thing that has to be true is an *ordering* —
|
|
12
|
+
// the hook exists before `react-dom` is evaluated — which no amount of reading
|
|
13
|
+
// the injector can establish about a particular page.
|
|
14
|
+
//
|
|
15
|
+
// It runs once, after hydration, in development only, and says nothing at all
|
|
16
|
+
// when there is nothing wrong. See ubugeeei-prod/uf#503.
|
|
17
|
+
//
|
|
18
|
+
// # Why it reports rather than throws
|
|
19
|
+
//
|
|
20
|
+
// Nothing here is a reason to stop a page. DevTools not attaching costs a
|
|
21
|
+
// developer a panel, and a framework that refused to render over it would have
|
|
22
|
+
// turned a missing convenience into an outage. So the two findings go to the
|
|
23
|
+
// terminal on the same channel as every other browser-side diagnostic — see
|
|
24
|
+
// `./diagnostics.js` — and the page carries on.
|
|
25
|
+
//
|
|
26
|
+
// # The two findings, and why they are the two
|
|
27
|
+
//
|
|
28
|
+
// A third condition — React's *development* build, which is what gives the
|
|
29
|
+
// panel props, hooks and source positions — is not checked here because a
|
|
30
|
+
// browser cannot tell the difference from the outside, and because uf owns it
|
|
31
|
+
// end to end: `mode` is `development` and `uf transform` is called with
|
|
32
|
+
// `development: true`, both asserted in `packages/vite/devtools.test.js`
|
|
33
|
+
// against the plugin rather than against a page. What is left is what only a
|
|
34
|
+
// running page knows.
|
|
35
|
+
//
|
|
36
|
+
// 1. **There is no hook.** Something ran before `react-dom` and there was
|
|
37
|
+
// nothing for it to register with, or the preamble did not reach this
|
|
38
|
+
// document. Either way no renderer was announced and the panel will say
|
|
39
|
+
// the page is not using React.
|
|
40
|
+
// 2. **There is more than one renderer.** Two copies of `react-dom` each
|
|
41
|
+
// injected, and DevTools shows the tree of whichever it heard from — which
|
|
42
|
+
// is the shape of the "multiple renderers concurrently rendering the same
|
|
43
|
+
// context provider" report, and a component tree that is missing half the
|
|
44
|
+
// page for a reason nothing on screen explains.
|
|
45
|
+
|
|
46
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The global React registers itself with.
|
|
50
|
+
*
|
|
51
|
+
* The same string as `DEVTOOLS_HOOK` in `@uniflowed/vite`'s
|
|
52
|
+
* `internal/devtools.js`, written out again rather than imported for the reason
|
|
53
|
+
* that file's neighbour `internal/diagnostics.js` gives about the endpoint
|
|
54
|
+
* paths: `@uniflowed/vite` is loaded by Vite before any Flow transform exists
|
|
55
|
+
* and this module is Flow, so the import cannot go either way.
|
|
56
|
+
* `packages/vite/devtools.test.js` asserts the two spellings agree, which is
|
|
57
|
+
* what makes a duplicated constant honest.
|
|
58
|
+
*/
|
|
59
|
+
export const DEVTOOLS_HOOK: string = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
|
|
60
|
+
|
|
61
|
+
/** The part of a page this module reads. */
|
|
62
|
+
type HookWindow = {
|
|
63
|
+
[key: string]: mixed,
|
|
64
|
+
...
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What is wrong with this page's DevTools hook, as a diagnostic, or `null`.
|
|
69
|
+
*
|
|
70
|
+
* Separated from the reporting so that a test can ask the question without a
|
|
71
|
+
* channel to answer on, and because the wording is the part worth pinning: a
|
|
72
|
+
* reader who sees this in a terminal has to be able to act on it without
|
|
73
|
+
* reading this file.
|
|
74
|
+
*/
|
|
75
|
+
export function devtoolsProblem(win: HookWindow): {|
|
|
76
|
+
readonly message: string,
|
|
77
|
+
readonly detail: $ReadOnlyArray<string>,
|
|
78
|
+
|} | null {
|
|
79
|
+
const hook = win[DEVTOOLS_HOOK];
|
|
80
|
+
if (hook == null || typeof hook !== "object") {
|
|
81
|
+
return {
|
|
82
|
+
message: `React DevTools cannot attach: nothing installed \`${DEVTOOLS_HOOK}\` before react-dom ran`,
|
|
83
|
+
detail: [
|
|
84
|
+
"React registers itself with that global while `react-dom` is evaluated, once and never again.",
|
|
85
|
+
"`uf dev` injects the hook as a classic script at the top of the head; a plugin that replaces",
|
|
86
|
+
"`transformIndexHtml`'s output, or a document that does not go through it, takes it away.",
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `renderers` is a Map React puts its renderer in, keyed by the id `inject`
|
|
92
|
+
// handed back. Anything else there is a hook uf did not install and DevTools
|
|
93
|
+
// did not either, and guessing at its shape would report a problem that is
|
|
94
|
+
// really this module not recognising one.
|
|
95
|
+
const renderers = (hook: $FlowFixMe).renderers;
|
|
96
|
+
const count = renderers instanceof Map ? renderers.size : null;
|
|
97
|
+
if (count != null && count > 1) {
|
|
98
|
+
return {
|
|
99
|
+
message: `React DevTools has ${count} renderers on this page and will show one of them`,
|
|
100
|
+
detail: [
|
|
101
|
+
"Two copies of `react-dom` are loaded, so half the component tree is in a tree the panel cannot see.",
|
|
102
|
+
'`resolve.dedupe: ["react", "react-dom"]` is what usually prevents it; a linked package with its',
|
|
103
|
+
"own `react-dom` in `node_modules` is what usually causes it.",
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Report the problem, if there is one, to the terminal running `uf dev`.
|
|
112
|
+
*
|
|
113
|
+
* Throws nothing and returns nothing, and the guard is around the whole body
|
|
114
|
+
* rather than around the reading: this is called from the line after a
|
|
115
|
+
* successful hydration, so every failure available to it — a hook object whose
|
|
116
|
+
* property getter throws, a host with no `fetch` to report through — is a
|
|
117
|
+
* development convenience failing, and a development convenience that can take
|
|
118
|
+
* a working page down is worse than no convenience at all.
|
|
119
|
+
*/
|
|
120
|
+
export function reportDevtools(win: HookWindow): void {
|
|
121
|
+
try {
|
|
122
|
+
const problem = devtoolsProblem(win);
|
|
123
|
+
if (problem == null) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
reportDiagnostic({ severity: "warn", message: problem.message, detail: problem.detail });
|
|
127
|
+
} catch {
|
|
128
|
+
// Deliberately silent. There is no second channel to complain on, and the
|
|
129
|
+
// thing being reported was never worth interrupting anybody for.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the way a diagnostic the browser produced
|
|
4
|
+
// reaches the terminal.
|
|
5
|
+
//
|
|
6
|
+
// Every other uf diagnostic — a type error, a lint finding, a failing test, a
|
|
7
|
+
// page that rendered its error boundary — arrives in the terminal the
|
|
8
|
+
// developer already has open. One produced *in the browser* had nowhere to go:
|
|
9
|
+
// the page is the only process that knows about it, and `uf dev`'s diagnostics
|
|
10
|
+
// come up the driver's event channel from the Node process. So the hydration
|
|
11
|
+
// report added in ubugeeei-prod/uf#582 lived in a browser overlay and in the
|
|
12
|
+
// console, which has to be noticed, in a window that may not be in front, by
|
|
13
|
+
// somebody who does not know to look. See ubugeeei-prod/uf#583.
|
|
14
|
+
//
|
|
15
|
+
// This is the client half. The server half is `uf dev`, through
|
|
16
|
+
// `@uniflowed/vite`'s `internal/diagnostics.js`, which turns what arrives into
|
|
17
|
+
// the same rendering the terminal gives everything else: a severity, the page
|
|
18
|
+
// it came from, and a code frame when there is a position to draw one around.
|
|
19
|
+
//
|
|
20
|
+
// # One channel, and why the poster is not shared
|
|
21
|
+
//
|
|
22
|
+
// `/__uf/diagnostic` and `/__uf/vitals` are one channel: two paths, one payload
|
|
23
|
+
// shape, one `diagnostic` event out of the driver, one renderer. What is *not*
|
|
24
|
+
// shared is the twenty lines that post to it. `@uniflowed/web/vitals` has its
|
|
25
|
+
// own `vitalsBeacon`, and this is the router's.
|
|
26
|
+
//
|
|
27
|
+
// `@uniflowed/hmr` would have been the tidier home — it is already "the browser
|
|
28
|
+
// half of `uf dev`" — and it cannot be one. `tools/ci/publishable.sh` refuses a
|
|
29
|
+
// published package that depends on an unpublished one, because the tarball
|
|
30
|
+
// would name a version the registry does not have; `@uniflowed/router` is on
|
|
31
|
+
// npm and `@uniflowed/hmr` is a declaration package that is not. What the two
|
|
32
|
+
// posters do share is the contract, and `packages/vite/dev-channel.test.js`
|
|
33
|
+
// asserts that every spelling of these paths agrees — a duplicated constant
|
|
34
|
+
// with a test on it is honest, and one without is how a browser ends up
|
|
35
|
+
// posting to a path nothing serves.
|
|
36
|
+
//
|
|
37
|
+
// # It is development only, and it is best effort
|
|
38
|
+
//
|
|
39
|
+
// `uf dev` serves this path and nothing else does: a built application has no
|
|
40
|
+
// `/__uf/` anything, so a call in production posts to a path that answers 404
|
|
41
|
+
// and the rejected promise is swallowed here. That is a fallback rather than a
|
|
42
|
+
// design — both callers are behind `import.meta.hot` in `../client.js`, the
|
|
43
|
+
// hydration report and the DevTools check, so a production bundle has no path
|
|
44
|
+
// to this module rather than merely no answer from it.
|
|
45
|
+
//
|
|
46
|
+
// Nothing here opens a connection until it is called, importing it does nothing
|
|
47
|
+
// at all, and what it sends goes to the page's own origin as a path rather than
|
|
48
|
+
// a URL, so it cannot be pointed at somebody else's server. Nothing leaves the
|
|
49
|
+
// machine.
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The path `uf dev` serves the diagnostic channel on.
|
|
53
|
+
*
|
|
54
|
+
* Under `/__uf/`, beside the update stream `@uniflowed/hmr` opens and the
|
|
55
|
+
* vitals endpoint `@uniflowed/web/vitals` posts to, for the reason that prefix
|
|
56
|
+
* exists: a directory in `app/` whose name begins with `_` is not a route, so
|
|
57
|
+
* no application can put anything here and nothing here can shadow a path a
|
|
58
|
+
* project wrote.
|
|
59
|
+
*/
|
|
60
|
+
export const DIAGNOSTIC_ENDPOINT: string = "/__uf/diagnostic";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How loudly a diagnostic reads in the terminal.
|
|
64
|
+
*
|
|
65
|
+
* `error` for something that is wrong, `warn` for something that is worth
|
|
66
|
+
* knowing, `info` for something that is only a measurement. Three rather than
|
|
67
|
+
* two because the vitals report on the same channel needs the third: a page
|
|
68
|
+
* whose numbers are all good has still reported, and printing that as a warning
|
|
69
|
+
* would teach the reader to ignore the warnings.
|
|
70
|
+
*/
|
|
71
|
+
export type DiagnosticSeverity = "error" | "warn" | "info";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One diagnostic, as the channel carries it.
|
|
75
|
+
*
|
|
76
|
+
* `message` is the headline and the only required field: one line, the thing
|
|
77
|
+
* that is wrong. `detail` is everything under it — the values that differed,
|
|
78
|
+
* the path through the tree, the advice — and is a list of lines rather than a
|
|
79
|
+
* blob so the terminal can indent them without guessing where they break.
|
|
80
|
+
*
|
|
81
|
+
* `url` is the page the browser was on, which the reporter fills in when the
|
|
82
|
+
* caller does not: a diagnostic that does not say which page produced it is a
|
|
83
|
+
* diagnostic somebody has to reproduce before they can act on it.
|
|
84
|
+
*
|
|
85
|
+
* `file`, `line` and `column` are for the rare browser-side report that knows a
|
|
86
|
+
* source position. When the file and the line are both there `uf dev` draws its
|
|
87
|
+
* ordinary code frame; when they are not it prints the headline and the detail,
|
|
88
|
+
* which is what a hydration mismatch — a fact about a DOM node rather than
|
|
89
|
+
* about a line — can honestly offer.
|
|
90
|
+
*/
|
|
91
|
+
export type BrowserDiagnostic = {
|
|
92
|
+
readonly severity: DiagnosticSeverity,
|
|
93
|
+
readonly message: string,
|
|
94
|
+
readonly detail?: $ReadOnlyArray<string>,
|
|
95
|
+
readonly url?: string,
|
|
96
|
+
readonly file?: string,
|
|
97
|
+
readonly line?: number,
|
|
98
|
+
readonly column?: number,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The part of the browser this module needs. */
|
|
102
|
+
type ReportingWindow = {
|
|
103
|
+
readonly location?: { readonly href?: string, ... },
|
|
104
|
+
readonly fetch?: (input: string, init: { ... }) => Promise<mixed>,
|
|
105
|
+
...
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** For a promise whose outcome is deliberately not looked at. */
|
|
109
|
+
function noop(): void {}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send one diagnostic to `uf dev`, if there is a `uf dev` to send it to.
|
|
113
|
+
*
|
|
114
|
+
* Returns nothing and throws nothing. A diagnostic is a thing a person reads,
|
|
115
|
+
* not a thing an application branches on, and a reporter that could fail would
|
|
116
|
+
* make every caller wrap it — from a code path that is, by construction,
|
|
117
|
+
* already handling something that went wrong.
|
|
118
|
+
*
|
|
119
|
+
* `fetch` rather than `sendBeacon`, which is the opposite of the choice
|
|
120
|
+
* `vitalsBeacon` makes and for the opposite reason: a vital is measured as the
|
|
121
|
+
* page is put away and needs a transport that outlives it, while a diagnostic
|
|
122
|
+
* is produced by a page that is still running and had better arrive in the
|
|
123
|
+
* order it happened. `keepalive` covers the case where the page goes away
|
|
124
|
+
* immediately afterwards anyway.
|
|
125
|
+
*
|
|
126
|
+
* @param diagnostic what to report
|
|
127
|
+
* @param endpoint where to post it; [`DIAGNOSTIC_ENDPOINT`] by default
|
|
128
|
+
*/
|
|
129
|
+
export function reportDiagnostic(diagnostic: BrowserDiagnostic, endpoint?: string): void {
|
|
130
|
+
const win = reportingWindow();
|
|
131
|
+
if (win == null) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const post = win.fetch;
|
|
135
|
+
if (post == null) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body = JSON.stringify({
|
|
140
|
+
...diagnostic,
|
|
141
|
+
url: diagnostic.url ?? win.location?.href ?? "",
|
|
142
|
+
});
|
|
143
|
+
// A rejected promise nobody is holding becomes an unhandled rejection, which
|
|
144
|
+
// arrives in whatever error reporter the application installed — from a line
|
|
145
|
+
// about development tooling. Losing the report is the right outcome when
|
|
146
|
+
// there is nothing listening; reporting the loss as an application error is
|
|
147
|
+
// not.
|
|
148
|
+
post(endpoint ?? DIAGNOSTIC_ENDPOINT, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
body,
|
|
151
|
+
keepalive: true,
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
}).then(noop, noop);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The window to report from, or `null` where there is no browser.
|
|
158
|
+
*
|
|
159
|
+
* The same reading `@uniflowed/web/vitals` makes, and for the same reason: in a
|
|
160
|
+
* browser `globalThis` *is* the window, but not where a document has been
|
|
161
|
+
* installed onto another host's global, which is every uf test process. Asking
|
|
162
|
+
* for the document first is what makes both cases work.
|
|
163
|
+
*/
|
|
164
|
+
function reportingWindow(): ReportingWindow | null {
|
|
165
|
+
if (typeof globalThis.document === "undefined") {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return globalThis.window ?? globalThis;
|
|
169
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what renders in place of a subtree that threw.
|
|
4
|
+
//
|
|
5
|
+
// The framework's error page, the component that picks between it and a
|
|
6
|
+
// project's `$error.js`, the page a route that resolved to its error boundary
|
|
7
|
+
// renders, and the class boundary that catches a throw while the browser
|
|
8
|
+
// renders. Every one of them is the browser's: a boundary is a class, and a
|
|
9
|
+
// retry is a navigation, so none of this can run in a graph resolved under
|
|
10
|
+
// React's `react-server` condition — which is why it is out of `./runtime.js`'s
|
|
11
|
+
// top half and in a module of its own. See ubugeeei-prod/uf#519.
|
|
12
|
+
|
|
13
|
+
"use client";
|
|
14
|
+
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
|
|
17
|
+
import type { RouteError } from "./routing.js";
|
|
18
|
+
import type { ErrorModule } from "./resolve.js";
|
|
19
|
+
import { errorTitle, renderable, routeErrorFor } from "./resolve.js";
|
|
20
|
+
import { useRouterState } from "./runtime.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The framework's error page, for a project that declares no `$error.js`.
|
|
24
|
+
*
|
|
25
|
+
* It says which of the three happened and offers the reset, and it does *not*
|
|
26
|
+
* print the thrown error: on the server that message is written for whoever
|
|
27
|
+
* deployed the application — a query, a path, a token in a stack — and this
|
|
28
|
+
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
29
|
+
* the terminal and `uf build` fails the route, which are the places the person
|
|
30
|
+
* who can act on it is looking.
|
|
31
|
+
*/
|
|
32
|
+
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
33
|
+
const title = errorTitle(error);
|
|
34
|
+
const detail = match (error) {
|
|
35
|
+
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
36
|
+
{kind: "forbidden"} => "You do not have access to this page.",
|
|
37
|
+
{kind: "thrown"} => "This page could not be rendered.",
|
|
38
|
+
};
|
|
39
|
+
return (
|
|
40
|
+
<main>
|
|
41
|
+
<title>{title}</title>
|
|
42
|
+
<h1>{title}</h1>
|
|
43
|
+
<p>{detail}</p>
|
|
44
|
+
<button type="button" onClick={reset}>
|
|
45
|
+
Try again
|
|
46
|
+
</button>
|
|
47
|
+
</main>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The component an error module renders: `default`, or the named `Error`. */
|
|
52
|
+
function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
|
|
53
|
+
const component = module.default ?? module.Error;
|
|
54
|
+
if (component == null) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return renderable(component);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The props an error boundary's component receives. */
|
|
63
|
+
type ErrorRenderProps = {|
|
|
64
|
+
readonly error: RouteError,
|
|
65
|
+
readonly reset: () => void,
|
|
66
|
+
|};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The error UI, from whichever module is in scope.
|
|
70
|
+
*
|
|
71
|
+
* One component for both ways in — the class boundary below, which catches a
|
|
72
|
+
* throw while the browser renders, and `ResolvedErrorPage`, which is what the
|
|
73
|
+
* server renders because React's boundaries do not run in `renderToString`.
|
|
74
|
+
* Two paths to the same screen is exactly the pair that drifts.
|
|
75
|
+
*/
|
|
76
|
+
component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
|
|
77
|
+
if (module == null) {
|
|
78
|
+
return <DefaultRouteError error={error} reset={reset} />;
|
|
79
|
+
}
|
|
80
|
+
const Boundary = errorComponent(module);
|
|
81
|
+
// The error module's own export, looked up by route, for the same reason as
|
|
82
|
+
// the page component in `compose.js`: the same component on every render of
|
|
83
|
+
// that route, through a lookup the compiler cannot see into.
|
|
84
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
85
|
+
return <Boundary error={error} reset={reset} />;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The page of a route that resolved to an error.
|
|
90
|
+
*
|
|
91
|
+
* A resolved error route carries the error and the module on the route itself,
|
|
92
|
+
* so this is a static component rather than a closure the resolver builds:
|
|
93
|
+
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
94
|
+
* makes "inside the layouts above the boundary" one code path and not two.
|
|
95
|
+
*
|
|
96
|
+
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
97
|
+
* because a loader or an import threw, so re-running the resolution is what
|
|
98
|
+
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
99
|
+
* a static render has nothing to re-run.
|
|
100
|
+
*/
|
|
101
|
+
export component ResolvedErrorPage() {
|
|
102
|
+
const { route, view, router } = useRouterState();
|
|
103
|
+
const reset = () => {
|
|
104
|
+
router.refresh().catch(() => {});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Unreachable otherwise: this is only ever the page of an error route that was
|
|
108
|
+
// resolved from its modules. A route React Server Components rendered has
|
|
109
|
+
// [`ErrorRoutePage`] instead, with the boundary's module handed over as a prop.
|
|
110
|
+
if (route.error == null || view.kind !== "modules") {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<RouteErrorView module={view.resolved.errorBoundary.module} error={route.error} reset={reset} />
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The page of a route that resolved to an error, rendered by React Server
|
|
120
|
+
* Components.
|
|
121
|
+
*
|
|
122
|
+
* [`ResolvedErrorPage`] reads the error and the boundary's module out of the
|
|
123
|
+
* router, which holds a whole resolved route in a single-page application. The
|
|
124
|
+
* route a Flight payload hands the browser carries neither — the module is the
|
|
125
|
+
* server's, and what crosses is the part a hook reads — so the Flight renderer
|
|
126
|
+
* passes both as props: the boundary's module as `{ default: <client
|
|
127
|
+
* reference> }`, which is what a `"use client"` `$error.js` becomes on the way,
|
|
128
|
+
* and the error, which React serialises the way it serialises any error value.
|
|
129
|
+
* The retry is the same `router.refresh()`, because trying again still means
|
|
130
|
+
* resolving the route again. See ubugeeei-prod/uf#519.
|
|
131
|
+
*/
|
|
132
|
+
export component ErrorRoutePage(module: ?ErrorModule, error: RouteError) {
|
|
133
|
+
const { router } = useRouterState();
|
|
134
|
+
const reset = () => {
|
|
135
|
+
router.refresh().catch(() => {});
|
|
136
|
+
};
|
|
137
|
+
return <RouteErrorView module={module} error={error} reset={reset} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type RouteErrorBoundaryProps = {|
|
|
141
|
+
readonly module: ?ErrorModule,
|
|
142
|
+
readonly resetKey: string,
|
|
143
|
+
readonly children: React.Node,
|
|
144
|
+
|};
|
|
145
|
+
|
|
146
|
+
type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The boundary that catches a throw while the browser renders the subtree.
|
|
150
|
+
*
|
|
151
|
+
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
152
|
+
* there is no hook that does it — this is the one place in the router where
|
|
153
|
+
* following React's public contract means not using a function component.
|
|
154
|
+
*
|
|
155
|
+
* Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
|
|
156
|
+
* `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
|
|
157
|
+
* navigation, error or not, and everything below the boundary goes with it —
|
|
158
|
+
* which is the layouts, whose whole purpose is to survive navigation with
|
|
159
|
+
* their scroll position and their open sections intact.
|
|
160
|
+
*/
|
|
161
|
+
export class RouteErrorBoundary extends React.Component<
|
|
162
|
+
RouteErrorBoundaryProps,
|
|
163
|
+
RouteErrorBoundaryState,
|
|
164
|
+
> {
|
|
165
|
+
constructor(props: RouteErrorBoundaryProps) {
|
|
166
|
+
super(props);
|
|
167
|
+
this.state = { error: null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
|
|
171
|
+
return { error: routeErrorFor(error) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
componentDidUpdate(previous: RouteErrorBoundaryProps) {
|
|
175
|
+
if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
|
|
176
|
+
this.setState({ error: null });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
render(): React.Node {
|
|
181
|
+
const { error } = this.state;
|
|
182
|
+
if (error == null) {
|
|
183
|
+
return this.props.children;
|
|
184
|
+
}
|
|
185
|
+
return (
|
|
186
|
+
<RouteErrorView
|
|
187
|
+
module={this.props.module}
|
|
188
|
+
error={error}
|
|
189
|
+
reset={() => this.setState({ error: null })}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|