@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.
@@ -10,12 +10,34 @@
10
10
  /** One parameter a route path captures. */
11
11
  export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
12
12
 
13
- /** The parameters captured from a URL. A catch-all captures the rest as a list. */
13
+ /**
14
+ * The parameters captured from a URL. A catch-all captures the rest as a list:
15
+ * `[...slug]` at least one segment, `[[...slug]]` any number, none included.
16
+ */
14
17
  export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
15
18
 
16
- /** The query string, as a read-only map. */
19
+ /** The query string, as a read-only map. A repeated key keeps its last value. */
17
20
  export type SearchParams = { readonly [string]: string };
18
21
 
22
+ /**
23
+ * The query string with every value of a repeated key kept, in order:
24
+ * `?tag=a&tag=b` is `{ tag: ["a", "b"] }`. See [`parseSearchAll`].
25
+ */
26
+ export type SearchParamsAll = { readonly [string]: $ReadOnlyArray<string> };
27
+
28
+ /**
29
+ * One reason a query did not fit the schema a page declared for it.
30
+ *
31
+ * The shape of `@uniflowed/validator`'s `Issue`, written out here so that this
32
+ * file stays free of imports: `path` is where in the query the problem is —
33
+ * `["tags", "1"]` for the second `tag`.
34
+ */
35
+ export type SearchParamsIssue = {|
36
+ readonly code: string,
37
+ readonly message: string,
38
+ readonly path?: $ReadOnlyArray<string>,
39
+ |};
40
+
19
41
  /** A lazy route module entry from the generated route table. */
20
42
  export type RouteModule<TModule = mixed> = () => Promise<TModule>;
21
43
 
@@ -165,11 +187,18 @@ export type RouteMatch<TRoute extends { readonly path: string, ... } = UnknownRo
165
187
  export type RouteError =
166
188
  | {| readonly kind: "thrown", readonly error: mixed |}
167
189
  | {| readonly kind: "unauthorized" |}
168
- | {| readonly kind: "forbidden" |};
190
+ | {| readonly kind: "forbidden" |}
191
+ /**
192
+ * The query did not fit the schema the page exports as `searchParams`.
193
+ * The request's fault rather than the page's, so a `400` — and the issues
194
+ * say which parameter and why, so an `$error.js` can say so too.
195
+ */
196
+ | {| readonly kind: "badRequest", readonly issues: $ReadOnlyArray<SearchParamsIssue> |};
169
197
 
170
198
  /** The status a `RouteError` answers with. */
171
- export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
199
+ export function routeErrorStatus(error: RouteError): 400 | 401 | 403 | 500 {
172
200
  return match (error) {
201
+ {kind: "badRequest", ...} => 400,
173
202
  {kind: "unauthorized"} => 401,
174
203
  {kind: "forbidden"} => 403,
175
204
  {kind: "thrown", ...} => 500,
@@ -192,6 +221,24 @@ export class UnauthorizedError extends Error {
192
221
  }
193
222
  }
194
223
 
224
+ /**
225
+ * Thrown when a query does not fit the page's `searchParams` schema; the
226
+ * renderer answers with the error boundary, as a `400`.
227
+ */
228
+ export class SearchParamsError extends Error {
229
+ issues: $ReadOnlyArray<SearchParamsIssue>;
230
+
231
+ constructor(issues: $ReadOnlyArray<SearchParamsIssue>) {
232
+ super(
233
+ `the query string does not fit this page's searchParams schema: ${issues
234
+ .map((issue) => `${(issue.path ?? []).join(".") || "(query)"}: ${issue.message}`)
235
+ .join("; ")}`,
236
+ );
237
+ this.name = "SearchParamsError";
238
+ this.issues = issues;
239
+ }
240
+ }
241
+
195
242
  /** Thrown by `forbidden()`; the renderer answers with the error boundary. */
196
243
  export class ForbiddenError extends Error {
197
244
  constructor() {
@@ -281,13 +328,21 @@ export class RedirectError extends Error {
281
328
  type Segment =
282
329
  | {| readonly kind: "static", readonly value: string |}
283
330
  | {| readonly kind: "param", readonly name: string |}
284
- | {| readonly kind: "catchAll", readonly name: string |};
331
+ | {| readonly kind: "catchAll", readonly name: string |}
332
+ | {| readonly kind: "optionalCatchAll", readonly name: string |};
285
333
 
334
+ /**
335
+ * A route path, as segments: `posts` is static, `:slug` a parameter, `:slug*`
336
+ * a catch-all (`[...slug]`) and `:slug*?` an optional one (`[[...slug]]`).
337
+ */
286
338
  function compile(routePath: string): $ReadOnlyArray<Segment> {
287
339
  return routePath
288
340
  .split("/")
289
341
  .filter((segment) => segment !== "")
290
342
  .map((segment): Segment => {
343
+ if (segment.startsWith(":") && segment.endsWith("*?")) {
344
+ return { kind: "optionalCatchAll", name: segment.slice(1, -2) };
345
+ }
291
346
  if (segment.startsWith(":") && segment.endsWith("*")) {
292
347
  return { kind: "catchAll", name: segment.slice(1, -1) };
293
348
  }
@@ -300,7 +355,8 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
300
355
 
301
356
  /**
302
357
  * How specific a route is, for ranking: a static segment outranks a parameter,
303
- * which outranks a catch-all, and a longer path outranks a shorter one.
358
+ * which outranks a catch-all, which outranks an optional one, and a longer
359
+ * path outranks a shorter one.
304
360
  */
305
361
  function specificity(segments: $ReadOnlyArray<Segment>): number {
306
362
  let score = 0;
@@ -309,6 +365,7 @@ function specificity(segments: $ReadOnlyArray<Segment>): number {
309
365
  {kind: "static", ...} => 3,
310
366
  {kind: "param", ...} => 2,
311
367
  {kind: "catchAll", ...} => 1,
368
+ {kind: "optionalCatchAll", ...} => 0,
312
369
  };
313
370
  }
314
371
  return score;
@@ -336,6 +393,17 @@ function matchSegments(
336
393
  index += 1;
337
394
  }
338
395
  {kind: "catchAll", name: const name} => {
396
+ // `[...slug]` needs something to take: `/docs` is not
397
+ // `/docs/[...slug]`, which is what `[[...slug]]` is for. Without
398
+ // this a catch-all outranked the page at its parent path — one more
399
+ // segment is one more point — and answered `/docs` in its place.
400
+ if (index >= parts.length) {
401
+ return null;
402
+ }
403
+ params[name] = parts.slice(index).map(decodeSegment);
404
+ index = parts.length;
405
+ }
406
+ {kind: "optionalCatchAll", name: const name} => {
339
407
  params[name] = parts.slice(index).map(decodeSegment);
340
408
  index = parts.length;
341
409
  }
@@ -377,6 +445,26 @@ export function buildRoute(routePath: string, params?: RouteParams): string {
377
445
  describeParam(value),
378
446
  );
379
447
  }
448
+ // The URL an empty list builds is the parent path, which this route
449
+ // does not serve — a link that 404s. An optional catch-all does.
450
+ if (value.length === 0) {
451
+ throw new Error(
452
+ `route ${routePath} takes at least one segment for :${name}*, and got an empty ` +
453
+ `array; a route that also serves its parent path is [[...${name}]], :${name}*?`,
454
+ );
455
+ }
456
+ for (const part of value) {
457
+ parts.push(encodeURIComponent(part));
458
+ }
459
+ }
460
+ {kind: "optionalCatchAll", name: const name} => {
461
+ const value = values[name];
462
+ if (value == null || typeof value === "string") {
463
+ throw new Error(
464
+ `route ${routePath} takes an array of segments for :${name}*?, and got ` +
465
+ describeParam(value),
466
+ );
467
+ }
380
468
  for (const part of value) {
381
469
  parts.push(encodeURIComponent(part));
382
470
  }
@@ -455,7 +543,8 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
455
543
  const next = match (segment) {
456
544
  {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
457
545
  {kind: "param", ...} => index < parts.length ? index + 1 : -1,
458
- {kind: "catchAll", ...} => parts.length,
546
+ {kind: "catchAll", ...} => index < parts.length ? parts.length : -1,
547
+ {kind: "optionalCatchAll", ...} => parts.length,
459
548
  };
460
549
  if (next === -1) {
461
550
  return false;
@@ -517,6 +606,31 @@ export function parseSearch(search: string): SearchParams {
517
606
  return params;
518
607
  }
519
608
 
609
+ /**
610
+ * Every value of every key in a query string, in order.
611
+ *
612
+ * [`parseSearch`] keeps the last value of a repeated key, which is what a page
613
+ * that reads `searchParams.q` wants and loses `?tag=a&tag=b`'s first tag. This
614
+ * is the same query with nothing dropped, for a page that declares no schema
615
+ * and needs the repeats; a page that declares one gets arrays wherever the
616
+ * schema says array.
617
+ */
618
+ export function parseSearchAll(search: string): SearchParamsAll {
619
+ // A `Map` and then `Object.fromEntries`, which defines own properties: a
620
+ // query is written by whoever sent the request, and `?__proto__=x` must be a
621
+ // key like any other rather than a lookup that finds `Object.prototype`.
622
+ const params = new Map<string, Array<string>>();
623
+ for (const [key, value] of new URLSearchParams(search)) {
624
+ const values = params.get(key);
625
+ if (values == null) {
626
+ params.set(key, [value]);
627
+ } else {
628
+ values.push(value);
629
+ }
630
+ }
631
+ return Object.fromEntries(params);
632
+ }
633
+
520
634
  /** Stop rendering the current page and show the not-found page instead. */
521
635
  export function notFound(): empty {
522
636
  throw new NotFoundError();
@@ -101,6 +101,7 @@ import {
101
101
  routeNavigations,
102
102
  } from "./navigation-cache.js";
103
103
  import {
104
+ NotFoundError,
104
105
  hasClientPage,
105
106
  matchRoute,
106
107
  nearestBoundary,
@@ -112,17 +113,27 @@ import {
112
113
  beneath,
113
114
  interceptingRoutes,
114
115
  loadOnce,
116
+ pageSearchParams,
117
+ resolveFailure,
115
118
  resolveInterception,
116
119
  resolveMatch,
117
120
  } from "./resolve.js";
118
121
  import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
119
122
 
120
- export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
123
+ export type {
124
+ RouteError,
125
+ RouteParamSpec,
126
+ RouteParams,
127
+ SearchParams,
128
+ SearchParamsAll,
129
+ SearchParamsIssue,
130
+ } from "./routing.js";
121
131
 
122
132
  export {
123
133
  ForbiddenError,
124
134
  NotFoundError,
125
135
  RedirectError,
136
+ SearchParamsError,
126
137
  UnauthorizedError,
127
138
  buildRoute,
128
139
  forbidden,
@@ -130,6 +141,7 @@ export {
130
141
  matchRoute,
131
142
  notFound,
132
143
  parseSearch,
144
+ parseSearchAll,
133
145
  permanentRedirect,
134
146
  redirect,
135
147
  routeErrorStatus,
@@ -370,18 +382,60 @@ export type Router = {|
370
382
  */
371
383
  let mountedRouter: Router | null = null;
372
384
 
373
- /** Publish `router` as [`mountedRouter`] while its provider is mounted. */
374
- hook useMountedRouter(router: Router): void {
385
+ /**
386
+ * How the provider on screen shows its not-found page for the URL it is on, or
387
+ * `null` when none is mounted. See [`showNotFoundPage`].
388
+ */
389
+ let mountedNotFound: ShowNotFound | null = null;
390
+
391
+ /**
392
+ * Show the not-found page for the URL on screen, and run `alongside` in the
393
+ * same transition as the commit that shows it. Resolves `false`, having shown
394
+ * nothing, when there is no not-found page to be had for it.
395
+ */
396
+ type ShowNotFound = (alongside: () => void) => Promise<boolean>;
397
+
398
+ /**
399
+ * Publish `router` as [`mountedRouter`], and its not-found page as
400
+ * [`mountedNotFound`], while its provider is mounted.
401
+ */
402
+ hook useMountedRouter(router: Router, showNotFound: ShowNotFound): void {
375
403
  useEffect(() => {
376
404
  mountedRouter = router;
405
+ mountedNotFound = showNotFound;
377
406
  return () => {
378
407
  if (mountedRouter === router) {
379
408
  mountedRouter = null;
409
+ mountedNotFound = null;
380
410
  }
381
411
  };
382
412
  });
383
413
  }
384
414
 
415
+ /**
416
+ * Show the page a loader's `notFound()` would have shown for the URL on screen,
417
+ * because something below the route's error boundary threw `NotFoundError`.
418
+ *
419
+ * What a loader's `notFound()` shows is not a boundary catching it: the
420
+ * resolver resolves the route again as its not-found page (`resolveFailure`).
421
+ * A hydrated server action that calls `notFound()` throws the same error in the
422
+ * browser (`../action.js`), React hands it to the nearest boundary, and the
423
+ * boundary asks for that same resolution here rather than rendering the error
424
+ * view (ubugeeei-prod/uf#1489). A page rendered from its modules resolves it
425
+ * in the browser; a page React Server Components rendered asks the server for
426
+ * the not-found payload of its URL, because only the server has the module.
427
+ *
428
+ * `alongside` is the boundary letting go of the error, run in the transition
429
+ * that commits the not-found page so the page that threw is never rendered
430
+ * again in between. `false` when there is nothing to show — no router mounted,
431
+ * or a host that did not answer with a not-found payload — and the boundary
432
+ * then shows its error view, as it did before.
433
+ */
434
+ export function showNotFoundPage(alongside: () => void): Promise<boolean> {
435
+ const show = mountedNotFound;
436
+ return show == null ? Promise.resolve(false) : show(alongside);
437
+ }
438
+
385
439
  /**
386
440
  * Go where a server action's `redirect()` pointed.
387
441
  *
@@ -1046,7 +1100,25 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
1046
1100
  },
1047
1101
  };
1048
1102
 
1049
- useMountedRouter(router);
1103
+ // The URL on screen resolved as its not-found page, in the page: the table
1104
+ // this router resolves from has the not-found modules too. No history entry
1105
+ // is written, because the URL is still the one the visitor is on.
1106
+ const showNotFound = async (alongside: () => void): Promise<boolean> => {
1107
+ if (!isBrowser()) {
1108
+ return false;
1109
+ }
1110
+ const here =
1111
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
1112
+ window.location.search;
1113
+ const nextResolved = await resolveFailure(routeTable(), here, new NotFoundError());
1114
+ startTransition(() => {
1115
+ show(nextResolved);
1116
+ alongside();
1117
+ });
1118
+ return true;
1119
+ };
1120
+
1121
+ useMountedRouter(router, showNotFound);
1050
1122
 
1051
1123
  const value: RouterState = {
1052
1124
  route: routeState(resolved),
@@ -1320,7 +1392,37 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1320
1392
  },
1321
1393
  };
1322
1394
 
1323
- useMountedRouter(router);
1395
+ // The URL on screen as its not-found page, asked of the server: the payload
1396
+ // for this URL with `notFound`, which renders the not-found resolution a
1397
+ // loader's `notFound()` would have. Not kept in the navigation cache, because
1398
+ // it is not what a navigation to this URL shows. Anything that is not such a
1399
+ // payload — a document, a payload another build rendered, a host that ignored
1400
+ // the request and answered the page — is nothing to show, and the boundary
1401
+ // falls back to its error view.
1402
+ const showNotFound = async (alongside: () => void): Promise<boolean> => {
1403
+ if (!isBrowser()) {
1404
+ return false;
1405
+ }
1406
+ const fetched = await fetchFlight(window.location.pathname + window.location.search, {
1407
+ ...flightFetchOptions(root.route.interception?.from ?? null),
1408
+ notFound: true,
1409
+ });
1410
+ if (fetched.kind === "document") {
1411
+ return false;
1412
+ }
1413
+ const payload = fetched.root;
1414
+ const nextRoot = await payload;
1415
+ if (fromAnotherDeployment(nextRoot.deployment) || nextRoot.route.status !== 404) {
1416
+ return false;
1417
+ }
1418
+ startTransition(() => {
1419
+ show(payload, nextRoot);
1420
+ alongside();
1421
+ });
1422
+ return true;
1423
+ };
1424
+
1425
+ useMountedRouter(router, showNotFound);
1324
1426
 
1325
1427
  const value: RouterState = {
1326
1428
  route: root.route,
@@ -1616,7 +1718,7 @@ component RenderedPage(data: mixed) {
1616
1718
  // uf-lint-disable react-compiler/static-components
1617
1719
  return (
1618
1720
  <>
1619
- <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
1721
+ <Page params={resolved.params} searchParams={pageSearchParams(resolved)} data={data} />
1620
1722
  {payloadElements(data)}
1621
1723
  </>
1622
1724
  );
@@ -0,0 +1,234 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: a page's `searchParams` schema, run against
4
+ // the query string.
5
+ //
6
+ // A page that exports
7
+ //
8
+ // export const searchParams = object({ page: number(), tag: array(string()) });
9
+ //
10
+ // is handed the schema's output as its `searchParams` prop instead of the
11
+ // string map every other page gets. Two things stand between a query string
12
+ // and that schema, and both are this file.
13
+ //
14
+ // **Shape.** A query is a list of pairs, and a key may repeat. Which repeats
15
+ // are a list and which are one value is not something the query says — it is
16
+ // what the schema says, so the schema is asked: a field described as an array
17
+ // (or a set, or a tuple) gets every value in order, and any other field gets
18
+ // the last one, which is what `parseSearch` has always kept. A field that is a
19
+ // bare array and whose key is absent gets `[]`, because no `tag` in the query
20
+ // is no tags rather than a malformed request.
21
+ //
22
+ // **Type.** Every value in a query is a string, and `number()` refuses a string.
23
+ // A field described as a number, a boolean, a bigint or a date is given the
24
+ // value the string spells when it spells one — `"2"` is `2`, `"true"` is
25
+ // `true` — and the string itself when it does not, so the schema's own message
26
+ // is what a malformed value is reported with. A field the schema reads as a
27
+ // string is left alone, which is why `pipe(string(), transform(Number))` still
28
+ // sees the string it asked for.
29
+ //
30
+ // Everything past that is the schema's: optionality, defaults, refinements,
31
+ // transforms. A query that does not fit is a `SearchParamsError`, which the
32
+ // resolver turns into the error boundary with a `400` — the request's fault,
33
+ // not the page's.
34
+
35
+ // The two subpaths rather than the package: a browser bundle of the router
36
+ // carries the parser and the description reader, and none of the builders.
37
+ import { safeParseAsync } from "@uniflowed/validator/parse";
38
+ import { describe } from "@uniflowed/validator/schema";
39
+ import type { Description, Schema } from "@uniflowed/validator/schema";
40
+
41
+ import { SearchParamsError, parseSearchAll } from "./routing.js";
42
+ import type { SearchParamsAll } from "./routing.js";
43
+
44
+ /**
45
+ * The value a page's `searchParams` schema produces for `search`.
46
+ *
47
+ * Throws a [`SearchParamsError`] carrying every issue when the query does not
48
+ * fit, rather than returning them: the resolver's catch is what turns a thrown
49
+ * router error into a boundary, and this is one.
50
+ */
51
+ export async function parseSearchParams(
52
+ schema: Schema<mixed, mixed>,
53
+ search: string,
54
+ ): Promise<mixed> {
55
+ const input = searchParamsInput(describe(schema), parseSearchAll(search));
56
+ const result = await safeParseAsync(schema, input);
57
+ if (!result.ok) {
58
+ throw new SearchParamsError(result.issues);
59
+ }
60
+ return result.value;
61
+ }
62
+
63
+ /**
64
+ * The object a schema described by `description` is given for a query.
65
+ *
66
+ * Exported for the tests, which hold the shape and the coercion to what the
67
+ * header says without a route table around them.
68
+ */
69
+ export function searchParamsInput(
70
+ description: Description,
71
+ query: SearchParamsAll,
72
+ ): { readonly [string]: mixed } {
73
+ const fields = objectFields(description);
74
+ // Built as a `Map` and turned into an object once, by `Object.fromEntries`,
75
+ // which defines own properties: `?__proto__=x` is a key like any other.
76
+ const input = new Map<string, mixed>();
77
+ for (const key of Object.keys(query)) {
78
+ const values = query[key];
79
+ const field = fields.get(key);
80
+ if (field == null) {
81
+ // A key the schema does not name: an `object()` strips it and a
82
+ // `strictObject()` refuses it, and a `looseObject()` keeps it as the
83
+ // query had it — one value, or every value of a repeated key.
84
+ input.set(key, values.length === 1 ? values[0] : values);
85
+ } else {
86
+ input.set(key, fieldValue(field, values));
87
+ }
88
+ }
89
+ for (const [key, field] of fields) {
90
+ if (!input.has(key) && isList(plain(field))) {
91
+ input.set(key, []);
92
+ }
93
+ }
94
+ return Object.fromEntries(input);
95
+ }
96
+
97
+ /** The fields a schema names, when it is an object or an intersection of them. */
98
+ function objectFields(description: Description): Map<string, Description> {
99
+ const found = new Map<string, Description>();
100
+ const visit = (node: Description): void => {
101
+ const unwrapped = unwrap(node);
102
+ if (unwrapped.kind === "object") {
103
+ for (const [key, field] of unwrapped.entries) {
104
+ found.set(key, field);
105
+ }
106
+ } else if (unwrapped.kind === "intersect") {
107
+ for (const part of unwrapped.parts) {
108
+ visit(part);
109
+ }
110
+ }
111
+ };
112
+ visit(description);
113
+ return found;
114
+ }
115
+
116
+ /** What one field is given: every value for a list, the last for anything else. */
117
+ function fieldValue(field: Description, values: $ReadOnlyArray<string>): mixed {
118
+ const described = unwrap(field);
119
+ if (described.kind === "array" || described.kind === "set") {
120
+ const item = described.item;
121
+ return values.map((value) => coerce(item, value));
122
+ }
123
+ if (described.kind === "tuple") {
124
+ const items = described.items;
125
+ return values.map((value, index) =>
126
+ index < items.length ? coerce(items[index], value) : value,
127
+ );
128
+ }
129
+ return coerce(described, values[values.length - 1]);
130
+ }
131
+
132
+ /** The value `raw` spells for a field described by `description`, or `raw`. */
133
+ function coerce(description: Description, raw: string): mixed {
134
+ const described = unwrap(description);
135
+ switch (described.kind) {
136
+ case "number":
137
+ return numeric(raw) ?? raw;
138
+ case "bigint":
139
+ return /^[-+]?\d+$/.test(raw.trim()) ? BigInt(raw.trim()) : raw;
140
+ case "boolean":
141
+ return raw === "true" ? true : raw === "false" ? false : raw;
142
+ case "date": {
143
+ const date = new Date(raw);
144
+ return Number.isNaN(date.getTime()) ? raw : date;
145
+ }
146
+ case "literal": {
147
+ const value = described.value;
148
+ if (typeof value === "number") return numeric(raw) === value ? value : raw;
149
+ if (typeof value === "boolean") return raw === String(value) ? value : raw;
150
+ if (value === null) return raw === "null" ? null : raw;
151
+ return raw;
152
+ }
153
+ case "union": {
154
+ // The string itself when some option takes it as it is, so
155
+ // `union([literal("all"), number()])` reads `all` as `"all"` and `3` as 3.
156
+ const options = described.options.map(unwrap);
157
+ if (options.some((option) => acceptsAsIs(option, raw))) {
158
+ return raw;
159
+ }
160
+ for (const option of options) {
161
+ const coerced = coerce(option, raw);
162
+ if (coerced !== raw) return coerced;
163
+ }
164
+ return raw;
165
+ }
166
+ default:
167
+ return raw;
168
+ }
169
+ }
170
+
171
+ /** Whether a field described this way takes `raw` without coercion. */
172
+ function acceptsAsIs(described: Description, raw: string): boolean {
173
+ switch (described.kind) {
174
+ case "string":
175
+ case "unknown":
176
+ return true;
177
+ case "enum":
178
+ return described.values.includes(raw);
179
+ case "literal":
180
+ return described.value === raw;
181
+ default:
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /** A finite number `raw` spells, or `null`. `""` is not zero. */
187
+ function numeric(raw: string): ?number {
188
+ if (raw.trim() === "") return null;
189
+ const value = Number(raw);
190
+ return Number.isFinite(value) ? value : null;
191
+ }
192
+
193
+ /**
194
+ * A description with every wrapper that does not change what the value *is*
195
+ * taken off: optionality, defaults, `pipe` steps and `lazy`.
196
+ */
197
+ function unwrap(description: Description): Description {
198
+ const inner = innerOf(description, true);
199
+ return inner == null ? description : unwrap(inner);
200
+ }
201
+
202
+ /**
203
+ * The same, keeping optionality and defaults: whether a field that is absent
204
+ * from the query should be an empty list is a question about the field as it
205
+ * was written, and `optional(array(…))` answers it differently from
206
+ * `array(…)`.
207
+ */
208
+ function plain(description: Description): Description {
209
+ const inner = innerOf(description, false);
210
+ return inner == null ? description : plain(inner);
211
+ }
212
+
213
+ /** What a wrapper wraps, or `null` for a description that is not one. */
214
+ function innerOf(description: Description, optionality: boolean): ?Description {
215
+ switch (description.kind) {
216
+ case "transformed":
217
+ case "constrained":
218
+ return description.inner;
219
+ case "lazy":
220
+ return description.inner();
221
+ case "optional":
222
+ case "nullable":
223
+ case "nullish":
224
+ case "default":
225
+ case "fallback":
226
+ return optionality ? description.inner : null;
227
+ default:
228
+ return null;
229
+ }
230
+ }
231
+
232
+ function isList(description: Description): boolean {
233
+ return description.kind === "array" || description.kind === "set";
234
+ }
package/middleware.js CHANGED
@@ -455,6 +455,12 @@ function matchPrefix(routePath: string, pathname: string): RouteParams | null {
455
455
 
456
456
  for (let index = 0; index < wanted.length; index += 1) {
457
457
  const segment = wanted[index];
458
+ // A prefix, so the rest of the path — none of it included — is either
459
+ // catch-all's: `[[...slug]]` is `:slug*?`, `[...slug]` is `:slug*`.
460
+ if (segment.startsWith(":") && segment.endsWith("*?")) {
461
+ params[segment.slice(1, -2)] = given.slice(index);
462
+ return params as $FlowFixMe;
463
+ }
458
464
  if (segment.startsWith(":") && segment.endsWith("*")) {
459
465
  params[segment.slice(1, -1)] = given.slice(index);
460
466
  return params as $FlowFixMe;
package/native.js CHANGED
@@ -207,6 +207,9 @@ export function nativeScreenName(routePath: string): string {
207
207
  }
208
208
  const name = segments
209
209
  .map((segment) => {
210
+ if (segment.startsWith(":") && segment.endsWith("*?")) {
211
+ return `Any${titlePart(segment.slice(1, -2))}`;
212
+ }
210
213
  if (segment.startsWith(":") && segment.endsWith("*")) {
211
214
  return `All${titlePart(segment.slice(1, -1))}`;
212
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -73,7 +73,8 @@
73
73
  }
74
74
  },
75
75
  "dependencies": {
76
- "@uniflowed/hooks": "0.5.0",
77
- "@uniflowed/server": "0.5.0"
76
+ "@uniflowed/hooks": "0.6.0",
77
+ "@uniflowed/server": "0.6.0",
78
+ "@uniflowed/validator": "0.6.0"
78
79
  }
79
80
  }
package/routing.js CHANGED
@@ -18,6 +18,8 @@ export type {
18
18
  RouteRecord,
19
19
  RouteTable,
20
20
  SearchParams,
21
+ SearchParamsAll,
22
+ SearchParamsIssue,
21
23
  SlotRecord,
22
24
  SlotRouteRecord,
23
25
  TemplateRecord,
@@ -35,6 +37,7 @@ export {
35
37
  ForbiddenError,
36
38
  NotFoundError,
37
39
  RedirectError,
40
+ SearchParamsError,
38
41
  UnauthorizedError,
39
42
  buildRoute,
40
43
  forbidden,
@@ -42,6 +45,7 @@ export {
42
45
  matchRoute,
43
46
  notFound,
44
47
  parseSearch,
48
+ parseSearchAll,
45
49
  permanentRedirect,
46
50
  redirect,
47
51
  routeErrorStatus,