From 138fc260df39ff7c0c94d56ba0e0d2884fca57d8 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 4 Nov 2026 13:31:49 +0800 Subject: [PATCH] feat(pages): skip Link prefetching on Save-Data and slow connections Link's automatic prefetch (every strategy, including the app-wide default) now does nothing while navigator.connection reports saveData or a 2g/slow-2g effectiveType. The connection is read on each prefetch. Browsers without navigator.connection keep prefetching, and calling router.prefetch() directly is unaffected. Co-Authored-By: Claude Opus 5.5 --- docs/guide/pages-routing/overview.md | 2 ++ docs/reference/api/pages.md | 2 +- packages/void/src/pages/link.ts | 16 ++++++++- packages/void/test/unit/pages-link.test.ts | 39 ++++++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 46df69700..6b46bc0e5 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -361,6 +361,8 @@ voidReact({ prefetch: { defaultStrategy: 'hover' } }); Prefer `'hover'` or `'click'` as the default. With `'mount'` or `'visible'`, every `Link` loads its page as soon as it renders or scrolls into view, including links in long lists. +`Link` doesn't prefetch while the browser reports that data saver is on or the connection is 2G, which only Chromium-based browsers report. + Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views. ## Scroll Restoration diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 75d94dd85..ddf07e2e6 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -32,7 +32,7 @@ type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; | `'visible'` | The first time the link scrolls into view | | `false` | Never | -With an array such as `['visible', 'hover']`, the first strategy to happen loads the page, and later ones reuse it. `prefetch` works only on GET links, and links to other sites are never prefetched. +With an array such as `['visible', 'hover']`, the first strategy to happen loads the page, and later ones reuse it. `prefetch` works only on GET links, and links to other sites are never prefetched. No strategy prefetches while the browser reports data saver or a 2G connection, which only Chromium-based browsers report; calling `router.prefetch()` yourself still loads the page. `cacheFor` sets how long a prefetched page is reused, in milliseconds or as a duration such as `'5m'`. A pair such as `['30s', '5m']` treats the page as fresh for 30 seconds, then still shows it for up to 5 minutes while loading a fresh copy in the background. diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts index 7751eddf8..997e0c332 100644 --- a/packages/void/src/pages/link.ts +++ b/packages/void/src/pages/link.ts @@ -211,12 +211,26 @@ function resolveSameOrigin(href: string, base: URL): URL | null { return target; } +/** The Network Information API fields Link reads. Only Chromium implements them. */ +type NetworkConnection = { saveData?: boolean; effectiveType?: string }; + +/** Data saver is on, or the connection is 2G-class. Read per call: both change while the page is open. */ +function isConstrainedConnection(): boolean { + const connection = (navigator as Navigator & { connection?: NetworkConnection }).connection; + return ( + connection?.saveData === true || + connection?.effectiveType === '2g' || + connection?.effectiveType === 'slow-2g' + ); +} + +/** Link's automatic prefetch. `router.prefetch()` called directly is not limited by the connection. */ export function prefetchLink( router: VoidRouter | null | undefined, href: Href, cacheFor: CacheForInput | undefined, ): void { - if (!router?.prefetch) { + if (!router?.prefetch || isConstrainedConnection()) { return; } diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts index 6da804e16..deb0ed984 100644 --- a/packages/void/test/unit/pages-link.test.ts +++ b/packages/void/test/unit/pages-link.test.ts @@ -329,6 +329,45 @@ describe('prefetchLink', () => { expect(router.prefetch.mock.calls).toEqual([['https://app.test/users/42', {}]]); }); + + it('skips while data saver is on or the connection is 2G-class', () => { + const router = createRouter(); + for (const connection of [ + { saveData: true, effectiveType: '4g' }, + { effectiveType: '2g' }, + { effectiveType: 'slow-2g' }, + ]) { + vi.stubGlobal('navigator', { connection }); + prefetchLink(router, '/users/42', undefined); + } + + expect(router.prefetch).not.toHaveBeenCalled(); + }); + + it('prefetches on faster connections and when the browser reports none', () => { + const router = createRouter(); + for (const stub of [ + { connection: { saveData: false, effectiveType: '3g' } }, + { connection: { effectiveType: '4g' } }, + {}, + ]) { + vi.stubGlobal('navigator', stub); + prefetchLink(router, '/users/42', undefined); + } + + expect(router.prefetch).toHaveBeenCalledTimes(3); + }); + + it('reads the connection on each prefetch', () => { + const router = createRouter(); + const connection = { saveData: true }; + vi.stubGlobal('navigator', { connection }); + prefetchLink(router, '/users/42', undefined); + connection.saveData = false; + prefetchLink(router, '/users/43', undefined); + + expect(router.prefetch.mock.calls).toEqual([['/users/43', {}]]); + }); }); describe('handleLinkClick', () => {