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