@jk2908/solas 0.16.2 → 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 +30 -0
- package/README.md +3 -1
- package/dist/index.js +7 -1
- package/dist/internal/browser-router/link.d.ts +3 -2
- package/dist/internal/browser-router/link.js +55 -43
- 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/dist/internal/env/browser.js +11 -4
- package/dist/internal/http-router/router.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
21
|
+
## 0.16.3
|
|
22
|
+
|
|
23
|
+
### Fixes
|
|
24
|
+
|
|
25
|
+
- Server actions invoked from the browser now request the RSC representation (`accept: text/x-component`). The server previously answered with an HTML document, which surfaced as React's "Connection closed" error (#412) and left the UI un-updated even though the action ran.
|
|
26
|
+
- Static responses carry `Content-Length`, so prerendered HTML, CSS and client assets are no longer sent chunked.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The client hydrates inside `startTransition`, so the browser can paint the server markup before React walks the tree.
|
|
31
|
+
- Production builds pin `process.env.NODE_ENV` to the build mode and minify server bundles. This removes the development React branches and reduces the deployable output size.
|
|
32
|
+
|
|
3
33
|
## 0.16.2
|
|
4
34
|
|
|
5
35
|
### Changed
|
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
|
|
package/dist/index.js
CHANGED
|
@@ -232,7 +232,7 @@ function solas(c) {
|
|
|
232
232
|
}
|
|
233
233
|
const plugin = {
|
|
234
234
|
name: Config.NAME,
|
|
235
|
-
async config(viteConfig) {
|
|
235
|
+
async config(viteConfig, env) {
|
|
236
236
|
const pkg = JSON.parse(fsSync.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
237
237
|
if (typeof pkg.name !== 'string' || pkg.name.length === 0) {
|
|
238
238
|
throw new Error(`Missing ${Config.NAME} package name`);
|
|
@@ -276,6 +276,12 @@ function solas(c) {
|
|
|
276
276
|
viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(resolvedUrl);
|
|
277
277
|
viteConfig.define['import.meta.env.SOLAS_VERSION'] = JSON.stringify(pkg.version);
|
|
278
278
|
viteConfig.define['import.meta.env.SOLAS_ADAPTER'] = JSON.stringify(config.adapter?.name ?? '');
|
|
279
|
+
// production builds can drop the development React branches and ship
|
|
280
|
+
// minified server bundles; dev keeps readable output and HMR
|
|
281
|
+
if (env?.command === 'build') {
|
|
282
|
+
viteConfig.define['process.env.NODE_ENV'] = JSON.stringify(env.mode);
|
|
283
|
+
viteConfig.build.minify ??= true;
|
|
284
|
+
}
|
|
279
285
|
viteConfig.optimizeDeps ??= {};
|
|
280
286
|
viteConfig.optimizeDeps.exclude = [
|
|
281
287
|
...new Set([
|
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
90
|
+
}, HOVER_DELAY);
|
|
79
91
|
}, onMouseLeave: e => {
|
|
80
|
-
|
|
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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { StrictMode, Suspense, useCallback, useState, useTransition } from 'react';
|
|
2
|
+
import { StrictMode, Suspense, startTransition, useCallback, useState, useTransition, } from 'react';
|
|
3
3
|
import { hydrateRoot } from 'react-dom/client';
|
|
4
4
|
import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from '@vitejs/plugin-rsc/browser';
|
|
5
5
|
import { getDocumentNonce } from '../../utils/get-document-nonce.js';
|
|
@@ -35,7 +35,10 @@ export async function browser() {
|
|
|
35
35
|
setServerCallback(async (id, args) => {
|
|
36
36
|
const url = new URL(window.location.href);
|
|
37
37
|
const temporaryReferences = createTemporaryReferenceSet();
|
|
38
|
-
const headers = new Headers({
|
|
38
|
+
const headers = new Headers({
|
|
39
|
+
accept: 'text/x-component',
|
|
40
|
+
'x-rsc-action-id': id,
|
|
41
|
+
});
|
|
39
42
|
const nonce = getDocumentNonce();
|
|
40
43
|
if (nonce)
|
|
41
44
|
headers.set('x-solas-nonce', nonce);
|
|
@@ -50,8 +53,12 @@ export async function browser() {
|
|
|
50
53
|
throw data;
|
|
51
54
|
return data;
|
|
52
55
|
});
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
// hydrate as a transition so the browser can paint the server markup
|
|
57
|
+
// before React starts walking the tree
|
|
58
|
+
startTransition(() => {
|
|
59
|
+
hydrateRoot(document, _jsx(StrictMode, { children: _jsx(A, {}) }), {
|
|
60
|
+
formState: payload.formState,
|
|
61
|
+
});
|
|
55
62
|
});
|
|
56
63
|
if (import.meta.hot) {
|
|
57
64
|
import.meta.hot.on?.('rsc:update', async () => {
|
|
@@ -288,9 +288,13 @@ export class HttpRouter {
|
|
|
288
288
|
}
|
|
289
289
|
// get mime type from original path, not compressed variant
|
|
290
290
|
const mimeType = assets.mimeType(asset.path);
|
|
291
|
-
const
|
|
291
|
+
const buffer = await assets.readBuffer(resolved);
|
|
292
|
+
const res = new Response(buffer, {
|
|
292
293
|
headers: {
|
|
293
294
|
'Content-Type': headers['Content-Type'] ?? mimeType,
|
|
295
|
+
// a static file has a known size; sending it lets the client finish
|
|
296
|
+
// the response without waiting for a chunked terminator
|
|
297
|
+
'Content-Length': String(buffer.byteLength),
|
|
294
298
|
},
|
|
295
299
|
});
|
|
296
300
|
for (const [key, value] of Object.entries(headers)) {
|
package/package.json
CHANGED