@solidjs/router 0.16.1 → 0.16.3
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/components.jsx +3 -2
- package/dist/data/action.js +5 -2
- package/dist/data/query.js +5 -5
- package/dist/index.js +185 -35
- package/dist/lifecycle.js +8 -1
- package/dist/routers/Router.d.ts +8 -0
- package/dist/routers/Router.js +25 -11
- package/dist/routers/components.d.ts +2 -2
- package/dist/routers/components.jsx +4 -1
- package/dist/routers/scrollRestoration.d.ts +24 -0
- package/dist/routers/scrollRestoration.js +106 -0
- package/dist/routing.js +29 -6
- package/dist/types.d.ts +5 -2
- package/dist/utils.js +9 -1
- package/package.json +1 -1
package/dist/components.jsx
CHANGED
|
@@ -18,8 +18,9 @@ export function A(props) {
|
|
|
18
18
|
const to_ = to();
|
|
19
19
|
if (to_ === undefined)
|
|
20
20
|
return [false, false];
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// trailing slashes are ignored so `/route` and `/route/` share active state
|
|
22
|
+
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
|
|
23
|
+
const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
|
|
23
24
|
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
|
|
24
25
|
});
|
|
25
26
|
return (<a {...rest} href={href() || props.href} state={JSON.stringify(props.state)} classList={{
|
package/dist/data/action.js
CHANGED
|
@@ -24,7 +24,7 @@ export function useSubmission(fn, filter) {
|
|
|
24
24
|
const submissions = useSubmissions(fn, filter);
|
|
25
25
|
return new Proxy({}, {
|
|
26
26
|
get(_, property) {
|
|
27
|
-
if (
|
|
27
|
+
if (submissions.length === 0 && (property === "clear" || property === "retry"))
|
|
28
28
|
return () => { };
|
|
29
29
|
return submissions[submissions.length - 1]?.[property];
|
|
30
30
|
}
|
|
@@ -118,7 +118,10 @@ function toAction(fn, url) {
|
|
|
118
118
|
fn.url = url;
|
|
119
119
|
if (!isServer) {
|
|
120
120
|
actions.set(url, fn);
|
|
121
|
-
|
|
121
|
+
// Only remove the registration if it still belongs to this instance —
|
|
122
|
+
// a re-created action (e.g. a new `.with()` binding after revalidation)
|
|
123
|
+
// may have registered itself under the same URL since.
|
|
124
|
+
getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
|
|
122
125
|
}
|
|
123
126
|
return fn;
|
|
124
127
|
}
|
package/dist/data/query.js
CHANGED
|
@@ -28,12 +28,12 @@ function getCache() {
|
|
|
28
28
|
* Revalidates the given cache entry/entries.
|
|
29
29
|
*/
|
|
30
30
|
export function revalidate(key, force = true) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
// force the cache miss synchronously so a `refetch` in the same tick sees it —
|
|
33
|
+
// startTransition defers its callback to a microtask (#497)
|
|
34
|
+
force && cacheKeyOp(key, entry => (entry[0] = 0));
|
|
31
35
|
return startTransition(() => {
|
|
32
|
-
|
|
33
|
-
cacheKeyOp(key, entry => {
|
|
34
|
-
force && (entry[0] = 0); //force cache miss
|
|
35
|
-
entry[4][1](now); // retrigger live signals
|
|
36
|
-
});
|
|
36
|
+
cacheKeyOp(key, entry => entry[4][1](now)); // retrigger live signals
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
export function cacheKeyOp(key, fn) {
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isServer, getRequestEvent, createComponent as createComponent$1, memo, delegateEvents, spread, mergeProps as mergeProps$1, template } from 'solid-js/web';
|
|
2
|
-
import { getOwner, runWithOwner, createMemo, createContext, onCleanup, useContext, untrack, createSignal, createRenderEffect, on, startTransition, resetErrorBoundaries, batch, createComponent, children, mergeProps, Show, createRoot, sharedConfig, getListener, $TRACK, splitProps, createResource, catchError } from 'solid-js';
|
|
2
|
+
import { getOwner, runWithOwner, createMemo, createContext, onCleanup, useContext, untrack, createSignal, createRenderEffect, on, startTransition, resetErrorBoundaries, batch, createComponent, children, mergeProps, Show, createRoot, sharedConfig, getListener, $TRACK, createEffect, splitProps, createResource, catchError } from 'solid-js';
|
|
3
3
|
import { createStore, reconcile, unwrap } from 'solid-js/store';
|
|
4
4
|
|
|
5
5
|
function createBeforeLeave() {
|
|
@@ -18,7 +18,14 @@ function createBeforeLeave() {
|
|
|
18
18
|
preventDefault: () => e.defaultPrevented = true
|
|
19
19
|
};
|
|
20
20
|
for (const l of listeners) l.listener({
|
|
21
|
-
|
|
21
|
+
to,
|
|
22
|
+
options,
|
|
23
|
+
// delegate to the shared event so later listeners' preventDefault
|
|
24
|
+
// calls are observable from earlier listeners
|
|
25
|
+
get defaultPrevented() {
|
|
26
|
+
return e.defaultPrevented;
|
|
27
|
+
},
|
|
28
|
+
preventDefault: e.preventDefault,
|
|
22
29
|
from: l.location,
|
|
23
30
|
retry: force => {
|
|
24
31
|
force && (ignore = true);
|
|
@@ -122,7 +129,12 @@ function createMatcher(path, partial, matchFilters) {
|
|
|
122
129
|
const segments = pattern.split("/").filter(Boolean);
|
|
123
130
|
const len = segments.length;
|
|
124
131
|
return location => {
|
|
125
|
-
const locSegments = location.split("/")
|
|
132
|
+
const locSegments = location.split("/");
|
|
133
|
+
// tolerate a single leading and trailing slash, but reject empty interior
|
|
134
|
+
// segments so `/foo//bar` doesn't silently match `/foo/bar` (#567)
|
|
135
|
+
if (locSegments[0] === "") locSegments.shift();
|
|
136
|
+
if (locSegments.length && locSegments[locSegments.length - 1] === "") locSegments.pop();
|
|
137
|
+
if (locSegments.includes("")) return null;
|
|
126
138
|
const lenDiff = locSegments.length - len;
|
|
127
139
|
if (lenDiff < 0 || lenDiff > 0 && splat === undefined && !partial) {
|
|
128
140
|
return null;
|
|
@@ -364,7 +376,11 @@ const useMatch = (path, matchFilters) => {
|
|
|
364
376
|
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
|
|
365
377
|
* ```
|
|
366
378
|
*/
|
|
367
|
-
const useCurrentMatches = () =>
|
|
379
|
+
const useCurrentMatches = () => {
|
|
380
|
+
const router = useRouter();
|
|
381
|
+
// return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
|
|
382
|
+
return () => router.matches().slice();
|
|
383
|
+
};
|
|
368
384
|
|
|
369
385
|
/**
|
|
370
386
|
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
|
|
@@ -407,11 +423,20 @@ const useParams = () => useRouter().params;
|
|
|
407
423
|
* ```
|
|
408
424
|
*/
|
|
409
425
|
const useSearchParams = () => {
|
|
410
|
-
const
|
|
426
|
+
const router = useRouter();
|
|
427
|
+
const location = router.location;
|
|
411
428
|
const navigate = useNavigate();
|
|
412
429
|
const setSearchParams = (params, options) => {
|
|
413
|
-
const
|
|
414
|
-
|
|
430
|
+
const to = untrack(() => {
|
|
431
|
+
// merge onto the in-flight navigation target (if any) so consecutive
|
|
432
|
+
// synchronous calls compose instead of the later one winning
|
|
433
|
+
const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
|
|
434
|
+
const pathname = pending ? pending.pathname : location.pathname;
|
|
435
|
+
const search = pending ? pending.search : location.search;
|
|
436
|
+
const hash = pending ? pending.hash : location.hash;
|
|
437
|
+
return pathname + mergeSearchString(search, params) + hash;
|
|
438
|
+
});
|
|
439
|
+
navigate(to, {
|
|
415
440
|
scroll: false,
|
|
416
441
|
resolve: false,
|
|
417
442
|
...options
|
|
@@ -455,6 +480,12 @@ const useBeforeLeave = listener => {
|
|
|
455
480
|
});
|
|
456
481
|
onCleanup(s);
|
|
457
482
|
};
|
|
483
|
+
|
|
484
|
+
// Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
|
|
485
|
+
// pchar characters (sub-delims / ":" / "@") literal, matching how browsers
|
|
486
|
+
// report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
|
|
487
|
+
// still percent-encoded exactly as before, since browsers encode those too.
|
|
488
|
+
const encodeSegment = s => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
|
|
458
489
|
function createRoutes(routeDef, base = "") {
|
|
459
490
|
const {
|
|
460
491
|
component,
|
|
@@ -475,7 +506,7 @@ function createRoutes(routeDef, base = "") {
|
|
|
475
506
|
const path = joinPaths(base, expandedPath);
|
|
476
507
|
let pattern = isLeaf ? path : path.split("/*", 1)[0];
|
|
477
508
|
pattern = pattern.split("/").map(s => {
|
|
478
|
-
return s.startsWith(":") || s.startsWith("*") ? s :
|
|
509
|
+
return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
|
|
479
510
|
}).join("/");
|
|
480
511
|
acc.push({
|
|
481
512
|
...shared,
|
|
@@ -549,7 +580,9 @@ function createLocation(path, state, queryWrapper) {
|
|
|
549
580
|
const url = createMemo(prev => {
|
|
550
581
|
const path_ = path();
|
|
551
582
|
try {
|
|
552
|
-
|
|
583
|
+
// anchor rooted paths against the origin explicitly - a path with
|
|
584
|
+
// doubled leading slashes would otherwise parse as protocol-relative
|
|
585
|
+
return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
|
|
553
586
|
} catch (err) {
|
|
554
587
|
console.error(`Invalid path ${path_}`);
|
|
555
588
|
return prev;
|
|
@@ -677,6 +710,9 @@ function createRouterContext(integration, branches, getContext, options = {}) {
|
|
|
677
710
|
location,
|
|
678
711
|
params,
|
|
679
712
|
isRouting,
|
|
713
|
+
get pendingTarget() {
|
|
714
|
+
return lastTransitionTarget;
|
|
715
|
+
},
|
|
680
716
|
renderPath,
|
|
681
717
|
parsePath,
|
|
682
718
|
navigatorFactory,
|
|
@@ -925,6 +961,9 @@ function Routes(props) {
|
|
|
925
961
|
}
|
|
926
962
|
const disposers = [];
|
|
927
963
|
let root;
|
|
964
|
+
// dispose the detached per-route roots when this component unmounts, otherwise
|
|
965
|
+
// they stay subscribed to `matches` and crash on a later navigation (#451)
|
|
966
|
+
onCleanup(() => disposers.forEach(dispose => dispose()));
|
|
928
967
|
const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
|
|
929
968
|
let equal = prevMatches && nextMatches.length === prevMatches.length;
|
|
930
969
|
const next = [];
|
|
@@ -1078,12 +1117,12 @@ function getCache() {
|
|
|
1078
1117
|
* Revalidates the given cache entry/entries.
|
|
1079
1118
|
*/
|
|
1080
1119
|
function revalidate(key, force = true) {
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
// force the cache miss synchronously so a `refetch` in the same tick sees it —
|
|
1122
|
+
// startTransition defers its callback to a microtask (#497)
|
|
1123
|
+
force && cacheKeyOp(key, entry => entry[0] = 0);
|
|
1081
1124
|
return startTransition(() => {
|
|
1082
|
-
|
|
1083
|
-
cacheKeyOp(key, entry => {
|
|
1084
|
-
force && (entry[0] = 0); //force cache miss
|
|
1085
|
-
entry[4][1](now); // retrigger live signals
|
|
1086
|
-
});
|
|
1125
|
+
cacheKeyOp(key, entry => entry[4][1](now)); // retrigger live signals
|
|
1087
1126
|
});
|
|
1088
1127
|
}
|
|
1089
1128
|
function cacheKeyOp(key, fn) {
|
|
@@ -1265,7 +1304,7 @@ function useSubmission(fn, filter) {
|
|
|
1265
1304
|
const submissions = useSubmissions(fn, filter);
|
|
1266
1305
|
return new Proxy({}, {
|
|
1267
1306
|
get(_, property) {
|
|
1268
|
-
if (submissions.length === 0 && property === "clear" || property === "retry") return () => {};
|
|
1307
|
+
if (submissions.length === 0 && (property === "clear" || property === "retry")) return () => {};
|
|
1269
1308
|
return submissions[submissions.length - 1]?.[property];
|
|
1270
1309
|
}
|
|
1271
1310
|
});
|
|
@@ -1356,7 +1395,10 @@ function toAction(fn, url) {
|
|
|
1356
1395
|
fn.url = url;
|
|
1357
1396
|
if (!isServer) {
|
|
1358
1397
|
actions.set(url, fn);
|
|
1359
|
-
|
|
1398
|
+
// Only remove the registration if it still belongs to this instance —
|
|
1399
|
+
// a re-created action (e.g. a new `.with()` binding after revalidation)
|
|
1400
|
+
// may have registered itself under the same URL since.
|
|
1401
|
+
getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
|
|
1360
1402
|
}
|
|
1361
1403
|
return fn;
|
|
1362
1404
|
}
|
|
@@ -1508,10 +1550,107 @@ function setupNativeEvents({
|
|
|
1508
1550
|
};
|
|
1509
1551
|
}
|
|
1510
1552
|
|
|
1553
|
+
const STORAGE_KEY = "solid-router:scroll";
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Explicit scroll restoration for back/forward navigation. The browser's
|
|
1557
|
+
* native same-document heuristic is unreliable for suspense-driven rendering:
|
|
1558
|
+
* if the destination route forces a layout while the document is still short,
|
|
1559
|
+
* the saved offset for the previous entry is clamped and lost (#577).
|
|
1560
|
+
*
|
|
1561
|
+
* Positions are captured continuously from the scroll event, keyed by the
|
|
1562
|
+
* `_depth` the router already stamps on every history entry — capturing at
|
|
1563
|
+
* scroll time (rather than at exit) stays correct through `useBeforeLeave`
|
|
1564
|
+
* blocked/reverted traversals. The map persists to sessionStorage on pagehide
|
|
1565
|
+
* so restoration survives reloads, which `scrollRestoration = "manual"`
|
|
1566
|
+
* otherwise disables. Restoration runs once routing settles; if the document
|
|
1567
|
+
* is still shorter than the target (a boundary below the fold hasn't
|
|
1568
|
+
* resolved), a ResizeObserver retries as content grows, cancelled by the
|
|
1569
|
+
* first user scroll.
|
|
1570
|
+
*/
|
|
1571
|
+
function createScrollRestoration() {
|
|
1572
|
+
window.history.scrollRestoration = "manual";
|
|
1573
|
+
// the current entry needs its depth stamp for captures to have a key, even
|
|
1574
|
+
// if something replaced history.state after the lifecycle module loaded
|
|
1575
|
+
saveCurrentDepth();
|
|
1576
|
+
let positions = {};
|
|
1577
|
+
try {
|
|
1578
|
+
positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {};
|
|
1579
|
+
} catch {}
|
|
1580
|
+
const depth = () => window.history.state && window.history.state._depth;
|
|
1581
|
+
let programmatic = false;
|
|
1582
|
+
let pending;
|
|
1583
|
+
let disconnect;
|
|
1584
|
+
const cancelGuard = () => {
|
|
1585
|
+
disconnect && disconnect();
|
|
1586
|
+
disconnect = undefined;
|
|
1587
|
+
};
|
|
1588
|
+
const unbind = [bindEvent(window, "scroll", () => {
|
|
1589
|
+
const d = depth();
|
|
1590
|
+
if (d != null) positions[d] = window.scrollY;
|
|
1591
|
+
if (!programmatic) {
|
|
1592
|
+
// the user took over — a pending or chasing restore would yank them
|
|
1593
|
+
pending = undefined;
|
|
1594
|
+
cancelGuard();
|
|
1595
|
+
}
|
|
1596
|
+
}), bindEvent(window, "pagehide", () => {
|
|
1597
|
+
try {
|
|
1598
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
|
|
1599
|
+
} catch {}
|
|
1600
|
+
})];
|
|
1601
|
+
const restore = () => {
|
|
1602
|
+
if (pending == null) return;
|
|
1603
|
+
const y = positions[pending];
|
|
1604
|
+
pending = undefined;
|
|
1605
|
+
if (y == null) return;
|
|
1606
|
+
cancelGuard();
|
|
1607
|
+
const attempt = () => {
|
|
1608
|
+
programmatic = true;
|
|
1609
|
+
window.scrollTo(0, y);
|
|
1610
|
+
programmatic = false;
|
|
1611
|
+
// reachable once the document is tall enough to hold the offset
|
|
1612
|
+
return document.documentElement.scrollHeight - window.innerHeight >= y;
|
|
1613
|
+
};
|
|
1614
|
+
if (!attempt() && typeof ResizeObserver !== "undefined") {
|
|
1615
|
+
const observer = new ResizeObserver(() => attempt() && cancelGuard());
|
|
1616
|
+
observer.observe(document.documentElement);
|
|
1617
|
+
disconnect = () => observer.disconnect();
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
return {
|
|
1621
|
+
/** Before the router reacts to a popstate: mark the traversal target. */
|
|
1622
|
+
onPop() {
|
|
1623
|
+
pending = depth();
|
|
1624
|
+
},
|
|
1625
|
+
/** After a push: forward entries died, and this depth may be reused. */
|
|
1626
|
+
onPush() {
|
|
1627
|
+
const d = depth();
|
|
1628
|
+
if (d != null) for (const k in positions) +k >= d && delete positions[k];
|
|
1629
|
+
},
|
|
1630
|
+
create(router) {
|
|
1631
|
+
createEffect(on(router.isRouting, routing => routing || restore(), {
|
|
1632
|
+
defer: true
|
|
1633
|
+
}));
|
|
1634
|
+
onCleanup(() => {
|
|
1635
|
+
unbind.forEach(u => u());
|
|
1636
|
+
cancelGuard();
|
|
1637
|
+
});
|
|
1638
|
+
// reload/back_forward document loads land on an existing entry; a fresh
|
|
1639
|
+
// navigation starts a new one and belongs at the top
|
|
1640
|
+
const [nav] = performance.getEntriesByType && performance.getEntriesByType("navigation");
|
|
1641
|
+
if (nav && nav.type !== "navigate") {
|
|
1642
|
+
pending = depth();
|
|
1643
|
+
restore();
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1511
1649
|
function Router(props) {
|
|
1512
1650
|
if (isServer) return StaticRouter(props);
|
|
1651
|
+
const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
|
|
1513
1652
|
const getSource = () => {
|
|
1514
|
-
const url = window.location.pathname
|
|
1653
|
+
const url = window.location.pathname + window.location.search;
|
|
1515
1654
|
const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
|
|
1516
1655
|
return {
|
|
1517
1656
|
value: url + window.location.hash,
|
|
@@ -1534,23 +1673,33 @@ function Router(props) {
|
|
|
1534
1673
|
}
|
|
1535
1674
|
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
|
|
1536
1675
|
saveCurrentDepth();
|
|
1676
|
+
restoration && !replace && restoration.onPush();
|
|
1677
|
+
},
|
|
1678
|
+
init: notify => {
|
|
1679
|
+
const handler = notifyIfNotBlocked(notify, delta => {
|
|
1680
|
+
if (delta) {
|
|
1681
|
+
return !beforeLeave.confirm(delta);
|
|
1682
|
+
} else {
|
|
1683
|
+
const s = getSource();
|
|
1684
|
+
return !beforeLeave.confirm(s.value, {
|
|
1685
|
+
state: s.state
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
return bindEvent(window, "popstate", restoration ? () => {
|
|
1690
|
+
restoration.onPop();
|
|
1691
|
+
handler();
|
|
1692
|
+
} : handler);
|
|
1693
|
+
},
|
|
1694
|
+
create: router => {
|
|
1695
|
+
setupNativeEvents({
|
|
1696
|
+
preload: props.preload,
|
|
1697
|
+
explicitLinks: props.explicitLinks,
|
|
1698
|
+
actionBase: props.actionBase,
|
|
1699
|
+
transformUrl: props.transformUrl
|
|
1700
|
+
})(router);
|
|
1701
|
+
restoration && restoration.create(router);
|
|
1537
1702
|
},
|
|
1538
|
-
init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
|
|
1539
|
-
if (delta) {
|
|
1540
|
-
return !beforeLeave.confirm(delta);
|
|
1541
|
-
} else {
|
|
1542
|
-
const s = getSource();
|
|
1543
|
-
return !beforeLeave.confirm(s.value, {
|
|
1544
|
-
state: s.state
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
})),
|
|
1548
|
-
create: setupNativeEvents({
|
|
1549
|
-
preload: props.preload,
|
|
1550
|
-
explicitLinks: props.explicitLinks,
|
|
1551
|
-
actionBase: props.actionBase,
|
|
1552
|
-
transformUrl: props.transformUrl
|
|
1553
|
-
}),
|
|
1554
1703
|
utils: {
|
|
1555
1704
|
go: delta => window.history.go(delta),
|
|
1556
1705
|
beforeLeave
|
|
@@ -1681,8 +1830,9 @@ function A(props) {
|
|
|
1681
1830
|
const isActive = createMemo(() => {
|
|
1682
1831
|
const to_ = to();
|
|
1683
1832
|
if (to_ === undefined) return [false, false];
|
|
1684
|
-
|
|
1685
|
-
const
|
|
1833
|
+
// trailing slashes are ignored so `/route` and `/route/` share active state
|
|
1834
|
+
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
|
|
1835
|
+
const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
|
|
1686
1836
|
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
|
|
1687
1837
|
});
|
|
1688
1838
|
return (() => {
|
package/dist/lifecycle.js
CHANGED
|
@@ -17,7 +17,14 @@ export function createBeforeLeave() {
|
|
|
17
17
|
};
|
|
18
18
|
for (const l of listeners)
|
|
19
19
|
l.listener({
|
|
20
|
-
|
|
20
|
+
to,
|
|
21
|
+
options,
|
|
22
|
+
// delegate to the shared event so later listeners' preventDefault
|
|
23
|
+
// calls are observable from earlier listeners
|
|
24
|
+
get defaultPrevented() {
|
|
25
|
+
return e.defaultPrevented;
|
|
26
|
+
},
|
|
27
|
+
preventDefault: e.preventDefault,
|
|
21
28
|
from: l.location,
|
|
22
29
|
retry: (force) => {
|
|
23
30
|
force && (ignore = true);
|
package/dist/routers/Router.d.ts
CHANGED
|
@@ -5,5 +5,13 @@ export type RouterProps = BaseRouterProps & {
|
|
|
5
5
|
actionBase?: string;
|
|
6
6
|
explicitLinks?: boolean;
|
|
7
7
|
preload?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Opt-in explicit scroll restoration for back/forward navigation (read
|
|
10
|
+
* once, not reactive). Sets `history.scrollRestoration = "manual"` and
|
|
11
|
+
* restores saved positions after the navigation settles, replacing the
|
|
12
|
+
* browser heuristic that loses offsets when the destination route forces
|
|
13
|
+
* a layout while rendering (#577).
|
|
14
|
+
*/
|
|
15
|
+
scrollRestoration?: boolean;
|
|
8
16
|
};
|
|
9
17
|
export declare function Router(props: RouterProps): JSX.Element;
|
package/dist/routers/Router.js
CHANGED
|
@@ -3,11 +3,13 @@ import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
|
|
|
3
3
|
import { StaticRouter } from "./StaticRouter.js";
|
|
4
4
|
import { setupNativeEvents } from "../data/events.js";
|
|
5
5
|
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
|
|
6
|
+
import { createScrollRestoration } from "./scrollRestoration.js";
|
|
6
7
|
export function Router(props) {
|
|
7
8
|
if (isServer)
|
|
8
9
|
return StaticRouter(props);
|
|
10
|
+
const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
|
|
9
11
|
const getSource = () => {
|
|
10
|
-
const url = window.location.pathname
|
|
12
|
+
const url = window.location.pathname + window.location.search;
|
|
11
13
|
const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
|
|
12
14
|
return {
|
|
13
15
|
value: url + window.location.hash,
|
|
@@ -26,17 +28,29 @@ export function Router(props) {
|
|
|
26
28
|
}
|
|
27
29
|
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
|
|
28
30
|
saveCurrentDepth();
|
|
31
|
+
restoration && !replace && restoration.onPush();
|
|
32
|
+
},
|
|
33
|
+
init: notify => {
|
|
34
|
+
const handler = notifyIfNotBlocked(notify, delta => {
|
|
35
|
+
if (delta) {
|
|
36
|
+
return !beforeLeave.confirm(delta);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const s = getSource();
|
|
40
|
+
return !beforeLeave.confirm(s.value, { state: s.state });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return bindEvent(window, "popstate", restoration
|
|
44
|
+
? () => {
|
|
45
|
+
restoration.onPop();
|
|
46
|
+
handler();
|
|
47
|
+
}
|
|
48
|
+
: handler);
|
|
49
|
+
},
|
|
50
|
+
create: router => {
|
|
51
|
+
setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl })(router);
|
|
52
|
+
restoration && restoration.create(router);
|
|
29
53
|
},
|
|
30
|
-
init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
|
|
31
|
-
if (delta) {
|
|
32
|
-
return !beforeLeave.confirm(delta);
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
const s = getSource();
|
|
36
|
-
return !beforeLeave.confirm(s.value, { state: s.state });
|
|
37
|
-
}
|
|
38
|
-
})),
|
|
39
|
-
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
|
|
40
54
|
utils: {
|
|
41
55
|
go: delta => window.history.go(delta),
|
|
42
56
|
beforeLeave
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Component, JSX } from "solid-js";
|
|
2
|
-
import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionProps } from "../types.js";
|
|
2
|
+
import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionComponent, RouteSectionProps } from "../types.js";
|
|
3
3
|
export type BaseRouterProps = {
|
|
4
4
|
base?: string;
|
|
5
5
|
/**
|
|
@@ -19,7 +19,7 @@ export type RouteProps<S extends string, T = unknown> = {
|
|
|
19
19
|
children?: JSX.Element;
|
|
20
20
|
preload?: RoutePreloadFunc<T>;
|
|
21
21
|
matchFilters?: MatchFilters<S>;
|
|
22
|
-
component?:
|
|
22
|
+
component?: RouteSectionComponent<T>;
|
|
23
23
|
info?: Record<string, any>;
|
|
24
24
|
/** @deprecated use preload */
|
|
25
25
|
load?: RoutePreloadFunc<T>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*@refresh skip*/
|
|
2
|
-
import { children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack } from "solid-js";
|
|
2
|
+
import { children, createMemo, createRoot, getOwner, mergeProps, on, onCleanup, Show, untrack } from "solid-js";
|
|
3
3
|
import { getRequestEvent, isServer } from "solid-js/web";
|
|
4
4
|
import { createBranches, createRouteContext, createRouterContext, getIntent, getRouteMatches, RouteContextObj, RouterContextObj, setInPreloadFn } from "../routing.js";
|
|
5
5
|
export const createRouterComponent = (router) => (props) => {
|
|
@@ -54,6 +54,9 @@ function Routes(props) {
|
|
|
54
54
|
}
|
|
55
55
|
const disposers = [];
|
|
56
56
|
let root;
|
|
57
|
+
// dispose the detached per-route roots when this component unmounts, otherwise
|
|
58
|
+
// they stay subscribed to `matches` and crash on a later navigation (#451)
|
|
59
|
+
onCleanup(() => disposers.forEach(dispose => dispose()));
|
|
57
60
|
const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
|
|
58
61
|
let equal = prevMatches && nextMatches.length === prevMatches.length;
|
|
59
62
|
const next = [];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RouterContext } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Explicit scroll restoration for back/forward navigation. The browser's
|
|
4
|
+
* native same-document heuristic is unreliable for suspense-driven rendering:
|
|
5
|
+
* if the destination route forces a layout while the document is still short,
|
|
6
|
+
* the saved offset for the previous entry is clamped and lost (#577).
|
|
7
|
+
*
|
|
8
|
+
* Positions are captured continuously from the scroll event, keyed by the
|
|
9
|
+
* `_depth` the router already stamps on every history entry — capturing at
|
|
10
|
+
* scroll time (rather than at exit) stays correct through `useBeforeLeave`
|
|
11
|
+
* blocked/reverted traversals. The map persists to sessionStorage on pagehide
|
|
12
|
+
* so restoration survives reloads, which `scrollRestoration = "manual"`
|
|
13
|
+
* otherwise disables. Restoration runs once routing settles; if the document
|
|
14
|
+
* is still shorter than the target (a boundary below the fold hasn't
|
|
15
|
+
* resolved), a ResizeObserver retries as content grows, cancelled by the
|
|
16
|
+
* first user scroll.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createScrollRestoration(): {
|
|
19
|
+
/** Before the router reacts to a popstate: mark the traversal target. */
|
|
20
|
+
onPop(): void;
|
|
21
|
+
/** After a push: forward entries died, and this depth may be reused. */
|
|
22
|
+
onPush(): void;
|
|
23
|
+
create(router: RouterContext): void;
|
|
24
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createEffect, on, onCleanup } from "solid-js";
|
|
2
|
+
import { saveCurrentDepth } from "../lifecycle.js";
|
|
3
|
+
import { bindEvent } from "./createRouter.js";
|
|
4
|
+
const STORAGE_KEY = "solid-router:scroll";
|
|
5
|
+
/**
|
|
6
|
+
* Explicit scroll restoration for back/forward navigation. The browser's
|
|
7
|
+
* native same-document heuristic is unreliable for suspense-driven rendering:
|
|
8
|
+
* if the destination route forces a layout while the document is still short,
|
|
9
|
+
* the saved offset for the previous entry is clamped and lost (#577).
|
|
10
|
+
*
|
|
11
|
+
* Positions are captured continuously from the scroll event, keyed by the
|
|
12
|
+
* `_depth` the router already stamps on every history entry — capturing at
|
|
13
|
+
* scroll time (rather than at exit) stays correct through `useBeforeLeave`
|
|
14
|
+
* blocked/reverted traversals. The map persists to sessionStorage on pagehide
|
|
15
|
+
* so restoration survives reloads, which `scrollRestoration = "manual"`
|
|
16
|
+
* otherwise disables. Restoration runs once routing settles; if the document
|
|
17
|
+
* is still shorter than the target (a boundary below the fold hasn't
|
|
18
|
+
* resolved), a ResizeObserver retries as content grows, cancelled by the
|
|
19
|
+
* first user scroll.
|
|
20
|
+
*/
|
|
21
|
+
export function createScrollRestoration() {
|
|
22
|
+
window.history.scrollRestoration = "manual";
|
|
23
|
+
// the current entry needs its depth stamp for captures to have a key, even
|
|
24
|
+
// if something replaced history.state after the lifecycle module loaded
|
|
25
|
+
saveCurrentDepth();
|
|
26
|
+
let positions = {};
|
|
27
|
+
try {
|
|
28
|
+
positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {};
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
const depth = () => window.history.state && window.history.state._depth;
|
|
32
|
+
let programmatic = false;
|
|
33
|
+
let pending;
|
|
34
|
+
let disconnect;
|
|
35
|
+
const cancelGuard = () => {
|
|
36
|
+
disconnect && disconnect();
|
|
37
|
+
disconnect = undefined;
|
|
38
|
+
};
|
|
39
|
+
const unbind = [
|
|
40
|
+
bindEvent(window, "scroll", () => {
|
|
41
|
+
const d = depth();
|
|
42
|
+
if (d != null)
|
|
43
|
+
positions[d] = window.scrollY;
|
|
44
|
+
if (!programmatic) {
|
|
45
|
+
// the user took over — a pending or chasing restore would yank them
|
|
46
|
+
pending = undefined;
|
|
47
|
+
cancelGuard();
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
bindEvent(window, "pagehide", () => {
|
|
51
|
+
try {
|
|
52
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
})
|
|
56
|
+
];
|
|
57
|
+
const restore = () => {
|
|
58
|
+
if (pending == null)
|
|
59
|
+
return;
|
|
60
|
+
const y = positions[pending];
|
|
61
|
+
pending = undefined;
|
|
62
|
+
if (y == null)
|
|
63
|
+
return;
|
|
64
|
+
cancelGuard();
|
|
65
|
+
const attempt = () => {
|
|
66
|
+
programmatic = true;
|
|
67
|
+
window.scrollTo(0, y);
|
|
68
|
+
programmatic = false;
|
|
69
|
+
// reachable once the document is tall enough to hold the offset
|
|
70
|
+
return document.documentElement.scrollHeight - window.innerHeight >= y;
|
|
71
|
+
};
|
|
72
|
+
if (!attempt() && typeof ResizeObserver !== "undefined") {
|
|
73
|
+
const observer = new ResizeObserver(() => attempt() && cancelGuard());
|
|
74
|
+
observer.observe(document.documentElement);
|
|
75
|
+
disconnect = () => observer.disconnect();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
/** Before the router reacts to a popstate: mark the traversal target. */
|
|
80
|
+
onPop() {
|
|
81
|
+
pending = depth();
|
|
82
|
+
},
|
|
83
|
+
/** After a push: forward entries died, and this depth may be reused. */
|
|
84
|
+
onPush() {
|
|
85
|
+
const d = depth();
|
|
86
|
+
if (d != null)
|
|
87
|
+
for (const k in positions)
|
|
88
|
+
+k >= d && delete positions[k];
|
|
89
|
+
},
|
|
90
|
+
create(router) {
|
|
91
|
+
createEffect(on(router.isRouting, routing => routing || restore(), { defer: true }));
|
|
92
|
+
onCleanup(() => {
|
|
93
|
+
unbind.forEach(u => u());
|
|
94
|
+
cancelGuard();
|
|
95
|
+
});
|
|
96
|
+
// reload/back_forward document loads land on an existing entry; a fresh
|
|
97
|
+
// navigation starts a new one and belongs at the top
|
|
98
|
+
const [nav] = (performance.getEntriesByType &&
|
|
99
|
+
performance.getEntriesByType("navigation"));
|
|
100
|
+
if (nav && nav.type !== "navigate") {
|
|
101
|
+
pending = depth();
|
|
102
|
+
restore();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
package/dist/routing.js
CHANGED
|
@@ -116,7 +116,11 @@ export const useMatch = (path, matchFilters) => {
|
|
|
116
116
|
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
|
|
117
117
|
* ```
|
|
118
118
|
*/
|
|
119
|
-
export const useCurrentMatches = () =>
|
|
119
|
+
export const useCurrentMatches = () => {
|
|
120
|
+
const router = useRouter();
|
|
121
|
+
// return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
|
|
122
|
+
return () => router.matches().slice();
|
|
123
|
+
};
|
|
120
124
|
/**
|
|
121
125
|
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
|
|
122
126
|
*
|
|
@@ -157,11 +161,20 @@ export const useParams = () => useRouter().params;
|
|
|
157
161
|
* ```
|
|
158
162
|
*/
|
|
159
163
|
export const useSearchParams = () => {
|
|
160
|
-
const
|
|
164
|
+
const router = useRouter();
|
|
165
|
+
const location = router.location;
|
|
161
166
|
const navigate = useNavigate();
|
|
162
167
|
const setSearchParams = (params, options) => {
|
|
163
|
-
const
|
|
164
|
-
|
|
168
|
+
const to = untrack(() => {
|
|
169
|
+
// merge onto the in-flight navigation target (if any) so consecutive
|
|
170
|
+
// synchronous calls compose instead of the later one winning
|
|
171
|
+
const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
|
|
172
|
+
const pathname = pending ? pending.pathname : location.pathname;
|
|
173
|
+
const search = pending ? pending.search : location.search;
|
|
174
|
+
const hash = pending ? pending.hash : location.hash;
|
|
175
|
+
return pathname + mergeSearchString(search, params) + hash;
|
|
176
|
+
});
|
|
177
|
+
navigate(to, {
|
|
165
178
|
scroll: false,
|
|
166
179
|
resolve: false,
|
|
167
180
|
...options
|
|
@@ -204,6 +217,11 @@ export const useBeforeLeave = (listener) => {
|
|
|
204
217
|
});
|
|
205
218
|
onCleanup(s);
|
|
206
219
|
};
|
|
220
|
+
// Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
|
|
221
|
+
// pchar characters (sub-delims / ":" / "@") literal, matching how browsers
|
|
222
|
+
// report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
|
|
223
|
+
// still percent-encoded exactly as before, since browsers encode those too.
|
|
224
|
+
const encodeSegment = (s) => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
|
|
207
225
|
export function createRoutes(routeDef, base = "") {
|
|
208
226
|
const { component, preload, load, children, info } = routeDef;
|
|
209
227
|
const isLeaf = !children || (Array.isArray(children) && !children.length);
|
|
@@ -220,7 +238,7 @@ export function createRoutes(routeDef, base = "") {
|
|
|
220
238
|
pattern = pattern
|
|
221
239
|
.split("/")
|
|
222
240
|
.map((s) => {
|
|
223
|
-
return s.startsWith(":") || s.startsWith("*") ? s :
|
|
241
|
+
return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
|
|
224
242
|
})
|
|
225
243
|
.join("/");
|
|
226
244
|
acc.push({
|
|
@@ -296,7 +314,9 @@ function createLocation(path, state, queryWrapper) {
|
|
|
296
314
|
const url = createMemo(prev => {
|
|
297
315
|
const path_ = path();
|
|
298
316
|
try {
|
|
299
|
-
|
|
317
|
+
// anchor rooted paths against the origin explicitly - a path with
|
|
318
|
+
// doubled leading slashes would otherwise parse as protocol-relative
|
|
319
|
+
return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
|
|
300
320
|
}
|
|
301
321
|
catch (err) {
|
|
302
322
|
console.error(`Invalid path ${path_}`);
|
|
@@ -421,6 +441,9 @@ export function createRouterContext(integration, branches, getContext, options =
|
|
|
421
441
|
location,
|
|
422
442
|
params,
|
|
423
443
|
isRouting,
|
|
444
|
+
get pendingTarget() {
|
|
445
|
+
return lastTransitionTarget;
|
|
446
|
+
},
|
|
424
447
|
renderPath,
|
|
425
448
|
parsePath,
|
|
426
449
|
navigatorFactory,
|
package/dist/types.d.ts
CHANGED
|
@@ -71,12 +71,13 @@ export interface RouteSectionProps<T = unknown> {
|
|
|
71
71
|
data: T;
|
|
72
72
|
children?: JSX.Element;
|
|
73
73
|
}
|
|
74
|
-
export type
|
|
74
|
+
export type RouteSectionComponent<T = unknown> = Component<RouteSectionProps<T>> | Component<Omit<RouteSectionProps<T>, "children">> | Component<{}>;
|
|
75
|
+
export type RouteDefinition<S extends string | string[] = any, T = any> = {
|
|
75
76
|
path?: S;
|
|
76
77
|
matchFilters?: MatchFilters<S>;
|
|
77
78
|
preload?: RoutePreloadFunc<T>;
|
|
78
79
|
children?: RouteDefinition | RouteDefinition[];
|
|
79
|
-
component?:
|
|
80
|
+
component?: RouteSectionComponent<T>;
|
|
80
81
|
info?: Record<string, any>;
|
|
81
82
|
/** @deprecated use preload */
|
|
82
83
|
load?: RoutePreloadFunc;
|
|
@@ -137,6 +138,8 @@ export interface RouterContext {
|
|
|
137
138
|
params: Params;
|
|
138
139
|
navigatorFactory: NavigatorFactory;
|
|
139
140
|
isRouting: () => boolean;
|
|
141
|
+
/** The target of the in-flight navigation transition, if any. Not reactive. */
|
|
142
|
+
readonly pendingTarget?: LocationChange;
|
|
140
143
|
matches: () => RouteMatch[];
|
|
141
144
|
renderPath(path: string): string;
|
|
142
145
|
parsePath(str: string): string;
|
package/dist/utils.js
CHANGED
|
@@ -52,7 +52,15 @@ export function createMatcher(path, partial, matchFilters) {
|
|
|
52
52
|
const segments = pattern.split("/").filter(Boolean);
|
|
53
53
|
const len = segments.length;
|
|
54
54
|
return (location) => {
|
|
55
|
-
const locSegments = location.split("/")
|
|
55
|
+
const locSegments = location.split("/");
|
|
56
|
+
// tolerate a single leading and trailing slash, but reject empty interior
|
|
57
|
+
// segments so `/foo//bar` doesn't silently match `/foo/bar` (#567)
|
|
58
|
+
if (locSegments[0] === "")
|
|
59
|
+
locSegments.shift();
|
|
60
|
+
if (locSegments.length && locSegments[locSegments.length - 1] === "")
|
|
61
|
+
locSegments.pop();
|
|
62
|
+
if (locSegments.includes(""))
|
|
63
|
+
return null;
|
|
56
64
|
const lenDiff = locSegments.length - len;
|
|
57
65
|
if (lenDiff < 0 || (lenDiff > 0 && splat === undefined && !partial)) {
|
|
58
66
|
return null;
|