@native-router/core 1.2.0 → 1.3.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,44 @@ 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
+ }
13
18
 
14
19
  const DEFAULT_MAX_STACK_DEPTH = 100;
15
20
 
21
+ /** Max redirects followed by {@link resolveEntry} before giving up. */
22
+ const MAX_REDIRECTS = 10;
23
+
24
+ /** Default cache lifetime of {@link preload} results, in milliseconds. */
25
+ const DEFAULT_PRELOAD_TTL = 30_000;
26
+
27
+ /**
28
+ * A location resolved through the route guards, together with the view task
29
+ * of its final target. When guards redirected, `location` is the terminal
30
+ * location and `task` resolves the view of the target route.
31
+ */
32
+
33
+ /**
34
+ * Cache record of {@link preload}: the resolution promise of the
35
+ * prefetched target(every hit within the TTL awaits the very same
36
+ * promise, which also deduplicates concurrent callers) plus its
37
+ * expiry timestamp.
38
+ */
39
+
40
+ /**
41
+ * Bookkeeping every {@link create}d router carries on top of
42
+ * {@link RouterInstance}. Declared in this module(instead of types.ts)
43
+ * to keep the public instance type surface stable:
44
+ * - `baseIndex`: absolute history index of `locationStack[0]`. The
45
+ * physical(window-relative) stack slot of a history entry is
46
+ * `history index - baseIndex`; entries whose slot falls outside the
47
+ * memory window re-resolve lazily when landed on.
48
+ * - `preloadCache`: router-level cache of {@link preload} results.
49
+ */
50
+
16
51
  /**
17
52
  * Create a router instance.
18
53
  * @group Methods
@@ -25,25 +60,30 @@ const DEFAULT_MAX_STACK_DEPTH = 100;
25
60
  */
26
61
  function create(routes, history, resolveView, options) {
27
62
  const [currentGuard, cancelAll] = createCurrentGuard();
63
+ const instanceHistory = history;
64
+ const state = instanceHistory.location.state || {};
28
65
  const {
29
66
  index
30
67
  } = getHistoryState({
31
- history: history
68
+ history: instanceHistory
32
69
  });
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 {
70
+ // Restore the session window from the bounded location window in the
71
+ // current entry state. Window-less legacy(1.x index-only) states degrade
72
+ // to a single-entry window aligned with the landed position.
73
+ const locationStack = restoreLocationStack(instanceHistory);
74
+ const baseIndex = state.locationStack?.length ? state.base || 0 :
75
+ // Degraded window: its only entry IS the landed position.
76
+ index;
77
+ const router = {
42
78
  routes: Array.isArray(routes) ? routes : [routes],
43
79
  resolveView,
44
- history: history,
80
+ history: instanceHistory,
45
81
  locationStack,
46
- viewStack,
82
+ // The view stack is window-relative, so it is exactly as long as the
83
+ // location window and stays bounded by maxStackDepth with it.
84
+ viewStack: new Array(locationStack.length).fill(null),
85
+ baseIndex,
86
+ preloadCache: new Map(),
47
87
  currentGuard,
48
88
  cancelAll,
49
89
  errorHandler: reject,
@@ -51,6 +91,16 @@ function create(routes, history, resolveView, options) {
51
91
  baseUrl: options?.baseUrl || '',
52
92
  maxStackDepth: options?.maxStackDepth || DEFAULT_MAX_STACK_DEPTH
53
93
  };
94
+ if (options?.currentView) {
95
+ const physical = index - baseIndex;
96
+ // Hand-crafted or corrupted state may land the index outside the
97
+ // restored window; skip the write instead of creating a string-keyed
98
+ // property on the array(a negative index would).
99
+ if (physical >= 0 && physical < router.viewStack.length) {
100
+ router.viewStack[physical] = options.currentView;
101
+ }
102
+ }
103
+ return router;
54
104
  }
55
105
  function setOptions(router, options) {
56
106
  return Object.assign(router, options);
@@ -66,34 +116,34 @@ function getLocation({
66
116
  }
67
117
 
68
118
  /**
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.
119
+ * Restore the bounded location window serialized in the current history
120
+ * entry state, as-is: entries before the window start are outside the
121
+ * memory window(see {@link RouterCore.baseIndex}) and re-resolve lazily
122
+ * when landed on. Window-less legacy(1.x index-only) state degrades to a
123
+ * single-entry window.
73
124
  */
74
125
  function restoreLocationStack(history) {
75
126
  const state = history.location.state || {};
76
- const {
77
- locationStack,
78
- base
79
- } = state;
80
- return locationStack?.length ? [...new Array(base || 0), ...locationStack] : [getLocation({
127
+ return state.locationStack?.length ? [...state.locationStack] : [getLocation({
81
128
  history
82
129
  })];
83
130
  }
84
131
 
85
132
  /**
86
- * Serialize the tail window of the in-memory stack, bounded by
87
- * `maxStackDepth`, together with the absolute index of its first entry.
133
+ * Serialize the memory window together with the absolute index of its
134
+ * first entry, so a refresh can restore it. The memory window is trimmed
135
+ * on every push, so it is already bounded by `maxStackDepth`; the cap
136
+ * only matters when `maxStackDepth` was lowered via
137
+ * {@link setOptions} after the fact.
88
138
  */
89
139
  function serializeStack(router) {
90
140
  const {
91
141
  locationStack,
92
142
  maxStackDepth
93
143
  } = router;
94
- const windowed = locationStack.slice(-maxStackDepth);
144
+ const windowed = locationStack.length > maxStackDepth ? locationStack.slice(-maxStackDepth) : locationStack;
95
145
  return {
96
- base: locationStack.length - windowed.length,
146
+ base: router.baseIndex + (locationStack.length - windowed.length),
97
147
  locationStack: windowed
98
148
  };
99
149
  }
@@ -103,8 +153,17 @@ function getHistoryState(router) {
103
153
  index: state.index || 0
104
154
  };
105
155
  }
156
+
157
+ /**
158
+ * Physical(window-relative) view slot of an absolute history index.
159
+ * Slots before the window start(negative) or past its end read as
160
+ * `undefined`, driving the lazy refresh fallback of {@link listen}.
161
+ */
162
+ function viewAt(router, index) {
163
+ return router.viewStack[index - router.baseIndex];
164
+ }
106
165
  function getCurrentView(router) {
107
- return router.viewStack[getHistoryState(router).index];
166
+ return viewAt(router, getHistoryState(router).index);
108
167
  }
109
168
 
110
169
  /**
@@ -204,6 +263,158 @@ function resolveTo(router, to, state) {
204
263
  return resolve(router, location);
205
264
  }
206
265
 
266
+ /**
267
+ * Resolve a location through the route guards(`redirect`/`beforeLoad`).
268
+ *
269
+ * Guards run per matched level from the shallowest to the deepest. A guard
270
+ * returning a path string(redirect) restarts the resolution at the new
271
+ * location — from the shallowest level again, so guards of shallower
272
+ * levels re-run on every hop(keep side-effectful guards idempotent) —
273
+ * carrying the original user state; at most
274
+ * {@link MAX_REDIRECTS 10} redirects are followed before a
275
+ * {@link RedirectLoopError} is thrown. An unmatched pathname keeps the
276
+ * {@link resolve resolve} behavior: the task rejects with a
277
+ * {@link NotFoundError} and is routed through `router.errorHandler`.
278
+ *
279
+ * @group Methods
280
+ * @category Router
281
+ * @param router router instance
282
+ * @param location the location to resolve; the object itself is never
283
+ * mutated — a redirect rebinds the resolution to a new location
284
+ * @returns the terminal location and its resolve task
285
+ */
286
+ async function resolveEntry(router, location) {
287
+ const {
288
+ resolveView,
289
+ errorHandler
290
+ } = router;
291
+ for (let redirects = 0;; redirects++) {
292
+ if (redirects > MAX_REDIRECTS) {
293
+ throw new RedirectLoopError(location.pathname);
294
+ }
295
+ const matched = match(router, location.pathname);
296
+ if (!matched) {
297
+ return {
298
+ location,
299
+ task: Promise.reject(new NotFoundError(location.pathname)).catch(errorHandler)
300
+ };
301
+ }
302
+ let redirected = false;
303
+ for (let i = 0; i < matched.length; i++) {
304
+ const {
305
+ route
306
+ } = matched[i];
307
+ // `redirect` wins over `beforeLoad`; a non-empty string target
308
+ // restarts the resolution at the redirected location.
309
+ const target = route.redirect ?? (
310
+ // eslint-disable-next-line no-await-in-loop -- guards must run in declaration order, sequentially
311
+ await route.beforeLoad?.({
312
+ router,
313
+ location,
314
+ params: mergeMatchedParams(matched, i)
315
+ }));
316
+ if (target) {
317
+ location = toLocation(router, target, location.state);
318
+ redirected = true;
319
+ break;
320
+ }
321
+ }
322
+ // eslint-disable-next-line no-continue -- the redirect loop restarts the outer resolution pass
323
+ if (redirected) continue;
324
+ return {
325
+ location,
326
+ task: resolveView(matched, {
327
+ router,
328
+ location
329
+ }).catch(errorHandler)
330
+ };
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Resolve a target through the route guards(`redirect`/`beforeLoad`) and
336
+ * cache the result at the router level, keyed by `pathname + search`.
337
+ *
338
+ * Within its TTL(`opts.ttl`, default 30s) repeated and concurrent calls
339
+ * return the very same entry promise, so concurrent callers share one
340
+ * resolution(in-flight dedup) and repeated prefetches reuse the resolved
341
+ * view task instead of re-running guards and `resolveView`. A rejected
342
+ * resolution(guard error, redirect loop) is evicted from the cache, so
343
+ * the next call retries it. Committing a navigation({@link commit} or
344
+ * {@link commitReplace}) consumes the entry and evicts its cache slot —
345
+ * a later preload re-resolves fresh state, while callers still holding
346
+ * the old entry keep their references.
347
+ *
348
+ * @group Methods
349
+ * @category Router
350
+ * @param router router instance
351
+ * @param to path string
352
+ * @param opts options; `ttl` is the cache lifetime in milliseconds
353
+ * @returns the terminal location and its resolve task
354
+ */
355
+ function preload(router, to, opts) {
356
+ const cache = preloadCacheOf(router);
357
+ const location = toLocation(router, to);
358
+ const key = preloadLocationKey(location);
359
+ const cached = cache.get(key);
360
+ if (cached && Date.now() < cached.expires) {
361
+ return cached.entry;
362
+ }
363
+ prunePreloadCache(cache);
364
+ const entry = resolveEntry(router, location);
365
+ const record = {
366
+ entry,
367
+ expires: Date.now() + (opts?.ttl ?? DEFAULT_PRELOAD_TTL)
368
+ };
369
+ cache.set(key, record);
370
+ entry.then(resolved => {
371
+ record.terminal = resolved.location;
372
+ }, () => {
373
+ // Never cache a failure: evict the slot(this record only, a newer
374
+ // preload may already have replaced it) so the next call retries.
375
+ if (cache.get(key)?.entry === entry) cache.delete(key);
376
+ });
377
+ return entry;
378
+ }
379
+ function preloadLocationKey(location) {
380
+ return location.pathname + location.search;
381
+ }
382
+
383
+ /**
384
+ * Drop expired records. Runs on every cache write, so distinct prefetched
385
+ * targets never accumulate beyond their TTL in a long session.
386
+ */
387
+ function prunePreloadCache(cache) {
388
+ const now = Date.now();
389
+ cache.forEach((record, key) => {
390
+ if (record.expires <= now) cache.delete(key);
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Evict the cache slots consumed by a committed navigation. A redirecting
396
+ * entry is cached under its pre-redirect key while its terminal location
397
+ * differs, so records whose terminal resolves to the committed location
398
+ * are dropped too; in-flight records(terminal not yet known) are left to
399
+ * the TTL.
400
+ */
401
+ function evictPreloadCache(router, location) {
402
+ const cache = preloadCacheOf(router);
403
+ const key = preloadLocationKey(location);
404
+ cache.delete(key);
405
+ cache.forEach((record, k) => {
406
+ if (record.terminal && preloadLocationKey(record.terminal) === key) {
407
+ cache.delete(k);
408
+ }
409
+ });
410
+ }
411
+ function preloadCacheOf(router) {
412
+ const core = router;
413
+ // create() always seeds the cache; the lazy path keeps hand-built
414
+ // router-shaped objects working.
415
+ return core.preloadCache ??= new Map();
416
+ }
417
+
207
418
  /**
208
419
  * Commit the resolve task and push history.
209
420
  * @group Methods
@@ -213,13 +424,43 @@ function resolveTo(router, to, state) {
213
424
  * @param location the location to resolved
214
425
  */
215
426
  function commit(router, resolvePromise, location) {
427
+ // Wrap the raw task so external callers share the guarded entry
428
+ // pipeline; the entry location is the given one, as-is.
429
+ return pushEntry(router, Promise.resolve({
430
+ location,
431
+ task: resolvePromise
432
+ }), location);
433
+ }
434
+ function pushEntry(router, entryPromise, fromLocation) {
216
435
  const {
217
436
  history
218
437
  } = router;
219
438
  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];
439
+ return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
440
+ const {
441
+ location
442
+ } = entry;
443
+ let next = nextIndex - router.baseIndex;
444
+ if (next < 0 || next > router.locationStack.length) {
445
+ // The current entry sits outside the memory window(a push while an
446
+ // out-of-window lazy refresh is still pending): restart the window
447
+ // at the pushed position. Out-of-window neighbours re-resolve
448
+ // lazily when landed on.
449
+ router.baseIndex = nextIndex;
450
+ router.locationStack = [];
451
+ router.viewStack = [];
452
+ next = 0;
453
+ }
454
+ router.locationStack = [...router.locationStack.slice(0, next), location];
455
+ router.viewStack = [...router.viewStack.slice(0, next), resolvedView];
456
+ // Bound the memory window: evict the oldest entries once the stack
457
+ // outgrows maxStackDepth, shifting the window base along.
458
+ if (router.locationStack.length > router.maxStackDepth) {
459
+ const evicted = router.locationStack.length - router.maxStackDepth;
460
+ router.locationStack = router.locationStack.slice(evicted);
461
+ router.viewStack = router.viewStack.slice(evicted);
462
+ router.baseIndex += evicted;
463
+ }
223
464
  history.push(location, {
224
465
  index: nextIndex,
225
466
  state: location.state,
@@ -237,15 +478,37 @@ function commit(router, resolvePromise, location) {
237
478
  * @param location the location to resolved
238
479
  */
239
480
  function commitReplace(router, resolvePromise, location) {
481
+ return replaceEntry(router, Promise.resolve({
482
+ location,
483
+ task: resolvePromise
484
+ }), location);
485
+ }
486
+ function replaceEntry(router, entryPromise, fromLocation) {
240
487
  const {
241
488
  history
242
489
  } = router;
243
490
  const {
244
491
  index
245
492
  } = getHistoryState(router);
246
- return commitBase(router, resolvePromise, location, resolvedView => {
247
- router.locationStack[index] = location;
248
- router.viewStack[index] = resolvedView;
493
+ return commitBase(router, entryPromise, fromLocation, (resolvedView, entry) => {
494
+ const {
495
+ location
496
+ } = entry;
497
+ const physical = index - router.baseIndex;
498
+ if (physical < 0 || physical >= router.locationStack.length) {
499
+ // The landed entry is outside the memory window(the browser evicted
500
+ // older history past the window, or a window-less legacy state).
501
+ // Restart the window at the landed position — placeholders for the
502
+ // unknown gap slots are gone, so neighbouring out-of-window entries
503
+ // re-resolve lazily on every POP, consistent with the rare
504
+ // browser-evicted paths.
505
+ router.baseIndex = index;
506
+ router.locationStack = [location];
507
+ router.viewStack = [resolvedView];
508
+ } else {
509
+ router.locationStack[physical] = location;
510
+ router.viewStack[physical] = resolvedView;
511
+ }
249
512
  history.replace(location, {
250
513
  index,
251
514
  state: location.state,
@@ -253,7 +516,7 @@ function commitReplace(router, resolvePromise, location) {
253
516
  });
254
517
  });
255
518
  }
256
- function commitBase(router, resolvePromise, location, onResolved) {
519
+ function commitBase(router, entryPromise, location, onResolved) {
257
520
  const {
258
521
  currentGuard,
259
522
  onLoadingChange = noop
@@ -264,16 +527,42 @@ function commitBase(router, resolvePromise, location, onResolved) {
264
527
  }
265
528
  router.resolving = location;
266
529
  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
- });
530
+ return (
531
+ // The whole chain — route guards AND the view task — is guarded from
532
+ // the very start: a superseding navigation or a cancel() while slow
533
+ // guards are still running parks this chain forever, exactly like
534
+ // during the view phase.
535
+ currentGuard(entryPromise.then(entry => entry.task.then(resolvedView => ({
536
+ entry,
537
+ resolvedView
538
+ })))).then(({
539
+ entry,
540
+ resolvedView
541
+ }) => {
542
+ onResolved(resolvedView, entry);
543
+ // The navigation consumed this resolution: drop its preload
544
+ // cache slots so a later preload re-resolves fresh state.
545
+ evictPreloadCache(router, entry.location);
546
+ }).then(() => {
547
+ // This chain settled, so it is no longer in flight. Superseded
548
+ // chains park forever, so only the latest chain can reach here:
549
+ // the mark is always ours to clear.
550
+ router.resolving = undefined;
551
+ onLoadingChange('resolved');
552
+ }).catch(e => {
553
+ router.resolving = undefined;
554
+ onLoadingChange('rejected');
555
+ throw e;
556
+ })
557
+ );
273
558
  }
274
559
 
275
560
  /**
276
- * Navigate to a new path.
561
+ * Navigate to a new path. Route guards(`redirect`/`beforeLoad`) run before
562
+ * the view resolves; the history entry is committed on the terminal
563
+ * location when guards redirected. The guard phase is part of the
564
+ * cancelable navigation: a superseding navigate or a `cancel()` while
565
+ * guards are still running discards this navigation.
277
566
  * @group Methods
278
567
  * @category Router
279
568
  * @param router router instance
@@ -282,20 +571,19 @@ function commitBase(router, resolvePromise, location, onResolved) {
282
571
  */
283
572
  function navigate(router, to, state) {
284
573
  const location = toLocation(router, to, state);
285
- const viewPromise = resolve(router, location);
286
- return commit(router, viewPromise, location);
574
+ return pushEntry(router, resolveEntry(router, location), location);
287
575
  }
288
576
 
289
577
  /**
290
- * Refresh the page.
578
+ * Refresh the page. Route guards run before the view resolves; a redirect
579
+ * replaces the current entry with the terminal location.
291
580
  * @group Methods
292
581
  * @category Router
293
582
  * @param router router instance
294
583
  */
295
584
  function refresh(router) {
296
585
  const location = getLocation(router);
297
- const viewPromise = resolve(router, location);
298
- return commitReplace(router, viewPromise, location);
586
+ return replaceEntry(router, resolveEntry(router, location), location);
299
587
  }
300
588
 
301
589
  /**
@@ -350,12 +638,13 @@ function createHref({
350
638
  * @category Router
351
639
  * @param router router instance
352
640
  */
353
- function cancel({
354
- cancelAll,
355
- onLoadingChange = noop
356
- }) {
357
- cancelAll();
358
- onLoadingChange();
641
+ function cancel(router) {
642
+ // The cancelled chain parks forever, so nothing else will clear the
643
+ // in-flight mark: drop it here, or a later navigation would fire a
644
+ // spurious cancel signal(`onLoadingChange()`) for a dead resolve.
645
+ router.resolving = undefined;
646
+ router.cancelAll();
647
+ router.onLoadingChange?.();
359
648
  }
360
649
 
361
650
  /**
@@ -370,7 +659,7 @@ function cancel({
370
659
  * @param router router instance
371
660
  */
372
661
  function initHistoryStack(router) {
373
- return Promise.all(router.locationStack.map(location => location ? resolve(router, location) : Promise.resolve(null))).then(views => {
662
+ return Promise.all(router.locationStack.map(location => resolve(router, location))).then(views => {
374
663
  router.viewStack = views;
375
664
  });
376
665
  }
@@ -394,13 +683,15 @@ function listen(router, onViewChange) {
394
683
  cancel(router);
395
684
  const state = location.state;
396
685
  const index = state?.index || 0;
397
- const view = router.viewStack[index];
686
+ const view = viewAt(router, index);
398
687
  onViewChange(view);
399
688
  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);
689
+ // Lazy fallback for out-of-window slots and window-less legacy
690
+ // state: re-resolving the landed entry also re-serializes the
691
+ // window into it via the replace commit. A guard failure here
692
+ // must not surface as an unhandled rejection — the landed entry
693
+ // simply keeps its(unknown) view.
694
+ refresh(router).catch(noop);
404
695
  } else if (action === 'POP') {
405
696
  // Sync the current window into the landed entry so a later
406
697
  // refresh("refresh → back → refresh again") still restores it.
@@ -419,15 +710,20 @@ function listen(router, onViewChange) {
419
710
  }
420
711
 
421
712
  /**
422
- * Merge params of all matched levels. Params of deeper levels override
713
+ * Merge params of the matched levels. Params of deeper levels override
423
714
  * the same keys of shallower ones.
715
+ *
716
+ * When `end` is given, only the levels up to and including `end` are
717
+ * merged — the accumulated params a level at index `end` sees(shallow →
718
+ * current level). Omitting `end` merges every level.
424
719
  * @group Methods
425
720
  * @category Router
426
721
  * @param matched matched route levels, see {@link match}
722
+ * @param end the index of the last level to merge, defaults to the deepest
427
723
  * @returns the merged params object
428
724
  */
429
- function mergeMatchedParams(matched) {
430
- return matched.reduce((params, {
725
+ function mergeMatchedParams(matched, end) {
726
+ return matched.slice(0, end === undefined ? matched.length : end + 1).reduce((params, {
431
727
  params: levelParams
432
728
  }) => ({
433
729
  ...params,
@@ -436,7 +732,11 @@ function mergeMatchedParams(matched) {
436
732
  }
437
733
 
438
734
  /**
439
- * Get current route params from router. Merges params of all matched levels.
735
+ * Get current route params from router. The params are re-derived by
736
+ * matching the current entry of {@link RouterInstance.locationStack} so
737
+ * they stay correct even when the view stack holds resolved views
738
+ * (e.g. React elements) instead of match results. Merges params of all
739
+ * matched levels; deeper levels override shallower ones.
440
740
  * @group Methods
441
741
  * @category Router
442
742
  * @param router router instance
@@ -446,9 +746,9 @@ function getParams(router) {
446
746
  const {
447
747
  index
448
748
  } = getHistoryState(router);
449
- const matched = router.viewStack[index];
450
- if (!matched || !matched.length) return {};
451
- return mergeMatchedParams(matched);
749
+ const location = router.locationStack[index - router.baseIndex];
750
+ if (!location) return {};
751
+ return mergeMatchedParams(match(router, location.pathname) ?? []);
452
752
  }
453
753
 
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 };
754
+ export { NativeRouterError, NotFoundError, RedirectLoopError, back, cancel, commit, commitReplace, create, createHref, forward, getCurrentView, getLocation, getParams, go, initHistoryStack, listen, match, mergeMatchedParams, navigate, preload, refresh, resolve, resolveEntry, resolveTo, setOptions, toLocation };
@@ -3,3 +3,6 @@ export declare class NativeRouterError extends Error {
3
3
  export declare class NotFoundError extends NativeRouterError {
4
4
  constructor(pathname: string);
5
5
  }
6
+ export declare class RedirectLoopError extends NativeRouterError {
7
+ constructor(target?: string);
8
+ }