@jk2908/solas 0.16.3 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Breaking
6
+
7
+ - Removed `prefetch="none"`. Use `prefetch={false}` or omit the prop.
8
+
9
+ ### Features
10
+
11
+ - `Link` supports `prefetch="viewport"` and `prefetch="auto"`. Viewport prefetches on intersection; auto uses hover on hover-capable devices and viewport elsewhere.
12
+ - Viewport prefetching uses one shared `IntersectionObserver` per router root instead of one per link.
13
+ - `prefetch={false}` is now the explicit off value.
14
+
15
+ ### Changed
16
+
17
+ - `Link` prefetch modes are resolved once into a single strategy before event handling.
18
+ - `Link` forwards refs with `useImperativeHandle` instead of merging callback refs manually.
19
+ - Examples use React 19.3.1.
20
+
3
21
  ## 0.16.3
4
22
 
5
23
  ### 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="none"` is the default. Nothing is prefetched unless you opt in with `Link` or call `router.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?: 'intent' | 'hover' | 'none';
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 'none'.
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,8 +1,10 @@
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;
6
8
  function guard(path, prefetcher) {
7
9
  const connection = window.navigator.connection;
8
10
  if (document.visibilityState === 'hidden')
@@ -13,14 +15,42 @@ function guard(path, prefetcher) {
13
15
  return;
14
16
  prefetcher(path);
15
17
  }
16
- export function Link({ children, href, params, prefetch = 'none', query, scroll, ...rest }) {
18
+ function getPrefetchStrategy(prefetch, hover, hasObserver) {
19
+ if (prefetch !== 'auto')
20
+ return prefetch;
21
+ if (hover)
22
+ return 'hover';
23
+ return hasObserver ? 'viewport' : 'intent';
24
+ }
25
+ export function Link({ children, href, params, prefetch = false, query, ref, scroll, ...rest }) {
17
26
  const { go, prefetch: prefetcher } = useRouter();
27
+ const observer = usePrefetchObserver();
18
28
  const timer = useRef(null);
19
- // track whether the link is meant to be handled by the router, to avoid
20
- // unnecessary prefetching and event handling for external links
29
+ const anchor = useRef(null);
21
30
  const handled = useRef(false);
31
+ const { onClick, onFocus, onTouchStart, onMouseEnter, onMouseLeave, ...anchorProps } = rest;
22
32
  const target = BrowserRouter.toTarget(href, params, query);
23
- // clear any pending hover-prefetch timer on unmount
33
+ const hoverable = typeof window !== 'undefined' &&
34
+ typeof window.matchMedia === 'function' &&
35
+ window.matchMedia('(hover: hover) and (pointer: fine)').matches;
36
+ const observable = typeof IntersectionObserver !== 'undefined';
37
+ const prefetchStrategy = getPrefetchStrategy(prefetch, hoverable, observable);
38
+ useImperativeHandle(ref, () => anchor.current, []);
39
+ function navigate(e) {
40
+ onClick?.(e);
41
+ if (e.defaultPrevented)
42
+ return;
43
+ if (e.button !== 0)
44
+ return;
45
+ if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
46
+ return;
47
+ if (anchorProps.target && anchorProps.target !== '_self')
48
+ return;
49
+ if (anchorProps.download || handled.current)
50
+ return;
51
+ e.preventDefault();
52
+ go(href, { params, query, scroll });
53
+ }
24
54
  useEffect(() => () => {
25
55
  if (timer.current)
26
56
  clearTimeout(timer.current);
@@ -30,54 +60,36 @@ export function Link({ children, href, params, prefetch = 'none', query, scroll,
30
60
  BrowserRouter.isHashOnlyTarget(target) ||
31
61
  BrowserRouter.isExternalTarget(target, window.location.origin);
32
62
  }, [target]);
33
- return (_jsx("a", { ...rest, href: target, onClick: e => {
34
- rest.onClick?.(e);
35
- if (e.defaultPrevented)
36
- return;
37
- // only intercept plain left-click same-origin navigations
38
- if (e.button !== 0)
39
- return;
40
- if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
41
- return;
42
- if (rest.target && rest.target !== '_self')
43
- return;
44
- if (rest.download)
45
- return;
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)
63
+ useEffect(() => {
64
+ const node = anchor.current;
65
+ if (!node || prefetchStrategy !== 'viewport' || !observable || !observer)
66
+ return;
67
+ if (BrowserRouter.isHashOnlyTarget(target) ||
68
+ BrowserRouter.isExternalTarget(target, window.location.origin)) {
69
+ return;
70
+ }
71
+ observer.observe(node, () => guard(target, prefetcher));
72
+ return () => observer.unobserve(node);
73
+ }, [observable, observer, prefetcher, prefetchStrategy, target]);
74
+ return (_jsx("a", { ...anchorProps, ref: anchor, href: target, onClick: navigate, onFocus: e => {
75
+ onFocus?.(e);
76
+ if (prefetchStrategy !== 'intent' || e.defaultPrevented || handled.current)
57
77
  return;
58
78
  guard(target, prefetcher);
59
79
  }, onTouchStart: e => {
60
- rest.onTouchStart?.(e);
61
- if (e.defaultPrevented)
62
- return;
63
- if (prefetch !== 'intent')
64
- return;
65
- if (handled.current)
80
+ onTouchStart?.(e);
81
+ if (prefetchStrategy !== 'intent' || e.defaultPrevented || handled.current)
66
82
  return;
67
83
  guard(target, prefetcher);
68
84
  }, onMouseEnter: e => {
69
- rest.onMouseEnter?.(e);
70
- if (e.defaultPrevented)
71
- return;
72
- if (prefetch !== 'hover')
73
- return;
74
- if (handled.current)
85
+ onMouseEnter?.(e);
86
+ if (prefetchStrategy !== 'hover' || e.defaultPrevented || handled.current)
75
87
  return;
76
88
  timer.current = setTimeout(() => {
77
89
  guard(target, prefetcher);
78
- }, 100);
90
+ }, HOVER_DELAY);
79
91
  }, onMouseLeave: e => {
80
- rest.onMouseLeave?.(e);
92
+ onMouseLeave?.(e);
81
93
  if (timer.current) {
82
94
  clearTimeout(timer.current);
83
95
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",