@native-router/core 1.7.0 → 1.9.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/README.md CHANGED
@@ -108,10 +108,42 @@ const router = create(
108
108
  );
109
109
  ```
110
110
 
111
- - `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query are arrays — which is also the input every schema validates
112
- - `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render/guard-time flavor and rejects async validators with a clear error
111
+ - `parseSearchInput(search)` degrades a query string into a plain object — single-valued keys are strings, keys repeated in the query string are arrays — which is also the input every schema validates
112
+ - `parseSearch(schema, search)` resolves the schema output (async validators are awaited); `parseSearchSync` is the render-time flavor and rejects async validators with a clear error
113
+ - Guards: `beforeLoad` receives the level's parsed search as `ctx.search` — the schema output (parsed with `parseSearch`, so async validators work), or the degraded input on schema-less levels; an invalid search fails the resolution through the `errorHandler` channel like a data-phase search error
113
114
  - A rejected validation throws `SearchError` (a `NativeRouterError`) carrying the raw `search` and the reported `issues` — route it through your `errorHandler` like any other resolve failure
114
115
 
116
+ ## Params validation
117
+
118
+ Params are always strings (wildcards: string arrays) — the URL has no types. Declare a `params` schema on a route level and the core validates/coerces the merged params of that level before its `beforeLoad` runs, so guards see numbers instead of `Number(id)` everywhere.
119
+
120
+ ```ts
121
+ import {create} from '@native-router/core';
122
+ import {z} from 'zod';
123
+
124
+ const router = create(
125
+ {
126
+ path: '',
127
+ children: [
128
+ {
129
+ path: '/users/:id',
130
+ params: z.object({id: z.coerce.number().int().positive()}),
131
+ beforeLoad: ({params}) => {
132
+ params.id; // number — coerced, or the navigation failed
133
+ }
134
+ }
135
+ ]
136
+ },
137
+ createBrowserHistory(),
138
+ (matched) => renderUser(matched)
139
+ );
140
+ ```
141
+
142
+ - No `params` schema → behavior unchanged: the raw string map flows through
143
+ - The parse runs per level (shallow → deep): a level's schema validates the params merged up to it; a deeper schema sees the (possibly coerced) output of the shallower ones
144
+ - A rejected validation fails the resolution through the `errorHandler` channel with a `ParamsError` (a `NativeRouterError`) carrying the raw `params` and the reported `issues` — the same route a search-schema failure takes
145
+ - `parseParams`/`parseParamsSync` are exported for custom `resolveView` implementations (the async/sync flavors mirror `parseSearch`/`parseSearchSync`)
146
+
115
147
  ## Design principles
116
148
 
117
149
  **Navigation semantics follow the browser** — native-router aligns with browser-native navigation semantics, not with what other SPA routers happen to do. Every navigation API decision is measured against that yardstick; "a popular router has it" is not, by itself, a reason to follow. These are deliberate choices, not bugs to fix.
package/dist/index.cjs CHANGED
@@ -39,12 +39,159 @@ class SearchError extends NativeRouterError {
39
39
  this.issues = issues;
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * Thrown when a route {@link BaseRoute.params params schema} rejects the
45
+ * merged path params. Issues are formatted like {@link SearchError}'s —
46
+ * `path: message` pairs joined with `; `, e.g.
47
+ * `Invalid path params "/users/abc": id: expected a number`.
48
+ */
49
+ class ParamsError extends NativeRouterError {
50
+ /** The raw params object that failed validation. */
51
+ params;
52
+
53
+ /** The issues reported by the schema. */
54
+ issues;
55
+ constructor(params, issues) {
56
+ super(`Invalid path params "${JSON.stringify(params)}": ${issues.map(({
57
+ message,
58
+ path
59
+ }) => `${formatIssuePath(path)}${message}`).join('; ')}`);
60
+ this.params = params;
61
+ this.issues = issues;
62
+ }
63
+ }
42
64
  function formatIssuePath(path) {
43
65
  if (!path?.length) return '';
44
66
  const keys = path.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment));
45
67
  return `${keys.join('.')}: `;
46
68
  }
47
69
 
70
+ /**
71
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
72
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
73
+ * single-valued keys are strings, keys repeated in the query string are
74
+ * arrays of their values. An empty search is `{}`.
75
+ *
76
+ * This is also the degraded shape every search API falls back to when no
77
+ * schema is given.
78
+ * @group Methods
79
+ * @category Route
80
+ * @param search the raw `location.search` string, with or without `?`
81
+ * @returns the input object for schema validation
82
+ */
83
+ function parseSearchInput(search) {
84
+ const input = {};
85
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
86
+ new URLSearchParams(search).forEach((value, key) => {
87
+ const prev = input[key];
88
+ if (prev === undefined) {
89
+ input[key] = value;
90
+ } else if (Array.isArray(prev)) {
91
+ prev.push(value);
92
+ } else {
93
+ input[key] = [prev, value];
94
+ }
95
+ });
96
+ return input;
97
+ }
98
+
99
+ /**
100
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
101
+ * zod/valibot/arktype schema works, no hard dependency. The string is
102
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
103
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
104
+ *
105
+ * Async schemas(`validate` returning a promise) are awaited.
106
+ *
107
+ * @group Methods
108
+ * @category Route
109
+ * @param schema the search schema
110
+ * @param search the raw `location.search` string
111
+ * @returns the parsed(and possibly coerced) output of the schema
112
+ * @throws {SearchError} when the schema reports issues
113
+ */
114
+ async function parseSearch(schema, search) {
115
+ const result = await schema['~standard'].validate(parseSearchInput(search));
116
+ if (result.issues) throw new SearchError(search, result.issues);
117
+ // The schema's declared output; the loose `StandardSchemaV1` default
118
+ // degrades to `unknown`.
119
+ return result.value;
120
+ }
121
+
122
+ /**
123
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
124
+ * `useSearch` of `@native-router/react`) and route guards.
125
+ *
126
+ * @group Methods
127
+ * @category Route
128
+ * @param schema the search schema — must validate synchronously
129
+ * @param search the raw `location.search` string
130
+ * @returns the parsed(and possibly coerced) output of the schema
131
+ * @throws {SearchError} when the schema reports issues
132
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
133
+ * for async schemas instead
134
+ */
135
+ function parseSearchSync(schema, search) {
136
+ const result = schema['~standard'].validate(parseSearchInput(search));
137
+ if (isThenable(result)) {
138
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
139
+ }
140
+ if (result.issues) throw new SearchError(search, result.issues);
141
+ // See parseSearch for the cast rationale.
142
+ return result.value;
143
+ }
144
+ function isThenable(value) {
145
+ return typeof value?.then === 'function';
146
+ }
147
+
148
+ /**
149
+ * Validate the merged path params of a route level with a
150
+ * {@link StandardSchemaV1} schema — any zod/valibot/arktype schema
151
+ * works, no hard dependency. Params arrive as the plain string map the
152
+ * matcher extracted(see `mergeMatchedParams`), so schemas can coerce
153
+ * (`'7'` → `7`) and normalize along the way.
154
+ *
155
+ * Async schemas(`validate` returning a promise) are awaited.
156
+ *
157
+ * @group Methods
158
+ * @category Route
159
+ * @param schema the params schema
160
+ * @param params the merged raw params of the level
161
+ * @returns the parsed(and possibly coerced) output of the schema
162
+ * @throws {ParamsError} when the schema reports issues
163
+ */
164
+ async function parseParams(schema, params) {
165
+ const result = await schema['~standard'].validate(params);
166
+ if (result.issues) throw new ParamsError(params, result.issues);
167
+ // The schema's declared output; the loose `StandardSchemaV1` default
168
+ // degrades to `unknown`.
169
+ return result.value;
170
+ }
171
+
172
+ /**
173
+ * Synchronous flavor of {@link parseParams}, for render-time reads and
174
+ * custom `resolveView` implementations.
175
+ *
176
+ * @group Methods
177
+ * @category Route
178
+ * @param schema the params schema — must validate synchronously
179
+ * @param params the merged raw params of the level
180
+ * @returns the parsed(and possibly coerced) output of the schema
181
+ * @throws {ParamsError} when the schema reports issues
182
+ * @throws when the schema validates asynchronously; use {@link parseParams}
183
+ * for async schemas instead
184
+ */
185
+ function parseParamsSync(schema, params) {
186
+ const result = schema['~standard'].validate(params);
187
+ if (isThenable(result)) {
188
+ throw new Error('The params schema validates asynchronously; parse it during resolve ' + '(parseParams) instead of synchronously');
189
+ }
190
+ if (result.issues) throw new ParamsError(params, result.issues);
191
+ // See parseParams for the cast rationale.
192
+ return result.value;
193
+ }
194
+
48
195
  const DEFAULT_MAX_STACK_DEPTH = 100;
49
196
 
50
197
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -327,6 +474,16 @@ function resolveTo(router, to, state) {
327
474
  * aborts — their resolution may be shared, so cancelling it on behalf of
328
475
  * one consumer is not sound yet.
329
476
  *
477
+ * A guard's context also carries the level's parsed
478
+ * {@link GuardContext.search search}: the {@link BaseRoute.search schema}
479
+ * output(its validation failure rejects this resolution with a
480
+ * `SearchError`), or the degraded input without a schema. Its
481
+ * {@link GuardContext.params params} are likewise the merged raw string
482
+ * map unless some level declares a {@link BaseRoute.params params
483
+ * schema} — the deepest schema seen so far has already upgraded them to
484
+ * its output(its validation failure rides the same channel with a
485
+ * `ParamsError`).
486
+ *
330
487
  * @group Methods
331
488
  * @category Router
332
489
  * @param router router instance
@@ -358,20 +515,67 @@ async function resolveEntry(router, location, opts) {
358
515
  };
359
516
  }
360
517
  let redirected = false;
518
+ // Raw params merged level-by-level; a level with a `params` schema
519
+ // upgrades them to the schema output before its guard runs, so
520
+ // guards of deeper levels see coerced params of the whole prefix.
521
+ let params = {};
361
522
  for (let i = 0; i < matched.length; i++) {
362
523
  const {
363
524
  route
364
525
  } = matched[i];
526
+ params = {
527
+ ...params,
528
+ ...matched[i].params
529
+ };
530
+ // The level's params schema runs before its guard, so the guard
531
+ // sees the coerced output. A validation failure fails the
532
+ // resolution through the task's errorHandler channel — the same
533
+ // route a search-schema failure takes — instead of rejecting this
534
+ // entry, which preload consumers share.
535
+ if (route.params) {
536
+ try {
537
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
538
+ params = await parseParams(route.params, params);
539
+ } catch (e) {
540
+ return {
541
+ location,
542
+ task: Promise.reject(e).catch(errorHandler)
543
+ };
544
+ }
545
+ }
365
546
  // `redirect` wins over `beforeLoad`; a non-empty string target
366
547
  // restarts the resolution at the redirected location.
367
- const target = route.redirect ?? (
368
- // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
369
- await route.beforeLoad?.({
370
- router,
371
- location,
372
- params: mergeMatchedParams(matched, i),
373
- signal
374
- }));
548
+ let target = route.redirect;
549
+ if (!target && route.beforeLoad) {
550
+ // The level's search schema runs before its guard, so the guard
551
+ // sees the parsed output(degraded input without a schema). A
552
+ // validation failure fails the resolution through the task's
553
+ // errorHandler channel — the same route a data-phase search
554
+ // error takes — instead of rejecting this entry, which preload
555
+ // consumers share.
556
+ let search;
557
+ if (route.search) {
558
+ try {
559
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
560
+ search = await parseSearch(route.search, location.search);
561
+ } catch (e) {
562
+ return {
563
+ location,
564
+ task: Promise.reject(e).catch(errorHandler)
565
+ };
566
+ }
567
+ } else {
568
+ search = parseSearchInput(location.search);
569
+ }
570
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
571
+ target = await route.beforeLoad({
572
+ router,
573
+ location,
574
+ params,
575
+ signal,
576
+ search
577
+ });
578
+ }
375
579
  if (target) {
376
580
  location = toLocation(router, target, location.state);
377
581
  redirected = true;
@@ -1076,86 +1280,9 @@ function getParams(router) {
1076
1280
  return mergeMatchedParams(match(router, location.pathname) ?? []);
1077
1281
  }
1078
1282
 
1079
- /**
1080
- * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
1081
- * input object consumed by {@link StandardSchemaV1 search schemas}:
1082
- * single-valued keys are strings, keys repeated in the query string are
1083
- * arrays of their values. An empty search is `{}`.
1084
- *
1085
- * This is also the degraded shape every search API falls back to when no
1086
- * schema is given.
1087
- * @group Methods
1088
- * @category Route
1089
- * @param search the raw `location.search` string, with or without `?`
1090
- * @returns the input object for schema validation
1091
- */
1092
- function parseSearchInput(search) {
1093
- const input = {};
1094
- // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
1095
- new URLSearchParams(search).forEach((value, key) => {
1096
- const prev = input[key];
1097
- if (prev === undefined) {
1098
- input[key] = value;
1099
- } else if (Array.isArray(prev)) {
1100
- prev.push(value);
1101
- } else {
1102
- input[key] = [prev, value];
1103
- }
1104
- });
1105
- return input;
1106
- }
1107
-
1108
- /**
1109
- * Validate a search string with a {@link StandardSchemaV1} schema — any
1110
- * zod/valibot/arktype schema works, no hard dependency. The string is
1111
- * first degraded via {@link parseSearchInput}, then parsed by the schema,
1112
- * so schemas can coerce(`'2'` → `2`) and normalize along the way.
1113
- *
1114
- * Async schemas(`validate` returning a promise) are awaited.
1115
- *
1116
- * @group Methods
1117
- * @category Route
1118
- * @param schema the search schema
1119
- * @param search the raw `location.search` string
1120
- * @returns the parsed(and possibly coerced) output of the schema
1121
- * @throws {SearchError} when the schema reports issues
1122
- */
1123
- async function parseSearch(schema, search) {
1124
- const result = await schema['~standard'].validate(parseSearchInput(search));
1125
- if (result.issues) throw new SearchError(search, result.issues);
1126
- // The schema's declared output; the loose `StandardSchemaV1` default
1127
- // degrades to `unknown`.
1128
- return result.value;
1129
- }
1130
-
1131
- /**
1132
- * Synchronous flavor of {@link parseSearch}, for render-time reads(see
1133
- * `useSearch` of `@native-router/react`) and route guards.
1134
- *
1135
- * @group Methods
1136
- * @category Route
1137
- * @param schema the search schema — must validate synchronously
1138
- * @param search the raw `location.search` string
1139
- * @returns the parsed(and possibly coerced) output of the schema
1140
- * @throws {SearchError} when the schema reports issues
1141
- * @throws when the schema validates asynchronously; use {@link parseSearch}
1142
- * for async schemas instead
1143
- */
1144
- function parseSearchSync(schema, search) {
1145
- const result = schema['~standard'].validate(parseSearchInput(search));
1146
- if (isThenable(result)) {
1147
- throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
1148
- }
1149
- if (result.issues) throw new SearchError(search, result.issues);
1150
- // See parseSearch for the cast rationale.
1151
- return result.value;
1152
- }
1153
- function isThenable(value) {
1154
- return typeof value?.then === 'function';
1155
- }
1156
-
1157
1283
  exports.NativeRouterError = NativeRouterError;
1158
1284
  exports.NotFoundError = NotFoundError;
1285
+ exports.ParamsError = ParamsError;
1159
1286
  exports.RedirectLoopError = RedirectLoopError;
1160
1287
  exports.SearchError = SearchError;
1161
1288
  exports.back = back;
@@ -1175,6 +1302,8 @@ exports.listen = listen;
1175
1302
  exports.match = match;
1176
1303
  exports.mergeMatchedParams = mergeMatchedParams;
1177
1304
  exports.navigate = navigate;
1305
+ exports.parseParams = parseParams;
1306
+ exports.parseParamsSync = parseParamsSync;
1178
1307
  exports.parseSearch = parseSearch;
1179
1308
  exports.parseSearchInput = parseSearchInput;
1180
1309
  exports.parseSearchSync = parseSearchSync;
package/dist/index.mjs CHANGED
@@ -37,12 +37,159 @@ class SearchError extends NativeRouterError {
37
37
  this.issues = issues;
38
38
  }
39
39
  }
40
+
41
+ /**
42
+ * Thrown when a route {@link BaseRoute.params params schema} rejects the
43
+ * merged path params. Issues are formatted like {@link SearchError}'s —
44
+ * `path: message` pairs joined with `; `, e.g.
45
+ * `Invalid path params "/users/abc": id: expected a number`.
46
+ */
47
+ class ParamsError extends NativeRouterError {
48
+ /** The raw params object that failed validation. */
49
+ params;
50
+
51
+ /** The issues reported by the schema. */
52
+ issues;
53
+ constructor(params, issues) {
54
+ super(`Invalid path params "${JSON.stringify(params)}": ${issues.map(({
55
+ message,
56
+ path
57
+ }) => `${formatIssuePath(path)}${message}`).join('; ')}`);
58
+ this.params = params;
59
+ this.issues = issues;
60
+ }
61
+ }
40
62
  function formatIssuePath(path) {
41
63
  if (!path?.length) return '';
42
64
  const keys = path.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment));
43
65
  return `${keys.join('.')}: `;
44
66
  }
45
67
 
68
+ /**
69
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
70
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
71
+ * single-valued keys are strings, keys repeated in the query string are
72
+ * arrays of their values. An empty search is `{}`.
73
+ *
74
+ * This is also the degraded shape every search API falls back to when no
75
+ * schema is given.
76
+ * @group Methods
77
+ * @category Route
78
+ * @param search the raw `location.search` string, with or without `?`
79
+ * @returns the input object for schema validation
80
+ */
81
+ function parseSearchInput(search) {
82
+ const input = {};
83
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
84
+ new URLSearchParams(search).forEach((value, key) => {
85
+ const prev = input[key];
86
+ if (prev === undefined) {
87
+ input[key] = value;
88
+ } else if (Array.isArray(prev)) {
89
+ prev.push(value);
90
+ } else {
91
+ input[key] = [prev, value];
92
+ }
93
+ });
94
+ return input;
95
+ }
96
+
97
+ /**
98
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
99
+ * zod/valibot/arktype schema works, no hard dependency. The string is
100
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
101
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
102
+ *
103
+ * Async schemas(`validate` returning a promise) are awaited.
104
+ *
105
+ * @group Methods
106
+ * @category Route
107
+ * @param schema the search schema
108
+ * @param search the raw `location.search` string
109
+ * @returns the parsed(and possibly coerced) output of the schema
110
+ * @throws {SearchError} when the schema reports issues
111
+ */
112
+ async function parseSearch(schema, search) {
113
+ const result = await schema['~standard'].validate(parseSearchInput(search));
114
+ if (result.issues) throw new SearchError(search, result.issues);
115
+ // The schema's declared output; the loose `StandardSchemaV1` default
116
+ // degrades to `unknown`.
117
+ return result.value;
118
+ }
119
+
120
+ /**
121
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
122
+ * `useSearch` of `@native-router/react`) and route guards.
123
+ *
124
+ * @group Methods
125
+ * @category Route
126
+ * @param schema the search schema — must validate synchronously
127
+ * @param search the raw `location.search` string
128
+ * @returns the parsed(and possibly coerced) output of the schema
129
+ * @throws {SearchError} when the schema reports issues
130
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
131
+ * for async schemas instead
132
+ */
133
+ function parseSearchSync(schema, search) {
134
+ const result = schema['~standard'].validate(parseSearchInput(search));
135
+ if (isThenable(result)) {
136
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
137
+ }
138
+ if (result.issues) throw new SearchError(search, result.issues);
139
+ // See parseSearch for the cast rationale.
140
+ return result.value;
141
+ }
142
+ function isThenable(value) {
143
+ return typeof value?.then === 'function';
144
+ }
145
+
146
+ /**
147
+ * Validate the merged path params of a route level with a
148
+ * {@link StandardSchemaV1} schema — any zod/valibot/arktype schema
149
+ * works, no hard dependency. Params arrive as the plain string map the
150
+ * matcher extracted(see `mergeMatchedParams`), so schemas can coerce
151
+ * (`'7'` → `7`) and normalize along the way.
152
+ *
153
+ * Async schemas(`validate` returning a promise) are awaited.
154
+ *
155
+ * @group Methods
156
+ * @category Route
157
+ * @param schema the params schema
158
+ * @param params the merged raw params of the level
159
+ * @returns the parsed(and possibly coerced) output of the schema
160
+ * @throws {ParamsError} when the schema reports issues
161
+ */
162
+ async function parseParams(schema, params) {
163
+ const result = await schema['~standard'].validate(params);
164
+ if (result.issues) throw new ParamsError(params, result.issues);
165
+ // The schema's declared output; the loose `StandardSchemaV1` default
166
+ // degrades to `unknown`.
167
+ return result.value;
168
+ }
169
+
170
+ /**
171
+ * Synchronous flavor of {@link parseParams}, for render-time reads and
172
+ * custom `resolveView` implementations.
173
+ *
174
+ * @group Methods
175
+ * @category Route
176
+ * @param schema the params schema — must validate synchronously
177
+ * @param params the merged raw params of the level
178
+ * @returns the parsed(and possibly coerced) output of the schema
179
+ * @throws {ParamsError} when the schema reports issues
180
+ * @throws when the schema validates asynchronously; use {@link parseParams}
181
+ * for async schemas instead
182
+ */
183
+ function parseParamsSync(schema, params) {
184
+ const result = schema['~standard'].validate(params);
185
+ if (isThenable(result)) {
186
+ throw new Error('The params schema validates asynchronously; parse it during resolve ' + '(parseParams) instead of synchronously');
187
+ }
188
+ if (result.issues) throw new ParamsError(params, result.issues);
189
+ // See parseParams for the cast rationale.
190
+ return result.value;
191
+ }
192
+
46
193
  const DEFAULT_MAX_STACK_DEPTH = 100;
47
194
 
48
195
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -325,6 +472,16 @@ function resolveTo(router, to, state) {
325
472
  * aborts — their resolution may be shared, so cancelling it on behalf of
326
473
  * one consumer is not sound yet.
327
474
  *
475
+ * A guard's context also carries the level's parsed
476
+ * {@link GuardContext.search search}: the {@link BaseRoute.search schema}
477
+ * output(its validation failure rejects this resolution with a
478
+ * `SearchError`), or the degraded input without a schema. Its
479
+ * {@link GuardContext.params params} are likewise the merged raw string
480
+ * map unless some level declares a {@link BaseRoute.params params
481
+ * schema} — the deepest schema seen so far has already upgraded them to
482
+ * its output(its validation failure rides the same channel with a
483
+ * `ParamsError`).
484
+ *
328
485
  * @group Methods
329
486
  * @category Router
330
487
  * @param router router instance
@@ -356,20 +513,67 @@ async function resolveEntry(router, location, opts) {
356
513
  };
357
514
  }
358
515
  let redirected = false;
516
+ // Raw params merged level-by-level; a level with a `params` schema
517
+ // upgrades them to the schema output before its guard runs, so
518
+ // guards of deeper levels see coerced params of the whole prefix.
519
+ let params = {};
359
520
  for (let i = 0; i < matched.length; i++) {
360
521
  const {
361
522
  route
362
523
  } = matched[i];
524
+ params = {
525
+ ...params,
526
+ ...matched[i].params
527
+ };
528
+ // The level's params schema runs before its guard, so the guard
529
+ // sees the coerced output. A validation failure fails the
530
+ // resolution through the task's errorHandler channel — the same
531
+ // route a search-schema failure takes — instead of rejecting this
532
+ // entry, which preload consumers share.
533
+ if (route.params) {
534
+ try {
535
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
536
+ params = await parseParams(route.params, params);
537
+ } catch (e) {
538
+ return {
539
+ location,
540
+ task: Promise.reject(e).catch(errorHandler)
541
+ };
542
+ }
543
+ }
363
544
  // `redirect` wins over `beforeLoad`; a non-empty string target
364
545
  // restarts the resolution at the redirected location.
365
- const target = route.redirect ?? (
366
- // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
367
- await route.beforeLoad?.({
368
- router,
369
- location,
370
- params: mergeMatchedParams(matched, i),
371
- signal
372
- }));
546
+ let target = route.redirect;
547
+ if (!target && route.beforeLoad) {
548
+ // The level's search schema runs before its guard, so the guard
549
+ // sees the parsed output(degraded input without a schema). A
550
+ // validation failure fails the resolution through the task's
551
+ // errorHandler channel — the same route a data-phase search
552
+ // error takes — instead of rejecting this entry, which preload
553
+ // consumers share.
554
+ let search;
555
+ if (route.search) {
556
+ try {
557
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
558
+ search = await parseSearch(route.search, location.search);
559
+ } catch (e) {
560
+ return {
561
+ location,
562
+ task: Promise.reject(e).catch(errorHandler)
563
+ };
564
+ }
565
+ } else {
566
+ search = parseSearchInput(location.search);
567
+ }
568
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
569
+ target = await route.beforeLoad({
570
+ router,
571
+ location,
572
+ params,
573
+ signal,
574
+ search
575
+ });
576
+ }
373
577
  if (target) {
374
578
  location = toLocation(router, target, location.state);
375
579
  redirected = true;
@@ -1074,82 +1278,4 @@ function getParams(router) {
1074
1278
  return mergeMatchedParams(match(router, location.pathname) ?? []);
1075
1279
  }
1076
1280
 
1077
- /**
1078
- * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
1079
- * input object consumed by {@link StandardSchemaV1 search schemas}:
1080
- * single-valued keys are strings, keys repeated in the query string are
1081
- * arrays of their values. An empty search is `{}`.
1082
- *
1083
- * This is also the degraded shape every search API falls back to when no
1084
- * schema is given.
1085
- * @group Methods
1086
- * @category Route
1087
- * @param search the raw `location.search` string, with or without `?`
1088
- * @returns the input object for schema validation
1089
- */
1090
- function parseSearchInput(search) {
1091
- const input = {};
1092
- // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
1093
- new URLSearchParams(search).forEach((value, key) => {
1094
- const prev = input[key];
1095
- if (prev === undefined) {
1096
- input[key] = value;
1097
- } else if (Array.isArray(prev)) {
1098
- prev.push(value);
1099
- } else {
1100
- input[key] = [prev, value];
1101
- }
1102
- });
1103
- return input;
1104
- }
1105
-
1106
- /**
1107
- * Validate a search string with a {@link StandardSchemaV1} schema — any
1108
- * zod/valibot/arktype schema works, no hard dependency. The string is
1109
- * first degraded via {@link parseSearchInput}, then parsed by the schema,
1110
- * so schemas can coerce(`'2'` → `2`) and normalize along the way.
1111
- *
1112
- * Async schemas(`validate` returning a promise) are awaited.
1113
- *
1114
- * @group Methods
1115
- * @category Route
1116
- * @param schema the search schema
1117
- * @param search the raw `location.search` string
1118
- * @returns the parsed(and possibly coerced) output of the schema
1119
- * @throws {SearchError} when the schema reports issues
1120
- */
1121
- async function parseSearch(schema, search) {
1122
- const result = await schema['~standard'].validate(parseSearchInput(search));
1123
- if (result.issues) throw new SearchError(search, result.issues);
1124
- // The schema's declared output; the loose `StandardSchemaV1` default
1125
- // degrades to `unknown`.
1126
- return result.value;
1127
- }
1128
-
1129
- /**
1130
- * Synchronous flavor of {@link parseSearch}, for render-time reads(see
1131
- * `useSearch` of `@native-router/react`) and route guards.
1132
- *
1133
- * @group Methods
1134
- * @category Route
1135
- * @param schema the search schema — must validate synchronously
1136
- * @param search the raw `location.search` string
1137
- * @returns the parsed(and possibly coerced) output of the schema
1138
- * @throws {SearchError} when the schema reports issues
1139
- * @throws when the schema validates asynchronously; use {@link parseSearch}
1140
- * for async schemas instead
1141
- */
1142
- function parseSearchSync(schema, search) {
1143
- const result = schema['~standard'].validate(parseSearchInput(search));
1144
- if (isThenable(result)) {
1145
- throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
1146
- }
1147
- if (result.issues) throw new SearchError(search, result.issues);
1148
- // See parseSearch for the cast rationale.
1149
- return result.value;
1150
- }
1151
- function isThenable(value) {
1152
- return typeof value?.then === 'function';
1153
- }
1154
-
1155
- export { NativeRouterError, NotFoundError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, invalidate, listen, match, mergeMatchedParams, navigate, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setBlocker, setOptions, toLocation };
1281
+ export { NativeRouterError, NotFoundError, ParamsError, RedirectLoopError, SearchError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, invalidate, listen, match, mergeMatchedParams, navigate, parseParams, parseParamsSync, parseSearch, parseSearchInput, parseSearchSync, preload, refresh, resolve, resolveEntry, resolveTo, setBlocker, setOptions, toLocation };
@@ -20,3 +20,16 @@ export declare class SearchError extends NativeRouterError {
20
20
  readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
21
21
  constructor(search: string, issues: ReadonlyArray<StandardSchemaV1.Issue>);
22
22
  }
23
+ /**
24
+ * Thrown when a route {@link BaseRoute.params params schema} rejects the
25
+ * merged path params. Issues are formatted like {@link SearchError}'s —
26
+ * `path: message` pairs joined with `; `, e.g.
27
+ * `Invalid path params "/users/abc": id: expected a number`.
28
+ */
29
+ export declare class ParamsError extends NativeRouterError {
30
+ /** The raw params object that failed validation. */
31
+ readonly params: Record<string, string>;
32
+ /** The issues reported by the schema. */
33
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
34
+ constructor(params: Record<string, string>, issues: ReadonlyArray<StandardSchemaV1.Issue>);
35
+ }
@@ -100,6 +100,16 @@ export declare function resolveTo<R extends BaseRoute = BaseRoute, V = any>(rout
100
100
  * aborts — their resolution may be shared, so cancelling it on behalf of
101
101
  * one consumer is not sound yet.
102
102
  *
103
+ * A guard's context also carries the level's parsed
104
+ * {@link GuardContext.search search}: the {@link BaseRoute.search schema}
105
+ * output(its validation failure rejects this resolution with a
106
+ * `SearchError`), or the degraded input without a schema. Its
107
+ * {@link GuardContext.params params} are likewise the merged raw string
108
+ * map unless some level declares a {@link BaseRoute.params params
109
+ * schema} — the deepest schema seen so far has already upgraded them to
110
+ * its output(its validation failure rides the same channel with a
111
+ * `ParamsError`).
112
+ *
103
113
  * @group Methods
104
114
  * @category Router
105
115
  * @param router router instance
@@ -43,3 +43,34 @@ export declare function parseSearch<S extends StandardSchemaV1>(schema: S, searc
43
43
  * for async schemas instead
44
44
  */
45
45
  export declare function parseSearchSync<S extends StandardSchemaV1>(schema: S, search: string): SearchOutputOf<S>;
46
+ /**
47
+ * Validate the merged path params of a route level with a
48
+ * {@link StandardSchemaV1} schema — any zod/valibot/arktype schema
49
+ * works, no hard dependency. Params arrive as the plain string map the
50
+ * matcher extracted(see `mergeMatchedParams`), so schemas can coerce
51
+ * (`'7'` → `7`) and normalize along the way.
52
+ *
53
+ * Async schemas(`validate` returning a promise) are awaited.
54
+ *
55
+ * @group Methods
56
+ * @category Route
57
+ * @param schema the params schema
58
+ * @param params the merged raw params of the level
59
+ * @returns the parsed(and possibly coerced) output of the schema
60
+ * @throws {ParamsError} when the schema reports issues
61
+ */
62
+ export declare function parseParams<S extends StandardSchemaV1>(schema: S, params: Record<string, string>): Promise<SearchOutputOf<S>>;
63
+ /**
64
+ * Synchronous flavor of {@link parseParams}, for render-time reads and
65
+ * custom `resolveView` implementations.
66
+ *
67
+ * @group Methods
68
+ * @category Route
69
+ * @param schema the params schema — must validate synchronously
70
+ * @param params the merged raw params of the level
71
+ * @returns the parsed(and possibly coerced) output of the schema
72
+ * @throws {ParamsError} when the schema reports issues
73
+ * @throws when the schema validates asynchronously; use {@link parseParams}
74
+ * for async schemas instead
75
+ */
76
+ export declare function parseParamsSync<S extends StandardSchemaV1>(schema: S, params: Record<string, string>): SearchOutputOf<S>;
@@ -143,10 +143,26 @@ export type ExtractPathParams<P extends string> = P extends `${infer Head}/${inf
143
143
  * `params` are accumulated from the root level down to the level that
144
144
  * owns the guard, so a guard only sees params of itself and its parents.
145
145
  */
146
- export type GuardContext<R extends BaseRoute = BaseRoute> = {
146
+ export type GuardContext<R extends BaseRoute = BaseRoute, S = unknown> = {
147
147
  router: RouterInstance<R>;
148
148
  location: Location;
149
+ /**
150
+ * The merged params of this level and its parents: when any level
151
+ * declares a {@link BaseRoute.params params schema}, the merged raw
152
+ * params are parsed through the deepest matching schema before the
153
+ * guard runs; without schemas the raw string map the matcher
154
+ * extracted.
155
+ */
149
156
  params: Record<string, string>;
157
+ /**
158
+ * The search the guard sees: the route's {@link BaseRoute.search search
159
+ * schema} output(parsed and validated before the guard runs), or the
160
+ * degraded {@link SearchInput} when the route declares no schema. The
161
+ * loose default types it `unknown` — narrow it in the guard, or let a
162
+ * typed route table(see `createRoutes` of `@native-router/react`)
163
+ * derive it from the schema.
164
+ */
165
+ search: S;
150
166
  /**
151
167
  * Aborted when this navigation is superseded by a newer one or
152
168
  * cancelled(see {@link RouterInstance.cancelAll cancel}); pass it to
@@ -171,9 +187,25 @@ export type BaseRoute<T = any> = {
171
187
  * navigation error.
172
188
  */
173
189
  search?: StandardSchemaV1;
190
+ /**
191
+ * Optional Standard Schema validator of the merged path params this
192
+ * level and its parents contribute(see `mergeMatchedParams`). The core
193
+ * runs it in {@link resolveEntry} after matching and before the level's
194
+ * `beforeLoad`, so guards and loaders see coerced params(e.g. `:id`
195
+ * as a number) instead of raw strings; a validation failure fails the
196
+ * resolve like any other navigation error(via `ParamsError`).
197
+ *
198
+ * Omit it and the params stay the raw `Record<string, string>` the
199
+ * matcher extracted — behavior is unchanged.
200
+ */
201
+ params?: StandardSchemaV1;
174
202
  /**
175
203
  * Route guard invoked before the view resolves. Return a path string
176
- * to redirect, or nothing(`undefined`) to continue.
204
+ * to redirect, or nothing(`undefined`) to continue. The guard's
205
+ * {@link GuardContext context} carries the level's parsed
206
+ * {@link GuardContext.search search}(schema output, or the degraded
207
+ * input without a schema); an invalid search fails the resolution at
208
+ * this phase like any other navigation error.
177
209
  */
178
210
  beforeLoad?(ctx: GuardContext<BaseRoute<T>>): Awaitable<string | void>;
179
211
  } & Omit<T, 'path' | 'children'>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/core",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",
@@ -31,7 +31,7 @@
31
31
  "build": "rm -rf dist && rollup -c && tsc -p tsconfig.production.json && tsc-alias -p tsconfig.production.json",
32
32
  "commit": "lint-staged && git-cz -n",
33
33
  "coverage": "vitest run --coverage",
34
- "lint": "eslint --fix src test *.js --ext .js,.jsx,.ts,.tsx",
34
+ "lint": "eslint --fix src test *.js",
35
35
  "doc:gen": "typedoc",
36
36
  "deploy": "npm run doc:gen && gh-pages -d dist",
37
37
  "test": "vitest run"
@@ -58,44 +58,46 @@
58
58
  "@babel/core": "^8.0.1",
59
59
  "@babel/preset-env": "^8.0.2",
60
60
  "@babel/preset-typescript": "^8.0.1",
61
+ "@eslint/eslintrc": "^3.3.6",
61
62
  "@rollup/plugin-babel": "^7.1.0",
62
63
  "@rollup/plugin-commonjs": "^29.0.3",
63
64
  "@rollup/plugin-node-resolve": "^16.0.3",
64
65
  "@rollup/plugin-replace": "^6.0.3",
65
- "@types/node": "^26.2.0",
66
+ "@types/node": "^26.4.0",
66
67
  "@types/sinon": "^22.0.0",
67
- "@typescript-eslint/eslint-plugin": "^6.2.0",
68
- "@typescript-eslint/parser": "^6.2.0",
68
+ "@typescript-eslint/eslint-plugin": "^8.68.0",
69
+ "@typescript-eslint/parser": "^8.68.0",
69
70
  "@vitest/coverage-v8": "^4.1.11",
70
71
  "commitizen": "^4.3.2",
71
72
  "core-js": "^3.50.0",
72
73
  "cross-env": "^10.1.0",
73
- "eslint": "^8.50.0",
74
+ "eslint": "^10.9.1",
74
75
  "eslint-config-airbnb": "^19.0.4",
75
- "eslint-config-airbnb-typescript": "^17.1.0",
76
- "eslint-config-prettier": "^8.8.0",
77
- "eslint-import-resolver-typescript": "^3.5.5",
78
- "eslint-plugin-compat": "^4.1.4",
79
- "eslint-plugin-import": "^2.27.5",
80
- "eslint-plugin-jsx-a11y": "^6.7.1",
81
- "eslint-plugin-prettier": "^5.0.0",
82
- "eslint-plugin-react": "^7.33.0",
83
- "eslint-plugin-react-hooks": "^4.6.0",
76
+ "eslint-config-airbnb-typescript": "^18.0.0",
77
+ "eslint-config-prettier": "^10.1.8",
78
+ "eslint-import-resolver-typescript": "^4.4.5",
79
+ "eslint-plugin-compat": "^7.0.2",
80
+ "eslint-plugin-import": "^2.32.0",
81
+ "eslint-plugin-jsx-a11y": "^6.10.2",
82
+ "eslint-plugin-prettier": "^5.5.6",
83
+ "eslint-plugin-react": "^7.37.5",
84
+ "eslint-plugin-react-hooks": "^7.1.1",
84
85
  "gh-pages": "^6.3.0",
86
+ "globals": "^17.11.0",
85
87
  "husky": "^9.1.7",
86
88
  "lint-staged": "^17.3.0",
87
89
  "prettier": "^3.9.6",
88
- "rollup": "^4.62.5",
90
+ "rollup": "^4.63.0",
89
91
  "semantic-release": "^25.0.9",
90
92
  "should": "^13.2.3",
91
93
  "should-sinon": "0.0.6",
92
94
  "sinon": "^22.1.0",
93
- "terser": "^5.50.0",
95
+ "terser": "^5.51.0",
94
96
  "tsc-alias": "^1.9.2",
95
97
  "typedoc": "^0.28.20",
96
98
  "typedoc-plugin-mark-react-functional-components": "^0.2.2",
97
99
  "typedoc-plugin-missing-exports": "^4.1.4",
98
- "typescript": "~5.9.3",
100
+ "typescript": "~6.0.3",
99
101
  "vitest": "^4.1.11"
100
102
  }
101
103
  }