@uniflowed/router 0.0.0-alpha.13 → 0.0.0-alpha.14
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/handler.js +10 -5
- package/internal/action-endpoint.js +39 -25
- package/internal/diagnostics.js +169 -0
- package/internal/hydration.js +35 -4
- package/internal/runtime.js +82 -61
- package/middleware.js +14 -5
- package/package.json +3 -2
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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,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 — the only caller is behind `import.meta.hot` in `../client.js`, so a
|
|
43
|
+
// production bundle has no path to this module rather than merely no answer
|
|
44
|
+
// 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
|
+
}
|
package/internal/hydration.js
CHANGED
|
@@ -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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
|
package/internal/runtime.js
CHANGED
|
@@ -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 =
|
|
1537
|
+
const reset = () => {
|
|
1534
1538
|
router.refresh().catch(() => {});
|
|
1535
|
-
}
|
|
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 =
|
|
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 =
|
|
1934
|
-
() => (
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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 =
|
|
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
|
-
<
|
|
2706
|
-
<
|
|
2707
|
-
|
|
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().
|
|
42
|
-
//
|
|
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().
|
|
144
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.0.0-alpha.14",
|
|
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/
|
|
36
|
+
"@uniflowed/hooks": "0.0.0-alpha.14",
|
|
37
|
+
"@uniflowed/server": "0.0.0-alpha.14"
|
|
37
38
|
}
|
|
38
39
|
}
|