@native-router/core 1.2.0 → 1.4.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/dist/index.cjs CHANGED
@@ -12,9 +12,70 @@ class NotFoundError extends NativeRouterError {
12
12
  super(`Can't find the path: ${pathname}`);
13
13
  }
14
14
  }
15
+ class RedirectLoopError extends NativeRouterError {
16
+ constructor(target) {
17
+ super(`Redirect loop detected${target ? ` after following redirects to: ${target}` : ''}`);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Thrown when a route {@link BaseRoute.search search schema} rejects the
23
+ * location search. Issues are formatted as `path: message` pairs joined
24
+ * with `; `, e.g.
25
+ * `Invalid search params "?page=abc": page: Expected a positive integer`.
26
+ */
27
+ class SearchError extends NativeRouterError {
28
+ /** The raw search string that failed validation. */
29
+
30
+ /** The issues reported by the schema. */
31
+
32
+ constructor(search, issues) {
33
+ super(`Invalid search params "${search}": ${issues.map(({
34
+ message,
35
+ path
36
+ }) => `${formatIssuePath(path)}${message}`).join('; ')}`);
37
+ this.search = search;
38
+ this.issues = issues;
39
+ }
40
+ }
41
+ function formatIssuePath(path) {
42
+ if (!path?.length) return '';
43
+ const keys = path.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment));
44
+ return `${keys.join('.')}: `;
45
+ }
15
46
 
16
47
  const DEFAULT_MAX_STACK_DEPTH = 100;
17
48
 
49
+ /** Max redirects followed by {@link resolveEntry} before giving up. */
50
+ const MAX_REDIRECTS = 10;
51
+
52
+ /** Default cache lifetime of {@link preload} results, in milliseconds. */
53
+ const DEFAULT_PRELOAD_TTL = 30_000;
54
+
55
+ /**
56
+ * A location resolved through the route guards, together with the view task
57
+ * of its final target. When guards redirected, `location` is the terminal
58
+ * location and `task` resolves the view of the target route.
59
+ */
60
+
61
+ /**
62
+ * Cache record of {@link preload}: the resolution promise of the
63
+ * prefetched target(every hit within the TTL awaits the very same
64
+ * promise, which also deduplicates concurrent callers) plus its
65
+ * expiry timestamp.
66
+ */
67
+
68
+ /**
69
+ * Bookkeeping every {@link create}d router carries on top of
70
+ * {@link RouterInstance}. Declared in this module(instead of types.ts)
71
+ * to keep the public instance type surface stable:
72
+ * - `baseIndex`: absolute history index of `locationStack[0]`. The
73
+ * physical(window-relative) stack slot of a history entry is
74
+ * `history index - baseIndex`; entries whose slot falls outside the
75
+ * memory window re-resolve lazily when landed on.
76
+ * - `preloadCache`: router-level cache of {@link preload} results.
77
+ */
78
+
18
79
  /**
19
80
  * Create a router instance.
20
81
  * @group Methods
@@ -27,25 +88,30 @@ const DEFAULT_MAX_STACK_DEPTH = 100;
27
88
  */
28
89
  function create(routes, history, resolveView, options) {
29
90
  const [currentGuard, cancelAll] = util.createCurrentGuard();
91
+ const instanceHistory = history;
92
+ const state = instanceHistory.location.state || {};
30
93
  const {
31
94
  index
32
95
  } = getHistoryState({
33
- history: history
96
+ history: instanceHistory
34
97
  });
35
- // Restore the session stack from the bounded window serialized in the
36
- // current entry state; entries before the window become placeholders.
37
- // Legacy index-only state(1.x) degrades to a single-entry stack.
38
- const locationStack = restoreLocationStack(history);
39
- const viewStack = new Array(Math.max(index + 1, locationStack.length)).fill(null);
40
- if (options?.currentView) {
41
- viewStack[index] = options.currentView;
42
- }
43
- return {
98
+ // Restore the session window from the bounded location window in the
99
+ // current entry state. Window-less legacy(1.x index-only) states degrade
100
+ // to a single-entry window aligned with the landed position.
101
+ const locationStack = restoreLocationStack(instanceHistory);
102
+ const baseIndex = state.locationStack?.length ? state.base || 0 :
103
+ // Degraded window: its only entry IS the landed position.
104
+ index;
105
+ const router = {
44
106
  routes: Array.isArray(routes) ? routes : [routes],
45
107
  resolveView,
46
- history: history,
108
+ history: instanceHistory,
47
109
  locationStack,
48
- viewStack,
110
+ // The view stack is window-relative, so it is exactly as long as the
111
+ // location window and stays bounded by maxStackDepth with it.
112
+ viewStack: new Array(locationStack.length).fill(null),
113
+ baseIndex,
114
+ preloadCache: new Map(),
49
115
  currentGuard,
50
116
  cancelAll,
51
117
  errorHandler: util.reject,
@@ -53,6 +119,16 @@ function create(routes, history, resolveView, options) {
53
119
  baseUrl: options?.baseUrl || '',
54
120
  maxStackDepth: options?.maxStackDepth || DEFAULT_MAX_STACK_DEPTH
55
121
  };
122
+ if (options?.currentView) {
123
+ const physical = index - baseIndex;
124
+ // Hand-crafted or corrupted state may land the index outside the
125
+ // restored window; skip the write instead of creating a string-keyed
126
+ // property on the array(a negative index would).
127
+ if (physical >= 0 && physical < router.viewStack.length) {
128
+ router.viewStack[physical] = options.currentView;
129
+ }
130
+ }
131
+ return router;
56
132
  }
57
133
  function setOptions(router, options) {
58
134
  return Object.assign(router, options);
@@ -68,34 +144,34 @@ function getLocation({
68
144
  }
69
145
 
70
146
  /**
71
- * Restore the in-memory location stack from the bounded window in the
72
- * current history entry state. Slots before the window become
73
- * placeholders(`undefined`) so {@link initHistoryStack} can skip them
74
- * and a POP onto them still falls back to a lazy refresh.
147
+ * Restore the bounded location window serialized in the current history
148
+ * entry state, as-is: entries before the window start are outside the
149
+ * memory window(see {@link RouterCore.baseIndex}) and re-resolve lazily
150
+ * when landed on. Window-less legacy(1.x index-only) state degrades to a
151
+ * single-entry window.
75
152
  */
76
153
  function restoreLocationStack(history) {
77
154
  const state = history.location.state || {};
78
- const {
79
- locationStack,
80
- base
81
- } = state;
82
- return locationStack?.length ? [...new Array(base || 0), ...locationStack] : [getLocation({
155
+ return state.locationStack?.length ? [...state.locationStack] : [getLocation({
83
156
  history
84
157
  })];
85
158
  }
86
159
 
87
160
  /**
88
- * Serialize the tail window of the in-memory stack, bounded by
89
- * `maxStackDepth`, together with the absolute index of its first entry.
161
+ * Serialize the memory window together with the absolute index of its
162
+ * first entry, so a refresh can restore it. The memory window is trimmed
163
+ * on every push, so it is already bounded by `maxStackDepth`; the cap
164
+ * only matters when `maxStackDepth` was lowered via
165
+ * {@link setOptions} after the fact.
90
166
  */
91
167
  function serializeStack(router) {
92
168
  const {
93
169
  locationStack,
94
170
  maxStackDepth
95
171
  } = router;
96
- const windowed = locationStack.slice(-maxStackDepth);
172
+ const windowed = locationStack.length > maxStackDepth ? locationStack.slice(-maxStackDepth) : locationStack;
97
173
  return {
98
- base: locationStack.length - windowed.length,
174
+ base: router.baseIndex + (locationStack.length - windowed.length),
99
175
  locationStack: windowed
100
176
  };
101
177
  }
@@ -105,8 +181,17 @@ function getHistoryState(router) {
105
181
  index: state.index || 0
106
182
  };
107
183
  }
184
+
185
+ /**
186
+ * Physical(window-relative) view slot of an absolute history index.
187
+ * Slots before the window start(negative) or past its end read as
188
+ * `undefined`, driving the lazy refresh fallback of {@link listen}.
189
+ */
190
+ function viewAt(router, index) {
191
+ return router.viewStack[index - router.baseIndex];
192
+ }
108
193
  function getCurrentView(router) {
109
- return router.viewStack[getHistoryState(router).index];
194
+ return viewAt(router, getHistoryState(router).index);
110
195
  }
111
196
 
112
197
  /**
@@ -206,6 +291,158 @@ function resolveTo(router, to, state) {
206
291
  return resolve(router, location);
207
292
  }
208
293
 
294
+ /**
295
+ * Resolve a location through the route guards(`redirect`/`beforeLoad`).
296
+ *
297
+ * Guards run per matched level from the shallowest to the deepest. A guard
298
+ * returning a path string(redirect) restarts the resolution at the new
299
+ * location — from the shallowest level again, so guards of shallower
300
+ * levels re-run on every hop(keep side-effectful guards idempotent) —
301
+ * carrying the original user state; at most
302
+ * {@link MAX_REDIRECTS 10} redirects are followed before a
303
+ * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
304
+ * {@link resolve resolve} behavior: the task rejects with a
305
+ * {@link NotFoundError} and is routed through `router.errorHandler`.
306
+ *
307
+ * @group Methods
308
+ * @category Router
309
+ * @param router router instance
310
+ * @param location the location to resolve; the object itself is never
311
+ * mutated — a redirect rebinds the resolution to a new location
312
+ * @returns the terminal location and its resolve task
313
+ */
314
+ async function resolveEntry(router, location) {
315
+ const {
316
+ resolveView,
317
+ errorHandler
318
+ } = router;
319
+ for (let redirects = 0;; redirects++) {
320
+ if (redirects > MAX_REDIRECTS) {
321
+ throw new RedirectLoopError(location.pathname);
322
+ }
323
+ const matched = match(router, location.pathname);
324
+ if (!matched) {
325
+ return {
326
+ location,
327
+ task: Promise.reject(new NotFoundError(location.pathname)).catch(errorHandler)
328
+ };
329
+ }
330
+ let redirected = false;
331
+ for (let i = 0; i < matched.length; i++) {
332
+ const {
333
+ route
334
+ } = matched[i];
335
+ // `redirect` wins over `beforeLoad`; a non-empty string target
336
+ // restarts the resolution at the redirected location.
337
+ const target = route.redirect ?? (
338
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
339
+ await route.beforeLoad?.({
340
+ router,
341
+ location,
342
+ params: mergeMatchedParams(matched, i)
343
+ }));
344
+ if (target) {
345
+ location = toLocation(router, target, location.state);
346
+ redirected = true;
347
+ break;
348
+ }
349
+ }
350
+ // eslint-disable-next-line no-continue -- the redirect loop restarts the outer resolution pass
351
+ if (redirected) continue;
352
+ return {
353
+ location,
354
+ task: resolveView(matched, {
355
+ router,
356
+ location
357
+ }).catch(errorHandler)
358
+ };
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Resolve a target through the route guards(`redirect`/`beforeLoad`) and
364
+ * cache the result at the router level, keyed by `pathname + search`.
365
+ *
366
+ * Within its TTL(`opts.ttl`, default 30s) repeated and concurrent calls
367
+ * return the very same entry promise, so concurrent callers share one
368
+ * resolution(in-flight dedup) and repeated prefetches reuse the resolved
369
+ * view task instead of re-running guards and `resolveView`. A rejected
370
+ * resolution(guard error, redirect loop) is evicted from the cache, so
371
+ * the next call retries it. Committing a navigation({@link commit} or
372
+ * {@link commitReplace}) consumes the entry and evicts its cache slot —
373
+ * a later preload re-resolves fresh state, while callers still holding
374
+ * the old entry keep their references.
375
+ *
376
+ * @group Methods
377
+ * @category Router
378
+ * @param router router instance
379
+ * @param to path string
380
+ * @param opts options; `ttl` is the cache lifetime in milliseconds
381
+ * @returns the terminal location and its resolve task
382
+ */
383
+ function preload(router, to, opts) {
384
+ const cache = preloadCacheOf(router);
385
+ const location = toLocation(router, to);
386
+ const key = preloadLocationKey(location);
387
+ const cached = cache.get(key);
388
+ if (cached && Date.now() < cached.expires) {
389
+ return cached.entry;
390
+ }
391
+ prunePreloadCache(cache);
392
+ const entry = resolveEntry(router, location);
393
+ const record = {
394
+ entry,
395
+ expires: Date.now() + (opts?.ttl ?? DEFAULT_PRELOAD_TTL)
396
+ };
397
+ cache.set(key, record);
398
+ entry.then(resolved => {
399
+ record.terminal = resolved.location;
400
+ }, () => {
401
+ // Never cache a failure: evict the slot(this record only, a newer
402
+ // preload may already have replaced it) so the next call retries.
403
+ if (cache.get(key)?.entry === entry) cache.delete(key);
404
+ });
405
+ return entry;
406
+ }
407
+ function preloadLocationKey(location) {
408
+ return location.pathname + location.search;
409
+ }
410
+
411
+ /**
412
+ * Drop expired records. Runs on every cache write, so distinct prefetched
413
+ * targets never accumulate beyond their TTL in a long session.
414
+ */
415
+ function prunePreloadCache(cache) {
416
+ const now = Date.now();
417
+ cache.forEach((record, key) => {
418
+ if (record.expires <= now) cache.delete(key);
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Evict the cache slots consumed by a committed navigation. A redirecting
424
+ * entry is cached under its pre-redirect key while its terminal location
425
+ * differs, so records whose terminal resolves to the committed location
426
+ * are dropped too; in-flight records(terminal not yet known) are left to
427
+ * the TTL.
428
+ */
429
+ function evictPreloadCache(router, location) {
430
+ const cache = preloadCacheOf(router);
431
+ const key = preloadLocationKey(location);
432
+ cache.delete(key);
433
+ cache.forEach((record, k) => {
434
+ if (record.terminal && preloadLocationKey(record.terminal) === key) {
435
+ cache.delete(k);
436
+ }
437
+ });
438
+ }
439
+ function preloadCacheOf(router) {
440
+ const core = router;
441
+ // create() always seeds the cache; the lazy path keeps hand-built
442
+ // router-shaped objects working.
443
+ return core.preloadCache ??= new Map();
444
+ }
445
+
209
446
  /**
210
447
  * Commit the resolve task and push history.
211
448
  * @group Methods
@@ -215,13 +452,43 @@ function resolveTo(router, to, state) {
215
452
  * @param location the location to resolved
216
453
  */
217
454
  function commit(router, resolvePromise, location) {
455
+ // Wrap the raw task so external callers share the guarded entry
456
+ // pipeline; the entry location is the given one, as-is.
457
+ return pushEntry(router, Promise.resolve({
458
+ location,
459
+ task: resolvePromise
460
+ }), location);
461
+ }
462
+ function pushEntry(router, entryPromise, fromLocation) {
218
463
  const {
219
464
  history
220
465
  } = router;
221
466
  const nextIndex = getHistoryState(router).index + 1;
222
- return commitBase(router, resolvePromise, location, resolvedView => {
223
- router.locationStack = [...router.locationStack.slice(0, nextIndex), location];
224
- router.viewStack = [...router.viewStack.slice(0, nextIndex), resolvedView];
467
+ return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
468
+ const {
469
+ location
470
+ } = entry;
471
+ let next = nextIndex - router.baseIndex;
472
+ if (next < 0 || next > router.locationStack.length) {
473
+ // The current entry sits outside the memory window(a push while an
474
+ // out-of-window lazy refresh is still pending): restart the window
475
+ // at the pushed position. Out-of-window neighbours re-resolve
476
+ // lazily when landed on.
477
+ router.baseIndex = nextIndex;
478
+ router.locationStack = [];
479
+ router.viewStack = [];
480
+ next = 0;
481
+ }
482
+ router.locationStack = [...router.locationStack.slice(0, next), location];
483
+ router.viewStack = [...router.viewStack.slice(0, next), resolvedView];
484
+ // Bound the memory window: evict the oldest entries once the stack
485
+ // outgrows maxStackDepth, shifting the window base along.
486
+ if (router.locationStack.length > router.maxStackDepth) {
487
+ const evicted = router.locationStack.length - router.maxStackDepth;
488
+ router.locationStack = router.locationStack.slice(evicted);
489
+ router.viewStack = router.viewStack.slice(evicted);
490
+ router.baseIndex += evicted;
491
+ }
225
492
  history.push(location, {
226
493
  index: nextIndex,
227
494
  state: location.state,
@@ -239,15 +506,37 @@ function commit(router, resolvePromise, location) {
239
506
  * @param location the location to resolved
240
507
  */
241
508
  function commitReplace(router, resolvePromise, location) {
509
+ return replaceEntry(router, Promise.resolve({
510
+ location,
511
+ task: resolvePromise
512
+ }), location);
513
+ }
514
+ function replaceEntry(router, entryPromise, fromLocation) {
242
515
  const {
243
516
  history
244
517
  } = router;
245
518
  const {
246
519
  index
247
520
  } = getHistoryState(router);
248
- return commitBase(router, resolvePromise, location, resolvedView => {
249
- router.locationStack[index] = location;
250
- router.viewStack[index] = resolvedView;
521
+ return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
522
+ const {
523
+ location
524
+ } = entry;
525
+ const physical = index - router.baseIndex;
526
+ if (physical < 0 || physical >= router.locationStack.length) {
527
+ // The landed entry is outside the memory window(the browser evicted
528
+ // older history past the window, or a window-less legacy state).
529
+ // Restart the window at the landed position — placeholders for the
530
+ // unknown gap slots are gone, so neighbouring out-of-window entries
531
+ // re-resolve lazily on every POP, consistent with the rare
532
+ // browser-evicted paths.
533
+ router.baseIndex = index;
534
+ router.locationStack = [location];
535
+ router.viewStack = [resolvedView];
536
+ } else {
537
+ router.locationStack[physical] = location;
538
+ router.viewStack[physical] = resolvedView;
539
+ }
251
540
  history.replace(location, {
252
541
  index,
253
542
  state: location.state,
@@ -255,7 +544,7 @@ function commitReplace(router, resolvePromise, location) {
255
544
  });
256
545
  });
257
546
  }
258
- function commitBase(router, resolvePromise, location, onResolved) {
547
+ function commitBase(router, entryPromise, location, onResolved) {
259
548
  const {
260
549
  currentGuard,
261
550
  onLoadingChange = util.noop
@@ -266,16 +555,42 @@ function commitBase(router, resolvePromise, location, onResolved) {
266
555
  }
267
556
  router.resolving = location;
268
557
  onLoadingChange('pending');
269
- return currentGuard(resolvePromise).then(onResolved)
270
- // eslint-disable-next-line no-void
271
- .then(() => void onLoadingChange('resolved')).catch(e => {
272
- onLoadingChange('rejected');
273
- throw e;
274
- });
558
+ return (
559
+ // The whole chain — route guards AND the view task — is guarded from
560
+ // the very start: a superseding navigation or a cancel() while slow
561
+ // guards are still running parks this chain forever, exactly like
562
+ // during the view phase.
563
+ currentGuard(entryPromise.then(entry => entry.task.then(resolvedView => ({
564
+ entry,
565
+ resolvedView
566
+ })))).then(({
567
+ entry,
568
+ resolvedView
569
+ }) => {
570
+ onResolved(resolvedView, entry);
571
+ // The navigation consumed this resolution: drop its preload
572
+ // cache slots so a later preload re-resolves fresh state.
573
+ evictPreloadCache(router, entry.location);
574
+ }).then(() => {
575
+ // This chain settled, so it is no longer in flight. Superseded
576
+ // chains park forever, so only the latest chain can reach here:
577
+ // the mark is always ours to clear.
578
+ router.resolving = undefined;
579
+ onLoadingChange('resolved');
580
+ }).catch(e => {
581
+ router.resolving = undefined;
582
+ onLoadingChange('rejected');
583
+ throw e;
584
+ })
585
+ );
275
586
  }
276
587
 
277
588
  /**
278
- * Navigate to a new path.
589
+ * Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
590
+ * the view resolves; the history entry is committed on the terminal
591
+ * location when guards redirected. The guard phase is part of the
592
+ * cancelable navigation: a superseding navigate or a `cancel()` while
593
+ * guards are still running discards this navigation.
279
594
  * @group Methods
280
595
  * @category Router
281
596
  * @param router router instance
@@ -284,20 +599,19 @@ function commitBase(router, resolvePromise, location, onResolved) {
284
599
  */
285
600
  function navigate(router, to, state) {
286
601
  const location = toLocation(router, to, state);
287
- const viewPromise = resolve(router, location);
288
- return commit(router, viewPromise, location);
602
+ return pushEntry(router, resolveEntry(router, location), location);
289
603
  }
290
604
 
291
605
  /**
292
- * Refresh the page.
606
+ * Refresh the page. Route guards run before the view resolves; a redirect
607
+ * replaces the current entry with the terminal location.
293
608
  * @group Methods
294
609
  * @category Router
295
610
  * @param router router instance
296
611
  */
297
612
  function refresh(router) {
298
613
  const location = getLocation(router);
299
- const viewPromise = resolve(router, location);
300
- return commitReplace(router, viewPromise, location);
614
+ return replaceEntry(router, resolveEntry(router, location), location);
301
615
  }
302
616
 
303
617
  /**
@@ -352,12 +666,13 @@ function createHref({
352
666
  * @category Router
353
667
  * @param router router instance
354
668
  */
355
- function cancel({
356
- cancelAll,
357
- onLoadingChange = util.noop
358
- }) {
359
- cancelAll();
360
- onLoadingChange();
669
+ function cancel(router) {
670
+ // The cancelled chain parks forever, so nothing else will clear the
671
+ // in-flight mark: drop it here, or a later navigation would fire a
672
+ // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
673
+ router.resolving = undefined;
674
+ router.cancelAll();
675
+ router.onLoadingChange?.();
361
676
  }
362
677
 
363
678
  /**
@@ -372,7 +687,7 @@ function cancel({
372
687
  * @param router router instance
373
688
  */
374
689
  function initHistoryStack(router) {
375
- return Promise.all(router.locationStack.map(location => location ? resolve(router, location) : Promise.resolve(null))).then(views => {
690
+ return Promise.all(router.locationStack.map(location => resolve(router, location))).then(views => {
376
691
  router.viewStack = views;
377
692
  });
378
693
  }
@@ -396,13 +711,15 @@ function listen(router, onViewChange) {
396
711
  cancel(router);
397
712
  const state = location.state;
398
713
  const index = state?.index || 0;
399
- const view = router.viewStack[index];
714
+ const view = viewAt(router, index);
400
715
  onViewChange(view);
401
716
  if (!view) {
402
- // Lazy fallback for placeholder slots and legacy-shaped state:
403
- // re-resolving the landed entry also re-serializes the window
404
- // into it via the replace commit.
405
- refresh(router);
717
+ // Lazy fallback for out-of-window slots and window-less legacy
718
+ // state: re-resolving the landed entry also re-serializes the
719
+ // window into it via the replace commit. A guard failure here
720
+ // must not surface as an unhandled rejection — the landed entry
721
+ // simply keeps its(unknown) view.
722
+ refresh(router).catch(util.noop);
406
723
  } else if (action === 'POP') {
407
724
  // Sync the current window into the landed entry so a later
408
725
  // refresh("refresh → back → refresh again") still restores it.
@@ -421,15 +738,20 @@ function listen(router, onViewChange) {
421
738
  }
422
739
 
423
740
  /**
424
- * Merge params of all matched levels. Params of deeper levels override
741
+ * Merge params of the matched levels. Params of deeper levels override
425
742
  * the same keys of shallower ones.
743
+ *
744
+ * When `end` is given, only the levels up to and including `end` are
745
+ * merged — the accumulated params a level at index `end` sees(shallow →
746
+ * current level). Omitting `end` merges every level.
426
747
  * @group Methods
427
748
  * @category Router
428
749
  * @param matched matched route levels, see {@link match}
750
+ * @param end the index of the last level to merge, defaults to the deepest
429
751
  * @returns the merged params object
430
752
  */
431
- function mergeMatchedParams(matched) {
432
- return matched.reduce((params, {
753
+ function mergeMatchedParams(matched, end) {
754
+ return matched.slice(0, end === undefined ? matched.length : end + 1).reduce((params, {
433
755
  params: levelParams
434
756
  }) => ({
435
757
  ...params,
@@ -438,7 +760,11 @@ function mergeMatchedParams(matched) {
438
760
  }
439
761
 
440
762
  /**
441
- * Get current route params from router. Merges params of all matched levels.
763
+ * Get current route params from router. The params are re-derived by
764
+ * matching the current entry of {@link RouterInstance.locationStack} so
765
+ * they stay correct even when the view stack holds resolved views
766
+ * (e.g. React elements) instead of match results. Merges params of all
767
+ * matched levels; deeper levels override shallower ones.
442
768
  * @group Methods
443
769
  * @category Router
444
770
  * @param router router instance
@@ -448,13 +774,93 @@ function getParams(router) {
448
774
  const {
449
775
  index
450
776
  } = getHistoryState(router);
451
- const matched = router.viewStack[index];
452
- if (!matched || !matched.length) return {};
453
- return mergeMatchedParams(matched);
777
+ const location = router.locationStack[index - router.baseIndex];
778
+ if (!location) return {};
779
+ return mergeMatchedParams(match(router, location.pathname) ?? []);
780
+ }
781
+
782
+ /**
783
+ * Parse a raw search string(e.g. `?page=2&tag=a&tag=b`) into the plain
784
+ * input object consumed by {@link StandardSchemaV1 search schemas}:
785
+ * single-valued keys are strings, keys repeated in the query string are
786
+ * arrays of their values. An empty search is `{}`.
787
+ *
788
+ * This is also the degraded shape every search API falls back to when no
789
+ * schema is given.
790
+ * @group Methods
791
+ * @category Route
792
+ * @param search the raw `location.search` string, with or without `?`
793
+ * @returns the input object for schema validation
794
+ */
795
+ function parseSearchInput(search) {
796
+ const input = {};
797
+ // eslint-disable-next-line compat/compat -- URLSearchParams support is the app's polyfill concern, not bundled
798
+ new URLSearchParams(search).forEach((value, key) => {
799
+ const prev = input[key];
800
+ if (prev === undefined) {
801
+ input[key] = value;
802
+ } else if (Array.isArray(prev)) {
803
+ prev.push(value);
804
+ } else {
805
+ input[key] = [prev, value];
806
+ }
807
+ });
808
+ return input;
809
+ }
810
+
811
+ /**
812
+ * Validate a search string with a {@link StandardSchemaV1} schema — any
813
+ * zod/valibot/arktype schema works, no hard dependency. The string is
814
+ * first degraded via {@link parseSearchInput}, then parsed by the schema,
815
+ * so schemas can coerce(`'2'` → `2`) and normalize along the way.
816
+ *
817
+ * Async schemas(`validate` returning a promise) are awaited.
818
+ *
819
+ * @group Methods
820
+ * @category Route
821
+ * @param schema the search schema
822
+ * @param search the raw `location.search` string
823
+ * @returns the parsed(and possibly coerced) output of the schema
824
+ * @throws {SearchError} when the schema reports issues
825
+ */
826
+ async function parseSearch(schema, search) {
827
+ const result = await schema['~standard'].validate(parseSearchInput(search));
828
+ if (result.issues) throw new SearchError(search, result.issues);
829
+ // The schema's declared output; the loose `StandardSchemaV1` default
830
+ // degrades to `unknown`.
831
+ return result.value;
832
+ }
833
+
834
+ /**
835
+ * Synchronous flavor of {@link parseSearch}, for render-time reads(see
836
+ * `useSearch` of `@native-router/react`) and route guards.
837
+ *
838
+ * @group Methods
839
+ * @category Route
840
+ * @param schema the search schema — must validate synchronously
841
+ * @param search the raw `location.search` string
842
+ * @returns the parsed(and possibly coerced) output of the schema
843
+ * @throws {SearchError} when the schema reports issues
844
+ * @throws when the schema validates asynchronously; use {@link parseSearch}
845
+ * for async schemas instead
846
+ */
847
+ function parseSearchSync(schema, search) {
848
+ const result = schema['~standard'].validate(parseSearchInput(search));
849
+ if (isThenable(result)) {
850
+ throw new Error('The search schema validates asynchronously; parse it during resolve ' + '(parseSearch) instead of synchronously');
851
+ }
852
+ if (result.issues) throw new SearchError(search, result.issues);
853
+ // See parseSearch for the cast rationale.
854
+ return result.value;
855
+ }
856
+ function isThenable(value) {
857
+ return typeof value?.then === 'function';
454
858
  }
455
859
 
456
860
  exports.NativeRouterError = NativeRouterError;
457
861
  exports.NotFoundError = NotFoundError;
862
+ exports.RedirectLoopError = RedirectLoopError;
863
+ exports.SearchError = SearchError;
458
864
  exports.back = back;
459
865
  exports.cancel = cancel;
460
866
  exports.commit = commit;
@@ -471,8 +877,13 @@ exports.listen = listen;
471
877
  exports.match = match;
472
878
  exports.mergeMatchedParams = mergeMatchedParams;
473
879
  exports.navigate = navigate;
880
+ exports.parseSearch = parseSearch;
881
+ exports.parseSearchInput = parseSearchInput;
882
+ exports.parseSearchSync = parseSearchSync;
883
+ exports.preload = preload;
474
884
  exports.refresh = refresh;
475
885
  exports.resolve = resolve;
886
+ exports.resolveEntry = resolveEntry;
476
887
  exports.resolveTo = resolveTo;
477
888
  exports.setOptions = setOptions;
478
889
  exports.toLocation = toLocation;