@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/README.md +120 -55
- package/dist/index.cjs +475 -64
- package/dist/index.mjs +469 -65
- package/dist/types/errors.d.ts +17 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/router.d.ts +74 -6
- package/dist/types/search.d.ts +45 -0
- package/dist/types/types.d.ts +115 -0
- package/package.json +1 -1
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:
|
|
96
|
+
history: instanceHistory
|
|
34
97
|
});
|
|
35
|
-
// Restore the session
|
|
36
|
-
// current entry state
|
|
37
|
-
//
|
|
38
|
-
const locationStack = restoreLocationStack(
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
108
|
+
history: instanceHistory,
|
|
47
109
|
locationStack,
|
|
48
|
-
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
|
89
|
-
*
|
|
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
|
|
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,
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 =>
|
|
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
|
|
714
|
+
const view = viewAt(router, index);
|
|
400
715
|
onViewChange(view);
|
|
401
716
|
if (!view) {
|
|
402
|
-
// Lazy fallback for
|
|
403
|
-
// re-resolving the landed entry also re-serializes the
|
|
404
|
-
// into it via the replace commit.
|
|
405
|
-
|
|
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
|
|
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.
|
|
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
|
|
452
|
-
if (!
|
|
453
|
-
return mergeMatchedParams(
|
|
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;
|