@uniflowed/router 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +94 -57
- package/index.js +26 -2
- package/internal/error-view.js +60 -6
- package/internal/flight-browser.js +25 -1
- package/internal/flight.js +16 -2
- package/internal/resolve.js +37 -2
- package/internal/resolved-summary.js +7 -3
- package/internal/routing.js +121 -7
- package/internal/runtime.js +108 -6
- package/internal/search-params.js +234 -0
- package/middleware.js +6 -0
- package/native.js +3 -0
- package/package.json +4 -3
- package/routing.js +4 -0
- package/rsc-client.js +2 -0
- package/rsc-ssr.js +9 -2
- package/rsc.js +57 -15
- package/server-components.js +3 -1
- package/server.js +1 -0
package/action.js
CHANGED
|
@@ -273,66 +273,102 @@ export function registerServerAction<T>(fn: T, id: string): T {
|
|
|
273
273
|
return fn;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Call the server reference a Flight payload handed a client component.
|
|
278
|
+
*
|
|
279
|
+
* A Server Component may pass a `"use server"` function to a Client Component
|
|
280
|
+
* as a prop. In the rsc graph each callable export is registered with React's
|
|
281
|
+
* `registerServerReference` under its action id (`registerServerFunction` in
|
|
282
|
+
* `./rsc.js`), so Flight writes it as a server reference rather than refusing a
|
|
283
|
+
* function. React's browser client decodes that reference into a function
|
|
284
|
+
* that calls back here with the reference's id and the arguments, including
|
|
285
|
+
* any `.bind(null, …)` bound on the server, which React has already placed
|
|
286
|
+
* first. `hydrateFlight` installs this as that callback.
|
|
287
|
+
*
|
|
288
|
+
* `reference` is what `registerServerReference` wrote: the action id, `#`, and
|
|
289
|
+
* the `module#export` name. The call is the same one an imported reference
|
|
290
|
+
* makes, over the same JSON wire, with the same grammar, `Origin` check and
|
|
291
|
+
* deployment header. There is no second wire. An argument outside the grammar,
|
|
292
|
+
* a bound one included, is an `ActionValueError` before anything is sent. See
|
|
293
|
+
* ubugeeei-prod/uf#1359.
|
|
294
|
+
*/
|
|
295
|
+
export function callServerReference(
|
|
296
|
+
reference: string,
|
|
297
|
+
args: $ReadOnlyArray<mixed>,
|
|
298
|
+
): Promise<ActionValue | void> {
|
|
299
|
+
const hash = reference.indexOf("#");
|
|
300
|
+
const id = hash === -1 ? reference : reference.slice(0, hash);
|
|
301
|
+
const name = hash === -1 ? reference : reference.slice(hash + 1);
|
|
302
|
+
return sendServerAction(id, name, args);
|
|
303
|
+
}
|
|
304
|
+
|
|
276
305
|
/** The network call one reference makes. */
|
|
277
306
|
function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
): Promise<ActionValue | void> {
|
|
281
|
-
const body = encodeActionArguments(args);
|
|
282
|
-
// The page the call is made from, kept for the answer: a relative redirect
|
|
283
|
-
// means relative to this page, whatever the visitor has done since.
|
|
284
|
-
const from = currentUrl();
|
|
285
|
-
// Built rather than written as a literal, because the header's name is a
|
|
286
|
-
// constant and a computed key in an object literal is a shape Flow
|
|
287
|
-
// declines to track.
|
|
288
|
-
const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
|
|
289
|
-
headers[ACTION_HEADER] = id;
|
|
290
|
-
// Which build this page is, so a server on another build refuses the call
|
|
291
|
-
// rather than looking this id up in a table it was never in.
|
|
292
|
-
withDeployment(headers);
|
|
293
|
-
const response = await fetch(from, {
|
|
294
|
-
method: "POST",
|
|
295
|
-
// Stated rather than left to the default, because the default is what a
|
|
296
|
-
// reader has to look up and because this one is load-bearing: the
|
|
297
|
-
// endpoint's `Origin` check is only meaningful for a request that
|
|
298
|
-
// carries the visitor's cookies in the first place.
|
|
299
|
-
credentials: "same-origin",
|
|
300
|
-
// Never a cached answer, and never one written to a cache: an action is
|
|
301
|
-
// a side effect, and `POST` responses are outside HTTP caching by
|
|
302
|
-
// default only until something decides otherwise.
|
|
303
|
-
cache: "no-store",
|
|
304
|
-
headers,
|
|
305
|
-
body,
|
|
306
|
-
});
|
|
307
|
-
// A route a navigation kept may no longer show what this action wrote, and
|
|
308
|
-
// which routes is the server's to know, so every kept route is asked for
|
|
309
|
-
// again. Whatever the status: an action that failed part-way may have
|
|
310
|
-
// written before it failed. See `./internal/navigation-cache.js`.
|
|
311
|
-
clearNavigationCache();
|
|
312
|
-
// The server is on another build: nothing ran, and nothing on this page
|
|
313
|
-
// can be called correctly any more. Load the page again, from the build
|
|
314
|
-
// that is live, and never settle — a rejection here would reach an error
|
|
315
|
-
// boundary for the moment before the document is replaced, and a result
|
|
316
|
-
// would be a lie. See `./internal/deployment.js`.
|
|
317
|
-
if (refusedAsAnotherDeployment(response)) {
|
|
318
|
-
loadDocument(currentUrl());
|
|
319
|
-
return new Promise<ActionValue | void>(() => {});
|
|
320
|
-
}
|
|
321
|
-
// The action called `redirect()`, `notFound()`, `unauthorized()` or
|
|
322
|
-
// `forbidden()`. Checked before the status, because three of the four are
|
|
323
|
-
// not `ok` and none of them is a failure.
|
|
324
|
-
const outcome = response.headers.get(ACTION_OUTCOME_HEADER);
|
|
325
|
-
if (outcome != null) {
|
|
326
|
-
void response.body?.cancel();
|
|
327
|
-
return followOutcome(name, outcome, response, from);
|
|
328
|
-
}
|
|
329
|
-
if (!response.ok) {
|
|
330
|
-
throw new ServerActionError(name, response.status);
|
|
331
|
-
}
|
|
332
|
-
return decodeActionResult(await response.text());
|
|
307
|
+
return function callServerAction(...args: Array<ActionArgument>): Promise<ActionValue | void> {
|
|
308
|
+
return sendServerAction(id, name, args);
|
|
333
309
|
};
|
|
334
310
|
}
|
|
335
311
|
|
|
312
|
+
/** Call action `id`, named `name` in an error, with `args`. */
|
|
313
|
+
async function sendServerAction(
|
|
314
|
+
id: string,
|
|
315
|
+
name: string,
|
|
316
|
+
args: $ReadOnlyArray<mixed>,
|
|
317
|
+
): Promise<ActionValue | void> {
|
|
318
|
+
const body = encodeActionArguments(args);
|
|
319
|
+
// The page the call is made from, kept for the answer: a relative redirect
|
|
320
|
+
// means relative to this page, whatever the visitor has done since.
|
|
321
|
+
const from = currentUrl();
|
|
322
|
+
// Built rather than written as a literal, because the header's name is a
|
|
323
|
+
// constant and a computed key in an object literal is a shape Flow
|
|
324
|
+
// declines to track.
|
|
325
|
+
const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
|
|
326
|
+
headers[ACTION_HEADER] = id;
|
|
327
|
+
// Which build this page is, so a server on another build refuses the call
|
|
328
|
+
// rather than looking this id up in a table it was never in.
|
|
329
|
+
withDeployment(headers);
|
|
330
|
+
const response = await fetch(from, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
// Stated rather than left to the default, because the default is what a
|
|
333
|
+
// reader has to look up and because this one is load-bearing: the
|
|
334
|
+
// endpoint's `Origin` check is only meaningful for a request that
|
|
335
|
+
// carries the visitor's cookies in the first place.
|
|
336
|
+
credentials: "same-origin",
|
|
337
|
+
// Never a cached answer, and never one written to a cache: an action is
|
|
338
|
+
// a side effect, and `POST` responses are outside HTTP caching by
|
|
339
|
+
// default only until something decides otherwise.
|
|
340
|
+
cache: "no-store",
|
|
341
|
+
headers,
|
|
342
|
+
body,
|
|
343
|
+
});
|
|
344
|
+
// A route a navigation kept may no longer show what this action wrote, and
|
|
345
|
+
// which routes is the server's to know, so every kept route is asked for
|
|
346
|
+
// again. Whatever the status: an action that failed part-way may have
|
|
347
|
+
// written before it failed. See `./internal/navigation-cache.js`.
|
|
348
|
+
clearNavigationCache();
|
|
349
|
+
// The server is on another build: nothing ran, and nothing on this page
|
|
350
|
+
// can be called correctly any more. Load the page again, from the build
|
|
351
|
+
// that is live, and never settle — a rejection here would reach an error
|
|
352
|
+
// boundary for the moment before the document is replaced, and a result
|
|
353
|
+
// would be a lie. See `./internal/deployment.js`.
|
|
354
|
+
if (refusedAsAnotherDeployment(response)) {
|
|
355
|
+
loadDocument(currentUrl());
|
|
356
|
+
return new Promise<ActionValue | void>(() => {});
|
|
357
|
+
}
|
|
358
|
+
// The action called `redirect()`, `notFound()`, `unauthorized()` or
|
|
359
|
+
// `forbidden()`. Checked before the status, because three of the four are
|
|
360
|
+
// not `ok` and none of them is a failure.
|
|
361
|
+
const outcome = response.headers.get(ACTION_OUTCOME_HEADER);
|
|
362
|
+
if (outcome != null) {
|
|
363
|
+
void response.body?.cancel();
|
|
364
|
+
return followOutcome(name, outcome, response, from);
|
|
365
|
+
}
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
throw new ServerActionError(name, response.status);
|
|
368
|
+
}
|
|
369
|
+
return decodeActionResult(await response.text());
|
|
370
|
+
}
|
|
371
|
+
|
|
336
372
|
/**
|
|
337
373
|
* Do in the browser what the action's routing call asked for.
|
|
338
374
|
*
|
|
@@ -344,8 +380,9 @@ function callServerActionFor(id: string, name: string): ServerActionFunction {
|
|
|
344
380
|
* - `not-found`, `unauthorized`, `forbidden`: throw the same error the server
|
|
345
381
|
* caught. React hands an error thrown by an action to the nearest error
|
|
346
382
|
* boundary, and uf's route boundary already tells `unauthorized()` and
|
|
347
|
-
* `forbidden()` apart. `notFound()` arrives as a `NotFoundError`, which
|
|
348
|
-
*
|
|
383
|
+
* `forbidden()` apart. `notFound()` arrives as a `NotFoundError`, which the
|
|
384
|
+
* boundary answers with the URL's not-found page rather than its error view
|
|
385
|
+
* (ubugeeei-prod/uf#1489).
|
|
349
386
|
*
|
|
350
387
|
* Any other value is a server this reference does not understand, and it is
|
|
351
388
|
* the same `ServerActionError` as any other failure.
|
package/index.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import * as React from "react";
|
|
33
33
|
|
|
34
|
+
import type { InferOutput } from "@uniflowed/validator";
|
|
35
|
+
|
|
34
36
|
import type { RouteError } from "./internal/runtime.js";
|
|
35
37
|
|
|
36
38
|
export type {
|
|
@@ -59,6 +61,8 @@ export type {
|
|
|
59
61
|
Router,
|
|
60
62
|
ResolvedSlot,
|
|
61
63
|
SearchParams,
|
|
64
|
+
SearchParamsAll,
|
|
65
|
+
SearchParamsIssue,
|
|
62
66
|
SlotRecord,
|
|
63
67
|
SlotRouteRecord,
|
|
64
68
|
TemplateModule,
|
|
@@ -72,6 +76,7 @@ export {
|
|
|
72
76
|
RedirectError,
|
|
73
77
|
RouteView,
|
|
74
78
|
RouterProvider,
|
|
79
|
+
SearchParamsError,
|
|
75
80
|
UnauthorizedError,
|
|
76
81
|
basePath,
|
|
77
82
|
buildRoute,
|
|
@@ -80,6 +85,7 @@ export {
|
|
|
80
85
|
matchRoute,
|
|
81
86
|
notFound,
|
|
82
87
|
parseSearch,
|
|
88
|
+
parseSearchAll,
|
|
83
89
|
permanentRedirect,
|
|
84
90
|
redirect,
|
|
85
91
|
resolveFailure,
|
|
@@ -96,16 +102,34 @@ export {
|
|
|
96
102
|
useSeo,
|
|
97
103
|
} from "./internal/runtime.js";
|
|
98
104
|
|
|
99
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Props a page receives.
|
|
107
|
+
*
|
|
108
|
+
* `TSearchParams` is the query string as the page is given it: the string map
|
|
109
|
+
* — one value per key, the last of a repeated one — unless the page exports a
|
|
110
|
+
* `searchParams` schema, when it is that schema's output. Spell it with
|
|
111
|
+
* [`SearchParamsOf`] so the prop and the export cannot drift apart:
|
|
112
|
+
*
|
|
113
|
+
* export const searchParams = object({ page: number(), tag: array(string()) });
|
|
114
|
+
* export component Page(...props: PageProps<{}, void, SearchParamsOf<typeof searchParams>>)
|
|
115
|
+
*/
|
|
100
116
|
export type PageProps<
|
|
101
117
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
102
118
|
TData = void,
|
|
119
|
+
TSearchParams = { readonly [string]: string },
|
|
103
120
|
> = {|
|
|
104
121
|
readonly params: TParams,
|
|
105
|
-
readonly searchParams:
|
|
122
|
+
readonly searchParams: TSearchParams,
|
|
106
123
|
readonly data: TData,
|
|
107
124
|
|};
|
|
108
125
|
|
|
126
|
+
/**
|
|
127
|
+
* What a page that exports `searchParams = schema` is given as its
|
|
128
|
+
* `searchParams` prop: the schema's output, the same type
|
|
129
|
+
* `@uniflowed/validator`'s `InferOutput` reads off it.
|
|
130
|
+
*/
|
|
131
|
+
export type SearchParamsOf<TSchema> = InferOutput<TSchema>;
|
|
132
|
+
|
|
109
133
|
/** Props an `$error.js` component receives. */
|
|
110
134
|
export type ErrorProps = {|
|
|
111
135
|
readonly error: RouteError,
|
package/internal/error-view.js
CHANGED
|
@@ -17,7 +17,8 @@ import * as React from "react";
|
|
|
17
17
|
import type { RouteError } from "./routing.js";
|
|
18
18
|
import type { ErrorModule } from "./resolve.js";
|
|
19
19
|
import { errorTitle, renderable, routeErrorFor } from "./resolve.js";
|
|
20
|
-
import {
|
|
20
|
+
import { NotFoundError } from "./routing.js";
|
|
21
|
+
import { showNotFoundPage, useRouterState } from "./runtime.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The framework's error page, for a project that declares no `$error.js`.
|
|
@@ -32,6 +33,7 @@ import { useRouterState } from "./runtime.js";
|
|
|
32
33
|
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
33
34
|
const title = errorTitle(error);
|
|
34
35
|
const detail = match (error) {
|
|
36
|
+
{kind: "badRequest", ...} => "This page cannot answer the query in its address.",
|
|
35
37
|
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
36
38
|
{kind: "forbidden"} => "You do not have access to this page.",
|
|
37
39
|
{kind: "thrown", ...} => "This page could not be rendered.",
|
|
@@ -143,7 +145,20 @@ type RouteErrorBoundaryProps = {|
|
|
|
143
145
|
readonly children: React.Node,
|
|
144
146
|
|};
|
|
145
147
|
|
|
146
|
-
type RouteErrorBoundaryState = {|
|
|
148
|
+
type RouteErrorBoundaryState = {|
|
|
149
|
+
readonly error: ?RouteError,
|
|
150
|
+
/**
|
|
151
|
+
* The subtree threw `NotFoundError`, and the router has been asked for the
|
|
152
|
+
* not-found page of the URL on screen. See [`RouteErrorBoundary`].
|
|
153
|
+
*/
|
|
154
|
+
readonly notFound: boolean,
|
|
155
|
+
/**
|
|
156
|
+
* How many times the boundary has let go of a `NotFoundError`: the key of the
|
|
157
|
+
* subtree, so what renders after it is mounted afresh. See
|
|
158
|
+
* [`RouteErrorBoundary`].
|
|
159
|
+
*/
|
|
160
|
+
readonly generation: number,
|
|
161
|
+
|};
|
|
147
162
|
|
|
148
163
|
/**
|
|
149
164
|
* The boundary that catches a throw while the browser renders the subtree.
|
|
@@ -157,6 +172,20 @@ type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
|
157
172
|
* navigation, error or not, and everything below the boundary goes with it —
|
|
158
173
|
* which is the layouts, whose whole purpose is to survive navigation with
|
|
159
174
|
* their scroll position and their open sections intact.
|
|
175
|
+
*
|
|
176
|
+
* # `notFound()` is not an error to show
|
|
177
|
+
*
|
|
178
|
+
* A loader's `notFound()` shows the project's not-found page because the
|
|
179
|
+
* resolver resolves the route again as that page, not because a boundary
|
|
180
|
+
* catches it. A `NotFoundError` that reaches this boundary — a hydrated server
|
|
181
|
+
* action that called `notFound()`, which React hands to the nearest boundary —
|
|
182
|
+
* gets the same page: the boundary renders nothing while the router resolves
|
|
183
|
+
* the not-found page for the URL on screen (`showNotFoundPage`), and lets go of
|
|
184
|
+
* the error in the transition that commits it (ubugeeei-prod/uf#1489). Letting
|
|
185
|
+
* go remounts the subtree, because a component that threw an action's error
|
|
186
|
+
* — a `useActionState` — throws it again on every render until it is
|
|
187
|
+
* remounted. Where the router has no not-found page to show, the error view is
|
|
188
|
+
* what renders, as it did before.
|
|
160
189
|
*/
|
|
161
190
|
export class RouteErrorBoundary extends React.Component<
|
|
162
191
|
RouteErrorBoundaryProps,
|
|
@@ -164,13 +193,35 @@ export class RouteErrorBoundary extends React.Component<
|
|
|
164
193
|
> {
|
|
165
194
|
constructor(props: RouteErrorBoundaryProps) {
|
|
166
195
|
super(props);
|
|
167
|
-
this.state = { error: null };
|
|
196
|
+
this.state = { error: null, notFound: false, generation: 0 };
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
|
|
199
|
+
static getDerivedStateFromError(error: mixed): Partial<RouteErrorBoundaryState> {
|
|
200
|
+
if (error instanceof NotFoundError) {
|
|
201
|
+
return { error: null, notFound: true };
|
|
202
|
+
}
|
|
171
203
|
return { error: routeErrorFor(error) };
|
|
172
204
|
}
|
|
173
205
|
|
|
206
|
+
componentDidCatch(error: mixed) {
|
|
207
|
+
if (!(error instanceof NotFoundError)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const recover = () => {
|
|
211
|
+
this.setState((state) => ({
|
|
212
|
+
error: null,
|
|
213
|
+
notFound: false,
|
|
214
|
+
generation: state.generation + 1,
|
|
215
|
+
}));
|
|
216
|
+
};
|
|
217
|
+
const giveUp = (failure: mixed) => {
|
|
218
|
+
this.setState({ error: routeErrorFor(failure), notFound: false });
|
|
219
|
+
};
|
|
220
|
+
showNotFoundPage(recover).then((shown) => {
|
|
221
|
+
if (!shown) giveUp(error);
|
|
222
|
+
}, giveUp);
|
|
223
|
+
}
|
|
224
|
+
|
|
174
225
|
componentDidUpdate(previous: RouteErrorBoundaryProps) {
|
|
175
226
|
if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
|
|
176
227
|
this.setState({ error: null });
|
|
@@ -178,9 +229,12 @@ export class RouteErrorBoundary extends React.Component<
|
|
|
178
229
|
}
|
|
179
230
|
|
|
180
231
|
render(): React.Node {
|
|
181
|
-
const { error } = this.state;
|
|
232
|
+
const { error, notFound, generation } = this.state;
|
|
233
|
+
if (notFound) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
182
236
|
if (error == null) {
|
|
183
|
-
return this.props.children
|
|
237
|
+
return <React.Fragment key={generation}>{this.props.children}</React.Fragment>;
|
|
184
238
|
}
|
|
185
239
|
return (
|
|
186
240
|
<RouteErrorView
|
|
@@ -23,13 +23,19 @@
|
|
|
23
23
|
// and turning it into an `import()` would be letting bytes decide which script
|
|
24
24
|
// runs.
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createFromFetch,
|
|
28
|
+
createFromReadableStream,
|
|
29
|
+
setServerCallback,
|
|
30
|
+
} from "react-server-dom-parcel/client.browser";
|
|
27
31
|
|
|
32
|
+
import { callServerReference } from "../action.js";
|
|
28
33
|
import { withDeployment } from "./deployment.js";
|
|
29
34
|
import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
|
|
30
35
|
import {
|
|
31
36
|
FLIGHT_CONTENT_TYPE,
|
|
32
37
|
INTERCEPTED_FROM_HEADER,
|
|
38
|
+
NOT_FOUND_HEADER,
|
|
33
39
|
type FetchedFlight,
|
|
34
40
|
type FlightFetchOptions,
|
|
35
41
|
type FlightRoot,
|
|
@@ -44,6 +50,21 @@ type ModuleNamespace = { readonly [string]: mixed };
|
|
|
44
50
|
/** The entry a refusal names: the one an application reaches this module through. */
|
|
45
51
|
const ENTRY = "@uniflowed/router/rsc/client";
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Install the call React's Flight client makes for a server reference.
|
|
55
|
+
*
|
|
56
|
+
* A Server Component that passes a `"use server"` function to a Client
|
|
57
|
+
* Component as a prop sends a server reference, and React's browser client
|
|
58
|
+
* decodes it into a function that calls this one callback with the reference's
|
|
59
|
+
* id and its arguments, bound ones first. uf answers with the call an imported
|
|
60
|
+
* reference makes, over the same JSON action wire (`callServerReference` in
|
|
61
|
+
* `../action.js`). See ubugeeei-prod/uf#1359.
|
|
62
|
+
*/
|
|
63
|
+
export function installServerCallback(): void {
|
|
64
|
+
requireServerComponentsReact(ENTRY);
|
|
65
|
+
setServerCallback(callServerReference);
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* Install the module hook React's Flight client resolves references through.
|
|
49
70
|
*
|
|
@@ -213,6 +234,9 @@ export async function fetchFlight(
|
|
|
213
234
|
if (interceptedFrom != null) {
|
|
214
235
|
headers.set(INTERCEPTED_FROM_HEADER, interceptedFrom);
|
|
215
236
|
}
|
|
237
|
+
if (options?.notFound === true) {
|
|
238
|
+
headers.set(NOT_FOUND_HEADER, "1");
|
|
239
|
+
}
|
|
216
240
|
let response: Response;
|
|
217
241
|
try {
|
|
218
242
|
response = await fetch(flightUrl(url), {
|
package/internal/flight.js
CHANGED
|
@@ -50,7 +50,7 @@ export type RouteState = {|
|
|
|
50
50
|
readonly deferred: ?Promise<mixed>,
|
|
51
51
|
readonly metadata: Metadata,
|
|
52
52
|
readonly viewTransition: ?string,
|
|
53
|
-
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
53
|
+
readonly status: 200 | 400 | 401 | 403 | 404 | 500,
|
|
54
54
|
readonly error: ?RouteError,
|
|
55
55
|
readonly interception?: ?FlightInterception,
|
|
56
56
|
|};
|
|
@@ -80,6 +80,13 @@ export type FlightInterception = {|
|
|
|
80
80
|
/** What the browser may send with a payload request. */
|
|
81
81
|
export type FlightFetchOptions = {|
|
|
82
82
|
readonly interceptedFrom?: string,
|
|
83
|
+
/**
|
|
84
|
+
* Ask for the URL's not-found page rather than its route: the payload a
|
|
85
|
+
* loader's `notFound()` would have answered with. What a route's error
|
|
86
|
+
* boundary asks for when a hydrated server action called `notFound()`
|
|
87
|
+
* (ubugeeei-prod/uf#1489).
|
|
88
|
+
*/
|
|
89
|
+
readonly notFound?: boolean,
|
|
83
90
|
|};
|
|
84
91
|
|
|
85
92
|
/**
|
|
@@ -125,7 +132,8 @@ export type FetchedFlight =
|
|
|
125
132
|
* shows and which a production payload drops along with every other `Error`
|
|
126
133
|
* message. The original value is still what the server reported: this is
|
|
127
134
|
* applied only to the copy that crosses. `unauthorized` and `forbidden` carry
|
|
128
|
-
* nothing and pass through as they are
|
|
135
|
+
* nothing and pass through as they are, and so does `badRequest`: its issues
|
|
136
|
+
* describe the query the visitor sent, not anything of the server's.
|
|
129
137
|
*/
|
|
130
138
|
export function crossableRouteError(error: ?RouteError): ?RouteError {
|
|
131
139
|
if (error == null || error.kind !== "thrown" || error.error instanceof Error) {
|
|
@@ -189,6 +197,12 @@ export const FLIGHT_CONTENT_TYPE: string = "text/x-component";
|
|
|
189
197
|
/** The request header that carries the page an intercepted payload is rendered over. */
|
|
190
198
|
export const INTERCEPTED_FROM_HEADER: string = "uf-intercepted-from";
|
|
191
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The request header that asks for a URL's not-found payload instead of its
|
|
202
|
+
* route. Its one value is `1`; see [`FlightFetchOptions`].
|
|
203
|
+
*/
|
|
204
|
+
export const NOT_FOUND_HEADER: string = "uf-not-found";
|
|
205
|
+
|
|
192
206
|
/**
|
|
193
207
|
* The URL of `url`'s payload: its pathname with [`FLIGHT_SEGMENT`] appended,
|
|
194
208
|
* and its query string unchanged.
|
package/internal/resolve.js
CHANGED
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
import * as React from "react";
|
|
25
25
|
|
|
26
26
|
import { ResolvedErrorPage } from "./error-view.js";
|
|
27
|
+
import { parseSearchParams } from "./search-params.js";
|
|
27
28
|
import {
|
|
28
29
|
ForbiddenError,
|
|
29
30
|
NotFoundError,
|
|
30
31
|
RedirectError,
|
|
32
|
+
SearchParamsError,
|
|
31
33
|
UnauthorizedError,
|
|
32
34
|
matchIn,
|
|
33
35
|
matchRoute,
|
|
@@ -50,6 +52,7 @@ import type {
|
|
|
50
52
|
SlotRouteRecord as RoutingSlotRouteRecord,
|
|
51
53
|
TemplateRecord as RoutingTemplateRecord,
|
|
52
54
|
} from "./routing.js";
|
|
55
|
+
import type { Schema } from "@uniflowed/validator";
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* A component found in a route module.
|
|
@@ -88,7 +91,8 @@ export type RouteComponent = React.ComponentType<empty>;
|
|
|
88
91
|
*/
|
|
89
92
|
export type PageRenderProps = {|
|
|
90
93
|
readonly params: RouteParams,
|
|
91
|
-
|
|
94
|
+
/** The string map, or the output of the page's `searchParams` schema. */
|
|
95
|
+
readonly searchParams: mixed,
|
|
92
96
|
readonly data: mixed,
|
|
93
97
|
|};
|
|
94
98
|
|
|
@@ -116,6 +120,13 @@ export type PageModule = {
|
|
|
116
120
|
readonly default?: RouteComponent,
|
|
117
121
|
readonly Page?: RouteComponent,
|
|
118
122
|
readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
123
|
+
/**
|
|
124
|
+
* A `@uniflowed/validator` schema for the query string. The page is then
|
|
125
|
+
* given its output as `searchParams` rather than the string map, and a
|
|
126
|
+
* query that does not fit it is the error boundary with a `400`. See
|
|
127
|
+
* `./search-params.js`.
|
|
128
|
+
*/
|
|
129
|
+
readonly searchParams?: Schema<mixed, mixed>,
|
|
119
130
|
readonly metadata?: Metadata,
|
|
120
131
|
readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
121
132
|
readonly generateStaticParams?: () =>
|
|
@@ -460,6 +471,11 @@ export type ResolvedRoute = {|
|
|
|
460
471
|
readonly path: string,
|
|
461
472
|
readonly params: RouteParams,
|
|
462
473
|
readonly searchParams: SearchParams,
|
|
474
|
+
/**
|
|
475
|
+
* What the page's `searchParams` schema made of the query, when it exports
|
|
476
|
+
* one; absent otherwise. [`pageSearchParams`] is what the page is given.
|
|
477
|
+
*/
|
|
478
|
+
readonly parsedSearchParams?: mixed,
|
|
463
479
|
readonly page: PageModule,
|
|
464
480
|
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
465
481
|
/** What the loader returned, once it has. `undefined` while `deferred` is set. */
|
|
@@ -490,7 +506,7 @@ export type ResolvedRoute = {|
|
|
|
490
506
|
* carries it and never reads it; see "View transitions".
|
|
491
507
|
*/
|
|
492
508
|
readonly viewTransition: ?string,
|
|
493
|
-
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
509
|
+
readonly status: 200 | 400 | 401 | 403 | 404 | 500,
|
|
494
510
|
/**
|
|
495
511
|
* Set when this resolution *is* the error page: the loader threw, or the
|
|
496
512
|
* server render did and the renderer resolved again. `null` on the ordinary
|
|
@@ -762,6 +778,12 @@ async function resolveRoute(
|
|
|
762
778
|
loadOnce(load),
|
|
763
779
|
Promise.all(matched.route.layouts.map((layout) => loadOnce(layout))),
|
|
764
780
|
]);
|
|
781
|
+
// Before anything else is started, and before the loader above all: a query
|
|
782
|
+
// the page's schema refuses is a `400`, and nothing the page does should run
|
|
783
|
+
// on a request it has said it cannot answer. The throw is a
|
|
784
|
+
// `SearchParamsError`, which `resolveMatch` hands to the error boundary.
|
|
785
|
+
const schema = page.searchParams;
|
|
786
|
+
const parsed = schema == null ? null : { value: await parseSearchParams(schema, search) };
|
|
765
787
|
// Started here and awaited at the end: the boundary's module does not depend
|
|
766
788
|
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
767
789
|
// never rejects, so an early throw below leaves no unhandled rejection.
|
|
@@ -832,6 +854,7 @@ async function resolveRoute(
|
|
|
832
854
|
path: matched.route.path,
|
|
833
855
|
params: matched.params,
|
|
834
856
|
searchParams,
|
|
857
|
+
...(parsed == null ? {} : { parsedSearchParams: parsed.value }),
|
|
835
858
|
page,
|
|
836
859
|
layouts,
|
|
837
860
|
data,
|
|
@@ -1299,9 +1322,20 @@ export function routeErrorFor(error: mixed): RouteError {
|
|
|
1299
1322
|
if (error instanceof ForbiddenError) {
|
|
1300
1323
|
return { kind: "forbidden" };
|
|
1301
1324
|
}
|
|
1325
|
+
if (error instanceof SearchParamsError) {
|
|
1326
|
+
return { kind: "badRequest", issues: error.issues };
|
|
1327
|
+
}
|
|
1302
1328
|
return { kind: "thrown", error };
|
|
1303
1329
|
}
|
|
1304
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* The `searchParams` a resolved route's page is given: its schema's output when
|
|
1333
|
+
* it exports one, and the query's string map when it does not.
|
|
1334
|
+
*/
|
|
1335
|
+
export function pageSearchParams(resolved: ResolvedRoute): mixed {
|
|
1336
|
+
return resolved.page.searchParams != null ? resolved.parsedSearchParams : resolved.searchParams;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1305
1339
|
/**
|
|
1306
1340
|
* The error boundary a route renders inside, loaded with the route rather than
|
|
1307
1341
|
* when it is needed.
|
|
@@ -1591,6 +1625,7 @@ component DefaultNotFound() {
|
|
|
1591
1625
|
/** The document title an error page gets when nothing declared one. */
|
|
1592
1626
|
export function errorTitle(error: RouteError): string {
|
|
1593
1627
|
return match (error) {
|
|
1628
|
+
{kind: "badRequest", ...} => "Bad request",
|
|
1594
1629
|
{kind: "unauthorized"} => "Sign in required",
|
|
1595
1630
|
{kind: "forbidden"} => "Not allowed",
|
|
1596
1631
|
{kind: "thrown", ...} => "Something went wrong",
|
|
@@ -6,12 +6,12 @@ import { routeBoundaries, suspenseId } from "./boundary-data.js";
|
|
|
6
6
|
import type { RouteBoundary } from "./boundary-data.js";
|
|
7
7
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
8
8
|
|
|
9
|
-
type RouteStatus = 200 | 401 | 403 | 404 | 500;
|
|
9
|
+
type RouteStatus = 200 | 400 | 401 | 403 | 404 | 500;
|
|
10
10
|
|
|
11
11
|
type RouteMetadata = { readonly [string]: mixed, ... };
|
|
12
12
|
|
|
13
13
|
type RouteErrorLike = {
|
|
14
|
-
readonly kind: "thrown" | "unauthorized" | "forbidden",
|
|
14
|
+
readonly kind: "thrown" | "unauthorized" | "forbidden" | "badRequest",
|
|
15
15
|
...
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -71,7 +71,8 @@ export type ResolvedTemplateSummary = {|
|
|
|
71
71
|
export type ResolvedRouteErrorSummary =
|
|
72
72
|
| {| readonly kind: "thrown" |}
|
|
73
73
|
| {| readonly kind: "unauthorized" |}
|
|
74
|
-
| {| readonly kind: "forbidden" |}
|
|
74
|
+
| {| readonly kind: "forbidden" |}
|
|
75
|
+
| {| readonly kind: "badRequest" |};
|
|
75
76
|
|
|
76
77
|
export type ResolvedErrorBoundarySummary = {|
|
|
77
78
|
readonly above: number,
|
|
@@ -195,5 +196,8 @@ function summarizeError(error: ?RouteErrorLike): ?ResolvedRouteErrorSummary {
|
|
|
195
196
|
if (error.kind === "forbidden") {
|
|
196
197
|
return { kind: "forbidden" };
|
|
197
198
|
}
|
|
199
|
+
if (error.kind === "badRequest") {
|
|
200
|
+
return { kind: "badRequest" };
|
|
201
|
+
}
|
|
198
202
|
return { kind: "thrown" };
|
|
199
203
|
}
|