@uniflowed/router 0.0.0-alpha.13 → 0.0.0-alpha.15

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 CHANGED
@@ -18,6 +18,37 @@
18
18
  // Development only, and dynamically imported so a production bundle has no path
19
19
  // to it. See ubugeeei-prod/uf#508.
20
20
  //
21
+ // # And whether React DevTools can see the page at all
22
+ //
23
+ // The other question only this module is in a position to ask.
24
+ // `@uniflowed/vite` installs the hook DevTools attaches through, above every
25
+ // module in the document; whether that worked *on this page* is a fact about a
26
+ // running browser, and the line after hydration is where it can be read.
27
+ // `internal/devtools.js` has the two findings and sends them to the same
28
+ // terminal the hydration report goes to. See ubugeeei-prod/uf#503.
29
+ //
30
+ // # Strict Mode, in development, by default
31
+ //
32
+ // `uf dev` generates `strictMode: true` into `virtual:uf/client` and `uf build`
33
+ // does not, so a development render is doubled and a visitor's is not. That is
34
+ // React's own check for the thing it cannot check any other way: a component
35
+ // whose render is not pure, and an effect whose cleanup does not undo its
36
+ // setup, both behave correctly until the one production render that interleaves
37
+ // with something — and Strict Mode makes them behave incorrectly at once, on
38
+ // the machine of the person writing them.
39
+ //
40
+ // The wrapper is the argument to `hydrateRoot` rather than something inside
41
+ // `<App>`, and that is load-bearing rather than tidy. React decides whether to
42
+ // double-invoke a mount's effects at the *topmost fiber it is placing*: if that
43
+ // fiber is not itself in Strict Mode, React stops there and never looks inside
44
+ // it. A `<StrictMode>` further down still doubles the renders under it — that
45
+ // comes from the fiber's own mode — and doubles no effect at all, so it would
46
+ // have bought the half of the check that is easy to notice and silently lost
47
+ // the half that finds the bug. It renders no element, so the hydrated tree is
48
+ // unchanged and the markup comparison above is unaffected.
49
+ // `app.react.strictMode: false` in `uf.config.js` turns it off. See
50
+ // ubugeeei-prod/uf#516.
51
+ //
21
52
  // # A route can decline to be hydrated
22
53
  //
23
54
  // uf's server-component analysis decides which routes have a `"use client"`
@@ -29,7 +60,7 @@
29
60
  // See ubugeeei-prod/uf#350.
30
61
 
31
62
  import * as React from "react";
32
- import { startTransition } from "react";
63
+ import { StrictMode, startTransition } from "react";
33
64
  import { hydrateRoot } from "react-dom/client";
34
65
 
35
66
  import {
@@ -54,6 +85,7 @@ export async function hydrate(options: {|
54
85
  readonly routes: RouteTable["routes"],
55
86
  readonly notFound: RouteTable["notFound"],
56
87
  readonly errors: RouteTable["errors"],
88
+ readonly strictMode?: boolean,
57
89
  |}): Promise<void> {
58
90
  const table: RouteTable = {
59
91
  routes: options.routes,
@@ -95,11 +127,28 @@ export async function hydrate(options: {|
95
127
  recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
96
128
  }
97
129
 
130
+ // `<StrictMode>` renders no element of its own, so the tree React hydrates
131
+ // against the server's markup is the same tree either way and the flag can
132
+ // be a development-only difference without being a hydration difference.
133
+ const tree = <App url={url} initial={resolved} />;
134
+
98
135
  startTransition(() => {
99
136
  hydrateRoot(
100
137
  container,
101
- <App url={url} initial={resolved} />,
138
+ options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
102
139
  recovery == null ? undefined : { onRecoverableError: recovery },
103
140
  );
104
141
  });
142
+
143
+ // And, in development only, whether the panel a developer is about to open
144
+ // can see any of that. `react-dom` announced itself while it was being
145
+ // imported — long before this line — so the answer is already settled and
146
+ // this only reads it. Behind the same `import.meta.hot` gate as the
147
+ // hydration reporter, dynamically imported for the same reason: a production
148
+ // bundle has no path to the module rather than merely no reason to run it.
149
+ // See `./internal/devtools.js` and ubugeeei-prod/uf#503.
150
+ if (import.meta.hot != null) {
151
+ const { reportDevtools } = await import("./internal/devtools.js");
152
+ reportDevtools(window);
153
+ }
105
154
  }
package/handler.js CHANGED
@@ -69,7 +69,7 @@
69
69
  // `after()` promises. A handler that streams its body has not sent a byte at
70
70
  // that point. See ubugeeei-prod/uf#389.
71
71
 
72
- import { noteRoute } from "@uniflowed/server/host";
72
+ import { asResponder, noteRoute } from "@uniflowed/server/host";
73
73
 
74
74
  import { requireRequest } from "./internal/request.js";
75
75
  import type { RouteParams } from "./internal/runtime.js";
@@ -157,10 +157,15 @@ export function createDispatcher(options: {|
157
157
  // In the host's request, so a handler that calls `headers()`,
158
158
  // `cookies()` or `after()` answers about the same one its guard did, and
159
159
  // what it defers is drained once, by the host, after the bytes are out.
160
- const response = await handler(request, {
161
- params,
162
- searchParams: url.searchParams,
163
- });
160
+ //
161
+ // And inside `asResponder`, which is the other half: a route handler is
162
+ // one of the two things that owns a response, so it is one of the two
163
+ // places `draftMode().enable()` is allowed — and the `Set-Cookie` it
164
+ // decided on is written onto the response below rather than left on an
165
+ // object the host is about to discard. See ubugeeei-prod/uf#282.
166
+ const response = await asResponder("a route handler", async () =>
167
+ handler(request, { params, searchParams: url.searchParams }),
168
+ );
164
169
 
165
170
  // A `HEAD` answered by `GET` must not carry the body. The test is
166
171
  // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
@@ -76,6 +76,8 @@
76
76
  // it. Written here because this is the file somebody reads before deciding
77
77
  // otherwise.
78
78
 
79
+ import { asResponder } from "@uniflowed/server/host";
80
+
79
81
  import {
80
82
  ACTION_CONTENT_TYPE,
81
83
  ACTION_HEADER,
@@ -198,32 +200,44 @@ export function createActionDispatcher(options: {|
198
200
  return refusal(500);
199
201
  }
200
202
 
201
- let result: mixed;
202
- try {
203
- // The build-time contract says this is `async (...ActionValue) => …`
204
- // (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
205
- // through a module loaded by a thunk. The arguments are the ones
206
- // `decodeActionArguments` produced, so what is unchecked here is the
207
- // shape of the function and not the shape of the payload.
208
- const call = action as $FlowFixMe;
209
- result = await call(...args);
210
- } catch (error) {
211
- report(record, error);
212
- return refusal(500);
213
- }
203
+ // Everything from here to the answer runs as the thing that owns this
204
+ // response, which is what makes `draftMode().enable()` legal in an action:
205
+ // a `"use server"` function is one of the two places uf lets draft mode be
206
+ // changed, and the `Set-Cookie` it decides on is written onto the response
207
+ // this returns rather than onto an object the host discards. See
208
+ // ubugeeei-prod/uf#282 and `asResponder`.
209
+ //
210
+ // The refusals stay outside it. A `403` for a cross-origin call must not
211
+ // carry a cookie the caller asked for, and a scope that covered them would
212
+ // be a scope in which nothing ran that could have asked.
213
+ return asResponder("a server action", async () => {
214
+ let result: mixed;
215
+ try {
216
+ // The build-time contract says this is `async (...ActionValue) => …`
217
+ // (`ServerActionBoundary` in `../action.js`), and Flow cannot read that
218
+ // through a module loaded by a thunk. The arguments are the ones
219
+ // `decodeActionArguments` produced, so what is unchecked here is the
220
+ // shape of the function and not the shape of the payload.
221
+ const call = action as $FlowFixMe;
222
+ result = await call(...args);
223
+ } catch (error) {
224
+ report(record, error);
225
+ return refusal(500);
226
+ }
214
227
 
215
- let answer: string;
216
- try {
217
- answer = encodeActionResult(result);
218
- } catch (error) {
219
- // The action ran and its return value cannot cross. Flow says so at
220
- // build time — `ServerActionBoundary` in `../action.js` holds every
221
- // action's return type against the grammar — so this is the case where
222
- // it was reached anyway, and half a value is worse than none.
223
- report(record, error);
224
- return refusal(500);
225
- }
226
- return new Response(answer, { status: 200, headers: { ...ANSWER_HEADERS } });
228
+ let answer: string;
229
+ try {
230
+ answer = encodeActionResult(result);
231
+ } catch (error) {
232
+ // The action ran and its return value cannot cross. Flow says so at
233
+ // build time — `ServerActionBoundary` in `../action.js` holds every
234
+ // action's return type against the grammar — so this is the case where
235
+ // it was reached anyway, and half a value is worse than none.
236
+ report(record, error);
237
+ return refusal(500);
238
+ }
239
+ return new Response(answer, { status: 200, headers: { ...ANSWER_HEADERS } });
240
+ });
227
241
  };
228
242
  }
229
243
 
@@ -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 `tests/library/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
+ * `tests/library/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 `tests/library/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
+ }
@@ -84,10 +84,21 @@
84
84
  // seed decided once and read by both sides (ubugeeei-prod/uf#554) — and this is
85
85
  // for the ones that still do.
86
86
  //
87
- // It does not reach the terminal either. `uf dev`'s diagnostics come up the
88
- // driver's event channel from the Node process, and a hydration mismatch
89
- // happens in the browser, which has no channel to send one back on. Naming
90
- // that here is more use than a half-built one: see ubugeeei-prod/uf#508.
87
+ // # It does reach the terminal
88
+ //
89
+ // It did not, once. `uf dev`'s diagnostics come up the driver's event channel
90
+ // from the Node process, and a hydration mismatch happens in the browser, which
91
+ // had no channel to send one back on — so this report existed in an overlay and
92
+ // in the console, and had to be noticed by somebody who knew to look. There is
93
+ // a channel now: `./diagnostics.js` posts to `/__uf/diagnostic`, `uf dev`
94
+ // answers it, and the same words this module formats for the overlay are
95
+ // printed by the same renderer that prints a type error. See
96
+ // ubugeeei-prod/uf#583, and #508 for where the gap was named.
97
+ //
98
+ // The overlay stays. It is in front of the reader who caused the mismatch by
99
+ // editing the page, and the terminal is for the one who did not.
100
+
101
+ import { reportDiagnostic } from "./diagnostics.js";
91
102
 
92
103
  /** Which of the usual causes the difference looks like. */
93
104
  export type HydrationCause = "variable-input" | "browser-only" | "invalid-nesting" | "unknown";
@@ -813,6 +824,13 @@ function paragraph(document: Document, text: string, className: string | null):
813
824
  * `reportError`, which is what React would have done: this callback replaces
814
825
  * React's default rather than adding to it, so anything it swallows is
815
826
  * swallowed for good.
827
+ *
828
+ * A mismatch goes to three places, and all three say the same words because all
829
+ * three come out of [`formatHydrationReport`]: the overlay, for the reader
830
+ * looking at the page; the console, for a headless run, a CI browser and a
831
+ * reader who closed the panel; and `uf dev`'s terminal, through
832
+ * [`reportDiagnostic`], for the reader who is not looking at the browser at
833
+ * all. See ubugeeei-prod/uf#583.
816
834
  */
817
835
  export function hydrationErrorHandler(
818
836
  container: Node,
@@ -848,6 +866,19 @@ export function hydrationErrorHandler(
848
866
  // console is where a headless run, a CI browser and a reader who closed
849
867
  // the panel all still see the report.
850
868
  console.error(text);
869
+ // And the terminal, where every other uf diagnostic already is. The
870
+ // headline is the first line and the rest is the detail, which is the
871
+ // shape the channel carries and is why the formatter puts the sentence
872
+ // first: one report, one wording, three places. No position goes with it —
873
+ // a mismatch is a fact about a DOM node rather than about a line of a
874
+ // file, and inventing a file and a line to earn a code frame would send
875
+ // the reader somewhere that is not the answer.
876
+ const [headlineLine, ...rest] = text.split("\n");
877
+ reportDiagnostic({
878
+ severity: "error",
879
+ message: headlineLine,
880
+ detail: rest,
881
+ });
851
882
  };
852
883
  }
853
884
 
@@ -15,10 +15,8 @@ import {
15
15
  createContext,
16
16
  startTransition,
17
17
  use,
18
- useCallback,
19
18
  useContext,
20
19
  useEffect,
21
- useMemo,
22
20
  useState,
23
21
  useSyncExternalStore,
24
22
  } from "react";
@@ -31,6 +29,12 @@ import {
31
29
  // being evaluated.
32
30
  import { flushSync } from "react-dom";
33
31
 
32
+ // The two things a render has to fix — its instant and its random seed — and
33
+ // the provider that fixes them. Imported here rather than left to the
34
+ // application, because a hydration guarantee nobody wires is not a guarantee:
35
+ // see [`routerView`] and ubugeeei-prod/uf#559.
36
+ import { RenderProvider } from "@uniflowed/hooks/render";
37
+
34
38
  // The id of the script the loader data is embedded in. It moved out of the
35
39
  // head and into the tree with ubugeeei-prod/uf#373 — see [`loaderDataScript`]
36
40
  // — so the module that renders it is this one rather than `../server.js`.
@@ -1530,9 +1534,9 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
1530
1534
  */
1531
1535
  component ResolvedErrorPage() {
1532
1536
  const { resolved, router } = useRouterState();
1533
- const reset = useCallback(() => {
1537
+ const reset = () => {
1534
1538
  router.refresh().catch(() => {});
1535
- }, [router]);
1539
+ };
1536
1540
 
1537
1541
  if (resolved.error == null) {
1538
1542
  // Unreachable: this module is only ever the page of a resolved error route.
@@ -1850,7 +1854,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1850
1854
  const [resolved, setResolved] = useState<ResolvedRoute>(initial);
1851
1855
  const [pending, setPending] = useState<boolean>(false);
1852
1856
 
1853
- const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
1857
+ const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
1854
1858
  if (!isBrowser()) {
1855
1859
  return;
1856
1860
  }
@@ -1899,7 +1903,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1899
1903
  setPending(false);
1900
1904
  throw error;
1901
1905
  }
1902
- }, []);
1906
+ };
1903
1907
 
1904
1908
  useEffect(() => {
1905
1909
  if (!isBrowser()) {
@@ -1930,59 +1934,53 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1930
1934
  };
1931
1935
  }, []);
1932
1936
 
1933
- const router = useMemo<Router>(
1934
- () => ({
1935
- push: (to, options) => navigate(to, options),
1936
- replace: (to) => navigate(to, { replace: true }),
1937
- prefetch: async (to) => {
1938
- if (!isBrowser()) {
1939
- return;
1940
- }
1941
- const target = new URL(to, window.location.href);
1942
- const matched = matchRoute(routeTable().routes, target.pathname);
1943
- const load = matched?.route.page;
1944
- if (matched == null || load == null) {
1945
- return;
1946
- }
1947
- await Promise.all([
1948
- loadOnce(load),
1949
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
1950
- ]);
1951
- },
1952
- refresh: async () => {
1953
- if (!isBrowser()) {
1954
- return;
1955
- }
1956
- const nextResolved = await resolveMatch(
1957
- routeTable(),
1958
- window.location.pathname + window.location.search,
1959
- );
1960
- // No view transition, and it is the one place that is right: a refresh
1961
- // is the same URL resolved again, so a transition would animate a page
1962
- // into itself — a cross-fade between two frames of the same thing,
1963
- // which is a flicker with a name.
1964
- startTransition(() => {
1965
- setResolved(nextResolved);
1966
- });
1967
- },
1968
- back: () => {
1969
- if (isBrowser()) {
1970
- window.history.back();
1971
- }
1972
- },
1973
- forward: () => {
1974
- if (isBrowser()) {
1975
- window.history.forward();
1976
- }
1977
- },
1978
- }),
1979
- [navigate],
1980
- );
1937
+ const router: Router = {
1938
+ push: (to, options) => navigate(to, options),
1939
+ replace: (to) => navigate(to, { replace: true }),
1940
+ prefetch: async (to) => {
1941
+ if (!isBrowser()) {
1942
+ return;
1943
+ }
1944
+ const target = new URL(to, window.location.href);
1945
+ const matched = matchRoute(routeTable().routes, target.pathname);
1946
+ const load = matched?.route.page;
1947
+ if (matched == null || load == null) {
1948
+ return;
1949
+ }
1950
+ await Promise.all([
1951
+ loadOnce(load),
1952
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
1953
+ ]);
1954
+ },
1955
+ refresh: async () => {
1956
+ if (!isBrowser()) {
1957
+ return;
1958
+ }
1959
+ const nextResolved = await resolveMatch(
1960
+ routeTable(),
1961
+ window.location.pathname + window.location.search,
1962
+ );
1963
+ // No view transition, and it is the one place that is right: a refresh
1964
+ // is the same URL resolved again, so a transition would animate a page
1965
+ // into itself — a cross-fade between two frames of the same thing,
1966
+ // which is a flicker with a name.
1967
+ startTransition(() => {
1968
+ setResolved(nextResolved);
1969
+ });
1970
+ },
1971
+ back: () => {
1972
+ if (isBrowser()) {
1973
+ window.history.back();
1974
+ }
1975
+ },
1976
+ forward: () => {
1977
+ if (isBrowser()) {
1978
+ window.history.forward();
1979
+ }
1980
+ },
1981
+ };
1981
1982
 
1982
- const value = useMemo<RouterState>(
1983
- () => ({ resolved, router, pending }),
1984
- [resolved, router, pending],
1985
- );
1983
+ const value: RouterState = { resolved, router, pending };
1986
1984
  return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
1987
1985
  }
1988
1986
 
@@ -2697,14 +2695,37 @@ function isExternal(to: string): boolean {
2697
2695
  * The argument documents where the routes live; the table itself is generated
2698
2696
  * from that directory at build time and installed by the entry that starts
2699
2697
  * the app, so the component only has to render it.
2698
+ *
2699
+ * # Why the render anchor is here
2700
+ *
2701
+ * `RenderProvider` fixes the render's instant, time zone and random seed once,
2702
+ * writes them into the markup and reads them back on the client, which is what
2703
+ * makes `useRenderedAt` and `useRandom` agree across hydration. An application
2704
+ * that did not render one got no error — it got the old behaviour, which is a
2705
+ * silent hydration mismatch in every page with a clock or a shuffle on it. A
2706
+ * guarantee that depends on remembering to opt in is not one, so the router
2707
+ * provides it and an application that wants different values *replaces* it by
2708
+ * rendering its own inside this one. See ubugeeei-prod/uf#559.
2709
+ *
2710
+ * Above `RouterProvider` rather than below it, because the route's own
2711
+ * modules — layouts as much as pages — are things that read a clock, and a
2712
+ * masthead showing the time is the first component anybody writes that does.
2713
+ *
2714
+ * It is safe above a root layout that renders `<html>` only because the
2715
+ * envelope's carrier is a `<meta>`: React hoists one into the head of a
2716
+ * document it rendered, and to the front of a tree that is not one, where uf's
2717
+ * shell lifts it into the head it wrote itself. `packages/hooks/render.js` has
2718
+ * the argument, and it is the reason the carrier is no longer a `<script>`.
2700
2719
  */
2701
2720
  export function routerView(root: string): React.ComponentType<AppProps> {
2702
2721
  void root;
2703
2722
  component App(url: string, initial: ResolvedRoute) {
2704
2723
  return (
2705
- <RouterProvider url={url} initial={initial}>
2706
- <RouteView />
2707
- </RouterProvider>
2724
+ <RenderProvider>
2725
+ <RouterProvider url={url} initial={initial}>
2726
+ <RouteView />
2727
+ </RouterProvider>
2728
+ </RenderProvider>
2708
2729
  );
2709
2730
  }
2710
2731
  return App;
package/middleware.js CHANGED
@@ -38,9 +38,18 @@
38
38
  // `@uniflowed/server/host`, once per request, around everything that answers
39
39
  // it — and the guard, the handler or page underneath it, and the render all
40
40
  // see that one context. So `cookies()` in a guard and `cookies()` in the page
41
- // it guards are the same cookies, `draftMode().enable()` in a guard is visible
42
- // to what it guards, and every `after()` on the request is one ordered list
43
- // the host drains after the response has gone.
41
+ // it guards are the same cookies, `draftMode().isEnabled` gives a guard and
42
+ // the page under it the same answer, and every `after()` on the request is one
43
+ // ordered list the host drains after the response has gone.
44
+ //
45
+ // *Changing* draft mode is not a guard's to do, and that is a separate rule
46
+ // with a separate reason: `enable()` writes a cookie, a cookie is part of a
47
+ // response, and a guard may decline — so a guard that turned draft mode on and
48
+ // then let the request through would have made a decision with nowhere to be
49
+ // written. `asResponder` marks the two calls that do own a response, a route
50
+ // handler and a server action, and `draftMode().enable()` refuses anywhere
51
+ // else by name. A guard that wants draft mode on answers with a redirect to
52
+ // the handler that turns it on. See ubugeeei-prod/uf#282.
44
53
  //
45
54
  // It used to be the other way, and it is worth saying why that was wrong
46
55
  // rather than merely different: this module built its own context and drained
@@ -140,8 +149,8 @@ export function createMiddlewareRunner(options: {|
140
149
  const middleware = pick(await record.load(), record.file);
141
150
  // In the host's context, not one of this module's own. Two middleware on
142
151
  // the same path see the same cookies, and so does the handler or the page
143
- // underneath them: `draftMode().enable()` in a guard is visible to what
144
- // it guards, and every `after()` on the request lands in one ordered list
152
+ // underneath them: `draftMode().isEnabled` is one answer for the whole
153
+ // request, and every `after()` on the request lands in one ordered list
145
154
  // that the host drains once, after the response has gone.
146
155
  const result = await middleware(request, { params, searchParams: url.searchParams });
147
156
  if (result != null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.13",
3
+ "version": "0.0.0-alpha.15",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "react-dom": ">=19"
34
34
  },
35
35
  "dependencies": {
36
- "@uniflowed/server": "0.0.0-alpha.13"
36
+ "@uniflowed/hooks": "0.0.0-alpha.15",
37
+ "@uniflowed/server": "0.0.0-alpha.15"
37
38
  }
38
39
  }