@native-router/core 1.7.0 → 1.8.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,8 +108,9 @@ 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
 
115
116
  ## Design principles
package/dist/index.cjs CHANGED
@@ -45,6 +45,84 @@ function formatIssuePath(path) {
45
45
  return `${keys.join('.')}: `;
46
46
  }
47
47
 
48
+ /**
49
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
50
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
51
+ * single-valued keys are strings, keys repeated in the query string are
52
+ * arrays of their values. An empty search is `{}`.
53
+ *
54
+ * This is also the degraded shape every search API falls back to when no
55
+ * schema is given.
56
+ * @group Methods
57
+ * @category Route
58
+ * @param search the raw `location.search` string, with or without `?`
59
+ * @returns the input object for schema validation
60
+ */
61
+ function parseSearchInput(search) {
62
+ const input = {};
63
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
64
+ new URLSearchParams(search).forEach((value, key) => {
65
+ const prev = input[key];
66
+ if (prev === undefined) {
67
+ input[key] = value;
68
+ } else if (Array.isArray(prev)) {
69
+ prev.push(value);
70
+ } else {
71
+ input[key] = [prev, value];
72
+ }
73
+ });
74
+ return input;
75
+ }
76
+
77
+ /**
78
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
79
+ * zod/valibot/arktype schema works, no hard dependency. The string is
80
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
81
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
82
+ *
83
+ * Async schemas(`validate` returning a promise) are awaited.
84
+ *
85
+ * @group Methods
86
+ * @category Route
87
+ * @param schema the search schema
88
+ * @param search the raw `location.search` string
89
+ * @returns the parsed(and possibly coerced) output of the schema
90
+ * @throws {SearchError} when the schema reports issues
91
+ */
92
+ async function parseSearch(schema, search) {
93
+ const result = await schema['~standard'].validate(parseSearchInput(search));
94
+ if (result.issues) throw new SearchError(search, result.issues);
95
+ // The schema's declared output; the loose `StandardSchemaV1` default
96
+ // degrades to `unknown`.
97
+ return result.value;
98
+ }
99
+
100
+ /**
101
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
102
+ * `useSearch` of `@native-router/react`) and route guards.
103
+ *
104
+ * @group Methods
105
+ * @category Route
106
+ * @param schema the search schema — must validate synchronously
107
+ * @param search the raw `location.search` string
108
+ * @returns the parsed(and possibly coerced) output of the schema
109
+ * @throws {SearchError} when the schema reports issues
110
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
111
+ * for async schemas instead
112
+ */
113
+ function parseSearchSync(schema, search) {
114
+ const result = schema['~standard'].validate(parseSearchInput(search));
115
+ if (isThenable(result)) {
116
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
117
+ }
118
+ if (result.issues) throw new SearchError(search, result.issues);
119
+ // See parseSearch for the cast rationale.
120
+ return result.value;
121
+ }
122
+ function isThenable(value) {
123
+ return typeof value?.then === 'function';
124
+ }
125
+
48
126
  const DEFAULT_MAX_STACK_DEPTH = 100;
49
127
 
50
128
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -327,6 +405,11 @@ function resolveTo(router, to, state) {
327
405
  * aborts — their resolution may be shared, so cancelling it on behalf of
328
406
  * one consumer is not sound yet.
329
407
  *
408
+ * A guard's context also carries the level's parsed
409
+ * {@link GuardContext.search search}: the {@link BaseRoute.search schema}
410
+ * output(its validation failure rejects this resolution with a
411
+ * `SearchError`), or the degraded input without a schema.
412
+ *
330
413
  * @group Methods
331
414
  * @category Router
332
415
  * @param router router instance
@@ -364,14 +447,37 @@ async function resolveEntry(router, location, opts) {
364
447
  } = matched[i];
365
448
  // `redirect` wins over `beforeLoad`; a non-empty string target
366
449
  // 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
- }));
450
+ let target = route.redirect;
451
+ if (!target && route.beforeLoad) {
452
+ // The level's search schema runs before its guard, so the guard
453
+ // sees the parsed output(degraded input without a schema). A
454
+ // validation failure fails the resolution through the task's
455
+ // errorHandler channel — the same route a data-phase search
456
+ // error takes — instead of rejecting this entry, which preload
457
+ // consumers share.
458
+ let search;
459
+ if (route.search) {
460
+ try {
461
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
462
+ search = await parseSearch(route.search, location.search);
463
+ } catch (e) {
464
+ return {
465
+ location,
466
+ task: Promise.reject(e).catch(errorHandler)
467
+ };
468
+ }
469
+ } else {
470
+ search = parseSearchInput(location.search);
471
+ }
472
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
473
+ target = await route.beforeLoad({
474
+ router,
475
+ location,
476
+ params: mergeMatchedParams(matched, i),
477
+ signal,
478
+ search
479
+ });
480
+ }
375
481
  if (target) {
376
482
  location = toLocation(router, target, location.state);
377
483
  redirected = true;
@@ -1076,84 +1182,6 @@ function getParams(router) {
1076
1182
  return mergeMatchedParams(match(router, location.pathname) ?? []);
1077
1183
  }
1078
1184
 
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
1185
  exports.NativeRouterError = NativeRouterError;
1158
1186
  exports.NotFoundError = NotFoundError;
1159
1187
  exports.RedirectLoopError = RedirectLoopError;
package/dist/index.mjs CHANGED
@@ -43,6 +43,84 @@ function formatIssuePath(path) {
43
43
  return `${keys.join('.')}: `;
44
44
  }
45
45
 
46
+ /**
47
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
48
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
49
+ * single-valued keys are strings, keys repeated in the query string are
50
+ * arrays of their values. An empty search is `{}`.
51
+ *
52
+ * This is also the degraded shape every search API falls back to when no
53
+ * schema is given.
54
+ * @group Methods
55
+ * @category Route
56
+ * @param search the raw `location.search` string, with or without `?`
57
+ * @returns the input object for schema validation
58
+ */
59
+ function parseSearchInput(search) {
60
+ const input = {};
61
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
62
+ new URLSearchParams(search).forEach((value, key) => {
63
+ const prev = input[key];
64
+ if (prev === undefined) {
65
+ input[key] = value;
66
+ } else if (Array.isArray(prev)) {
67
+ prev.push(value);
68
+ } else {
69
+ input[key] = [prev, value];
70
+ }
71
+ });
72
+ return input;
73
+ }
74
+
75
+ /**
76
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
77
+ * zod/valibot/arktype schema works, no hard dependency. The string is
78
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
79
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
80
+ *
81
+ * Async schemas(`validate` returning a promise) are awaited.
82
+ *
83
+ * @group Methods
84
+ * @category Route
85
+ * @param schema the search schema
86
+ * @param search the raw `location.search` string
87
+ * @returns the parsed(and possibly coerced) output of the schema
88
+ * @throws {SearchError} when the schema reports issues
89
+ */
90
+ async function parseSearch(schema, search) {
91
+ const result = await schema['~standard'].validate(parseSearchInput(search));
92
+ if (result.issues) throw new SearchError(search, result.issues);
93
+ // The schema's declared output; the loose `StandardSchemaV1` default
94
+ // degrades to `unknown`.
95
+ return result.value;
96
+ }
97
+
98
+ /**
99
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
100
+ * `useSearch` of `@native-router/react`) and route guards.
101
+ *
102
+ * @group Methods
103
+ * @category Route
104
+ * @param schema the search schema — must validate synchronously
105
+ * @param search the raw `location.search` string
106
+ * @returns the parsed(and possibly coerced) output of the schema
107
+ * @throws {SearchError} when the schema reports issues
108
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
109
+ * for async schemas instead
110
+ */
111
+ function parseSearchSync(schema, search) {
112
+ const result = schema['~standard'].validate(parseSearchInput(search));
113
+ if (isThenable(result)) {
114
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
115
+ }
116
+ if (result.issues) throw new SearchError(search, result.issues);
117
+ // See parseSearch for the cast rationale.
118
+ return result.value;
119
+ }
120
+ function isThenable(value) {
121
+ return typeof value?.then === 'function';
122
+ }
123
+
46
124
  const DEFAULT_MAX_STACK_DEPTH = 100;
47
125
 
48
126
  /** Max redirects followed by {@link resolveEntry} before giving up. */
@@ -325,6 +403,11 @@ function resolveTo(router, to, state) {
325
403
  * aborts — their resolution may be shared, so cancelling it on behalf of
326
404
  * one consumer is not sound yet.
327
405
  *
406
+ * A guard's context also carries the level's parsed
407
+ * {@link GuardContext.search search}: the {@link BaseRoute.search schema}
408
+ * output(its validation failure rejects this resolution with a
409
+ * `SearchError`), or the degraded input without a schema.
410
+ *
328
411
  * @group Methods
329
412
  * @category Router
330
413
  * @param router router instance
@@ -362,14 +445,37 @@ async function resolveEntry(router, location, opts) {
362
445
  } = matched[i];
363
446
  // `redirect` wins over `beforeLoad`; a non-empty string target
364
447
  // 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
- }));
448
+ let target = route.redirect;
449
+ if (!target && route.beforeLoad) {
450
+ // The level's search schema runs before its guard, so the guard
451
+ // sees the parsed output(degraded input without a schema). A
452
+ // validation failure fails the resolution through the task's
453
+ // errorHandler channel — the same route a data-phase search
454
+ // error takes — instead of rejecting this entry, which preload
455
+ // consumers share.
456
+ let search;
457
+ if (route.search) {
458
+ try {
459
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
460
+ search = await parseSearch(route.search, location.search);
461
+ } catch (e) {
462
+ return {
463
+ location,
464
+ task: Promise.reject(e).catch(errorHandler)
465
+ };
466
+ }
467
+ } else {
468
+ search = parseSearchInput(location.search);
469
+ }
470
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
471
+ target = await route.beforeLoad({
472
+ router,
473
+ location,
474
+ params: mergeMatchedParams(matched, i),
475
+ signal,
476
+ search
477
+ });
478
+ }
373
479
  if (target) {
374
480
  location = toLocation(router, target, location.state);
375
481
  redirected = true;
@@ -1074,82 +1180,4 @@ function getParams(router) {
1074
1180
  return mergeMatchedParams(match(router, location.pathname) ?? []);
1075
1181
  }
1076
1182
 
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
1183
  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 };
@@ -100,6 +100,11 @@ 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.
107
+ *
103
108
  * @group Methods
104
109
  * @category Router
105
110
  * @param router router instance
@@ -143,10 +143,19 @@ 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
149
  params: Record<string, string>;
150
+ /**
151
+ * The search the guard sees: the route's {@link BaseRoute.search search
152
+ * schema} output(parsed and validated before the guard runs), or the
153
+ * degraded {@link SearchInput} when the route declares no schema. The
154
+ * loose default types it `unknown` — narrow it in the guard, or let a
155
+ * typed route table(see `createRoutes` of `@native-router/react`)
156
+ * derive it from the schema.
157
+ */
158
+ search: S;
150
159
  /**
151
160
  * Aborted when this navigation is superseded by a newer one or
152
161
  * cancelled(see {@link RouterInstance.cancelAll cancel}); pass it to
@@ -173,7 +182,11 @@ export type BaseRoute<T = any> = {
173
182
  search?: StandardSchemaV1;
174
183
  /**
175
184
  * Route guard invoked before the view resolves. Return a path string
176
- * to redirect, or nothing(`undefined`) to continue.
185
+ * to redirect, or nothing(`undefined`) to continue. The guard's
186
+ * {@link GuardContext context} carries the level's parsed
187
+ * {@link GuardContext.search search}(schema output, or the degraded
188
+ * input without a schema); an invalid search fails the resolution at
189
+ * this phase like any other navigation error.
177
190
  */
178
191
  beforeLoad?(ctx: GuardContext<BaseRoute<T>>): Awaitable<string | void>;
179
192
  } & 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.8.0",
4
4
  "exports": {
5
5
  ".": {
6
6
  "types": "./dist/types/index.d.ts",