@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 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="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
 
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?: '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
  }
@@ -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({ 'x-rsc-action-id': id });
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
- hydrateRoot(document, _jsx(StrictMode, { children: _jsx(A, {}) }), {
54
- formState: payload.formState,
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 res = new Response(await assets.readBuffer(resolved), {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.16.2",
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",