@jk2908/solas 0.16.3 → 0.17.1
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/CHANGELOG.md +24 -0
- package/README.md +3 -1
- package/dist/internal/browser-router/link.d.ts +3 -2
- package/dist/internal/browser-router/link.js +67 -44
- package/dist/internal/browser-router/prefetch-observer.d.ts +11 -0
- package/dist/internal/browser-router/prefetch-observer.js +56 -0
- package/dist/internal/browser-router/router.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.1
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- Links no longer prefetch the route currently displayed. The comparison uses pathname and search, so the same path with a different query still prefetches.
|
|
8
|
+
|
|
9
|
+
## 0.17.0
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
|
|
13
|
+
- Removed `prefetch="none"`. Use `prefetch={false}` or omit the prop.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- `Link` supports `prefetch="viewport"` and `prefetch="auto"`. Viewport prefetches on intersection; auto uses hover on hover-capable devices and viewport elsewhere.
|
|
18
|
+
- Viewport prefetching uses one shared `IntersectionObserver` per router root instead of one per link.
|
|
19
|
+
- `prefetch={false}` is now the explicit off value.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `Link` prefetch modes are resolved once into a single strategy before event handling.
|
|
24
|
+
- `Link` forwards refs with `useImperativeHandle` instead of merging callback refs manually.
|
|
25
|
+
- Examples use React 19.3.1.
|
|
26
|
+
|
|
3
27
|
## 0.16.3
|
|
4
28
|
|
|
5
29
|
### Fixes
|
package/README.md
CHANGED
|
@@ -125,10 +125,12 @@ Use `Link` for same-origin app navigation. Prefetching is opt-in:
|
|
|
125
125
|
<Link href="/posts">Posts</Link>
|
|
126
126
|
<Link href="/posts" prefetch="intent">Prefetch on focus or touch</Link>
|
|
127
127
|
<Link href="/posts" prefetch="hover">Prefetch on hover</Link>
|
|
128
|
+
<Link href="/posts" prefetch="viewport">Prefetch when visible</Link>
|
|
129
|
+
<Link href="/posts" prefetch="auto">Hover or viewport, depending on input</Link>
|
|
128
130
|
<Link href="/p/:id" params={{ id: 'post-1' }}>Typed params</Link>
|
|
129
131
|
```
|
|
130
132
|
|
|
131
|
-
`prefetch=
|
|
133
|
+
`prefetch={false}` is the default. `intent` prefetches on focus or touch, `hover` after a short mouse hover, `viewport` when the link enters the viewport, and `auto` uses hover on hover-capable devices and viewport otherwise. Prefetches are skipped for hidden pages, save-data connections and 2G/slow-2G. Nothing is prefetched unless you opt in with `Link` or call `router.prefetch(...)`.
|
|
132
134
|
|
|
133
135
|
Use `useRouter()` inside client components for programmatic navigation, prefetching, and refreshing the current route:
|
|
134
136
|
|
|
@@ -2,14 +2,15 @@ import * as BrowserRouter from './shared.js';
|
|
|
2
2
|
type AnchorProps = React.ComponentPropsWithRef<'a'> & {
|
|
3
3
|
href: string;
|
|
4
4
|
};
|
|
5
|
+
type PrefetchMode = 'intent' | 'hover' | 'viewport' | 'auto';
|
|
5
6
|
type BaseProps = {
|
|
6
|
-
prefetch?:
|
|
7
|
+
prefetch?: PrefetchMode | false;
|
|
7
8
|
} & AnchorProps;
|
|
8
9
|
type Props = BaseProps & BrowserRouter.LinkProps;
|
|
9
10
|
/**
|
|
10
11
|
* A link component that navigates to a given target.
|
|
11
12
|
* @param href - The route target to navigate to.
|
|
12
|
-
* @param prefetch - When to prefetch the linked page, defaults to
|
|
13
|
+
* @param prefetch - When to prefetch the linked page, defaults to false.
|
|
13
14
|
* @param rest - Other props to pass to the underlying anchor element.
|
|
14
15
|
* @returns A link element that navigates to the given target.
|
|
15
16
|
*/
|
|
@@ -1,26 +1,67 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { useEffect, useImperativeHandle, useRef } from 'react';
|
|
4
|
+
import { usePrefetchObserver } from './prefetch-observer.js';
|
|
4
5
|
import * as BrowserRouter from './shared.js';
|
|
5
6
|
import { useRouter } from './use-router.js';
|
|
7
|
+
const HOVER_DELAY = 100;
|
|
8
|
+
function isCurrent(path) {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(path, window.location.href);
|
|
11
|
+
return (url.pathname === window.location.pathname && url.search === window.location.search);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
6
17
|
function guard(path, prefetcher) {
|
|
7
|
-
const connection = window.navigator.connection;
|
|
8
18
|
if (document.visibilityState === 'hidden')
|
|
9
19
|
return;
|
|
20
|
+
const connection = window.navigator.connection;
|
|
10
21
|
if (connection?.saveData)
|
|
11
22
|
return;
|
|
12
23
|
if (['2g', 'slow-2g'].includes(connection?.effectiveType ?? ''))
|
|
13
24
|
return;
|
|
25
|
+
if (isCurrent(path))
|
|
26
|
+
return;
|
|
14
27
|
prefetcher(path);
|
|
15
28
|
}
|
|
16
|
-
|
|
29
|
+
function getPrefetchStrategy(prefetch, hoverable, observable) {
|
|
30
|
+
if (prefetch !== 'auto')
|
|
31
|
+
return prefetch;
|
|
32
|
+
if (hoverable)
|
|
33
|
+
return 'hover';
|
|
34
|
+
return observable ? 'viewport' : 'intent';
|
|
35
|
+
}
|
|
36
|
+
export function Link({ children, href, params, prefetch = false, query, ref, scroll, ...rest }) {
|
|
17
37
|
const { go, prefetch: prefetcher } = useRouter();
|
|
38
|
+
const observer = usePrefetchObserver();
|
|
18
39
|
const timer = useRef(null);
|
|
19
|
-
|
|
20
|
-
// unnecessary prefetching and event handling for external links
|
|
40
|
+
const anchor = useRef(null);
|
|
21
41
|
const handled = useRef(false);
|
|
42
|
+
const { onClick, onFocus, onTouchStart, onMouseEnter, onMouseLeave, ...anchorProps } = rest;
|
|
22
43
|
const target = BrowserRouter.toTarget(href, params, query);
|
|
23
|
-
|
|
44
|
+
const hoverable = typeof window !== 'undefined' &&
|
|
45
|
+
typeof window.matchMedia === 'function' &&
|
|
46
|
+
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
47
|
+
const observable = typeof IntersectionObserver !== 'undefined';
|
|
48
|
+
const prefetchStrategy = getPrefetchStrategy(prefetch, hoverable, observable);
|
|
49
|
+
useImperativeHandle(ref, () => anchor.current, []);
|
|
50
|
+
function navigate(e) {
|
|
51
|
+
onClick?.(e);
|
|
52
|
+
if (e.defaultPrevented)
|
|
53
|
+
return;
|
|
54
|
+
if (e.button !== 0)
|
|
55
|
+
return;
|
|
56
|
+
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
|
|
57
|
+
return;
|
|
58
|
+
if (anchorProps.target && anchorProps.target !== '_self')
|
|
59
|
+
return;
|
|
60
|
+
if (anchorProps.download || handled.current)
|
|
61
|
+
return;
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
go(href, { params, query, scroll });
|
|
64
|
+
}
|
|
24
65
|
useEffect(() => () => {
|
|
25
66
|
if (timer.current)
|
|
26
67
|
clearTimeout(timer.current);
|
|
@@ -30,54 +71,36 @@ export function Link({ children, href, params, prefetch = 'none', query, scroll,
|
|
|
30
71
|
BrowserRouter.isHashOnlyTarget(target) ||
|
|
31
72
|
BrowserRouter.isExternalTarget(target, window.location.origin);
|
|
32
73
|
}, [target]);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (handled.current)
|
|
47
|
-
return;
|
|
48
|
-
e.preventDefault();
|
|
49
|
-
go(href, { params, query, scroll });
|
|
50
|
-
}, onFocus: e => {
|
|
51
|
-
rest.onFocus?.(e);
|
|
52
|
-
if (e.defaultPrevented)
|
|
53
|
-
return;
|
|
54
|
-
if (prefetch !== 'intent')
|
|
55
|
-
return;
|
|
56
|
-
if (handled.current)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const node = anchor.current;
|
|
76
|
+
if (!node || prefetchStrategy !== 'viewport' || !observable || !observer)
|
|
77
|
+
return;
|
|
78
|
+
if (BrowserRouter.isHashOnlyTarget(target) ||
|
|
79
|
+
BrowserRouter.isExternalTarget(target, window.location.origin)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
observer.observe(node, () => guard(target, prefetcher));
|
|
83
|
+
return () => observer.unobserve(node);
|
|
84
|
+
}, [observable, observer, prefetcher, prefetchStrategy, target]);
|
|
85
|
+
return (_jsx("a", { ...anchorProps, ref: anchor, href: target, onClick: navigate, onFocus: e => {
|
|
86
|
+
onFocus?.(e);
|
|
87
|
+
if (prefetchStrategy !== 'intent' || e.defaultPrevented || handled.current)
|
|
57
88
|
return;
|
|
58
89
|
guard(target, prefetcher);
|
|
59
90
|
}, onTouchStart: e => {
|
|
60
|
-
|
|
61
|
-
if (e.defaultPrevented)
|
|
62
|
-
return;
|
|
63
|
-
if (prefetch !== 'intent')
|
|
64
|
-
return;
|
|
65
|
-
if (handled.current)
|
|
91
|
+
onTouchStart?.(e);
|
|
92
|
+
if (prefetchStrategy !== 'intent' || e.defaultPrevented || handled.current)
|
|
66
93
|
return;
|
|
67
94
|
guard(target, prefetcher);
|
|
68
95
|
}, onMouseEnter: e => {
|
|
69
|
-
|
|
70
|
-
if (e.defaultPrevented)
|
|
71
|
-
return;
|
|
72
|
-
if (prefetch !== 'hover')
|
|
73
|
-
return;
|
|
74
|
-
if (handled.current)
|
|
96
|
+
onMouseEnter?.(e);
|
|
97
|
+
if (prefetchStrategy !== 'hover' || e.defaultPrevented || handled.current)
|
|
75
98
|
return;
|
|
76
99
|
timer.current = setTimeout(() => {
|
|
77
100
|
guard(target, prefetcher);
|
|
78
|
-
},
|
|
101
|
+
}, HOVER_DELAY);
|
|
79
102
|
}, onMouseLeave: e => {
|
|
80
|
-
|
|
103
|
+
onMouseLeave?.(e);
|
|
81
104
|
if (timer.current) {
|
|
82
105
|
clearTimeout(timer.current);
|
|
83
106
|
timer.current = null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Callback = () => void;
|
|
2
|
+
export declare const DEFAULT_PREFETCH_MARGIN = "200px";
|
|
3
|
+
export type PrefetchObserver = {
|
|
4
|
+
observe: (el: Element, callback: Callback) => void;
|
|
5
|
+
unobserve: (el: Element) => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function usePrefetchObserver(): PrefetchObserver | null;
|
|
8
|
+
export declare function PrefetchObserverProvider({ children }: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, use, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
export const DEFAULT_PREFETCH_MARGIN = '200px';
|
|
5
|
+
const PrefetchObserverContext = createContext(null);
|
|
6
|
+
export function usePrefetchObserver() {
|
|
7
|
+
return use(PrefetchObserverContext);
|
|
8
|
+
}
|
|
9
|
+
function getObserver(callbacks, observer) {
|
|
10
|
+
if (observer.current)
|
|
11
|
+
return observer.current;
|
|
12
|
+
if (typeof IntersectionObserver === 'undefined')
|
|
13
|
+
return null;
|
|
14
|
+
observer.current = new IntersectionObserver(entries => {
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.isIntersecting)
|
|
17
|
+
continue;
|
|
18
|
+
const callback = callbacks.get(entry.target);
|
|
19
|
+
if (!callback)
|
|
20
|
+
continue;
|
|
21
|
+
callbacks.delete(entry.target);
|
|
22
|
+
observer.current?.unobserve(entry.target);
|
|
23
|
+
callback();
|
|
24
|
+
}
|
|
25
|
+
if (callbacks.size === 0) {
|
|
26
|
+
observer.current?.disconnect();
|
|
27
|
+
observer.current = null;
|
|
28
|
+
}
|
|
29
|
+
}, { rootMargin: DEFAULT_PREFETCH_MARGIN });
|
|
30
|
+
return observer.current;
|
|
31
|
+
}
|
|
32
|
+
export function PrefetchObserverProvider({ children }) {
|
|
33
|
+
const callbacks = useRef(new Map());
|
|
34
|
+
const observer = useRef(null);
|
|
35
|
+
const value = useMemo(() => ({
|
|
36
|
+
observe(el, callback) {
|
|
37
|
+
callbacks.current.set(el, callback);
|
|
38
|
+
getObserver(callbacks.current, observer)?.observe(el);
|
|
39
|
+
},
|
|
40
|
+
unobserve(el) {
|
|
41
|
+
callbacks.current.delete(el);
|
|
42
|
+
if (callbacks.current.size === 0) {
|
|
43
|
+
observer.current?.disconnect();
|
|
44
|
+
observer.current = null;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
observer.current?.unobserve(el);
|
|
48
|
+
},
|
|
49
|
+
}), []);
|
|
50
|
+
useEffect(() => () => {
|
|
51
|
+
observer.current?.disconnect();
|
|
52
|
+
callbacks.current.clear();
|
|
53
|
+
observer.current = null;
|
|
54
|
+
}, [value]);
|
|
55
|
+
return _jsx(PrefetchObserverContext, { value: value, children: children });
|
|
56
|
+
}
|
|
@@ -6,6 +6,7 @@ import { getDocumentNonce } from '../../utils/get-document-nonce.js';
|
|
|
6
6
|
import { Logger } from '../../utils/logger.js';
|
|
7
7
|
import * as Events from '../../events.js';
|
|
8
8
|
import { BrowserRouterHistory } from './history.js';
|
|
9
|
+
import { PrefetchObserverProvider } from './prefetch-observer.js';
|
|
9
10
|
import { ResponseCache } from './response-cache.js';
|
|
10
11
|
import * as BrowserRouter from './shared.js';
|
|
11
12
|
export * as BrowserRouter from './shared.js';
|
|
@@ -297,5 +298,5 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
297
298
|
index: history.current.index,
|
|
298
299
|
},
|
|
299
300
|
}), [go, prefetch, refresh, isNavigating, url]);
|
|
300
|
-
return _jsx(BrowserRouterContext, { value: value, children: children });
|
|
301
|
+
return (_jsx(PrefetchObserverProvider, { children: _jsx(BrowserRouterContext, { value: value, children: children }) }));
|
|
301
302
|
}
|
package/package.json
CHANGED