@uniflowed/router 0.4.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 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 async function callServerAction(
279
- ...args: Array<ActionArgument>
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 an
348
- * `$error.js` can test for.
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
- /** Props a page receives. */
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: { readonly [string]: string },
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,
@@ -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 { useRouterState } from "./runtime.js";
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 = {| readonly error: ?RouteError |};
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 { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
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), {
@@ -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.
@@ -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
- readonly searchParams: SearchParams,
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
  }