@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (42) hide show
  1. package/action.js +301 -0
  2. package/client.js +295 -8
  3. package/handler.js +101 -17
  4. package/index.js +71 -4
  5. package/internal/action-endpoint.js +434 -0
  6. package/internal/action-wire.js +608 -0
  7. package/internal/base-path.js +175 -0
  8. package/internal/boundaries.js +481 -0
  9. package/internal/boundary-data.js +88 -0
  10. package/internal/compose.js +490 -0
  11. package/internal/devtools.js +131 -0
  12. package/internal/diagnostics.js +169 -0
  13. package/internal/error-view.js +193 -0
  14. package/internal/flight-browser.js +228 -0
  15. package/internal/flight-chunks.js +205 -0
  16. package/internal/flight-ssr.js +78 -0
  17. package/internal/flight.js +158 -0
  18. package/internal/head.js +219 -0
  19. package/internal/hydration.js +1085 -0
  20. package/internal/inspector.js +626 -0
  21. package/internal/navigation-cache.js +144 -0
  22. package/internal/payload-rows.js +270 -0
  23. package/internal/payload.js +685 -0
  24. package/internal/prepare-document.js +49 -0
  25. package/internal/react-version.js +77 -0
  26. package/internal/request.js +43 -0
  27. package/internal/resolve.js +1615 -0
  28. package/internal/resolved-summary.js +199 -0
  29. package/internal/routing.js +474 -0
  30. package/internal/runtime.js +1511 -543
  31. package/internal/server-route.js +58 -0
  32. package/internal/shell.js +115 -0
  33. package/internal/stream.js +1084 -0
  34. package/middleware.js +350 -0
  35. package/native.js +408 -0
  36. package/package.json +36 -7
  37. package/routing.js +51 -0
  38. package/rsc-client.js +120 -0
  39. package/rsc-ssr.js +440 -0
  40. package/rsc.js +334 -0
  41. package/server-components.js +159 -0
  42. package/server.js +448 -75
@@ -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
+ }