diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index aba050f38..1d092ed0c 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -334,7 +334,7 @@ Add `prefetch` to a `Link` to load its page before the user clicks, so the visit ``` -`prefetch` on its own loads the page on hover, keyboard focus, or touch. A `Link` can also prefetch on click, as soon as it renders, or when it scrolls into view, and `cacheFor` sets how long a prefetched page is reused. See [Prefetching](../../reference/api/pages.md#prefetching) for every option. +`prefetch` on its own loads the page on hover, keyboard focus, or touch. A `Link` can also prefetch on click, as soon as it renders, or once it stays in view, and `cacheFor` sets how long a prefetched page is reused. See [Prefetching](../../reference/api/pages.md#prefetching) for every option. To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the adapter plugin. A `Link` that sets its own `prefetch` keeps it: @@ -343,7 +343,7 @@ To give every `Link` a prefetch strategy, set `prefetch.defaultStrategy` in the 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. +Prefer `'hover'` or `'click'` as the default. With `'mount'` or `'visible'`, every `Link` loads its page once it renders or stays in view, which adds up in long lists. Prefetching runs the destination's loader, so keep loaders free of side effects such as counting views. diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 1e26ac2ed..ec7a68343 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -29,7 +29,7 @@ type LinkPrefetchStrategy = 'hover' | 'click' | 'mount' | 'visible'; | `'hover'` or `true` | When the pointer rests on the link for `hoverDelay`, when it gets keyboard focus, or on touch | | `'click'` | When the mouse button or finger goes down, just before the click | | `'mount'` | As soon as the link renders | -| `'visible'` | The first time the link scrolls into view | +| `'visible'` | Once the link has stayed in view for 300 ms. Links scrolled quickly past are skipped | | `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. diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts index 7d55394bb..d56ead040 100644 --- a/packages/void/src/pages/link.ts +++ b/packages/void/src/pages/link.ts @@ -233,18 +233,51 @@ export function handleLinkClick( }); } -/** Calls `onVisible` once, the first time `element` enters the viewport. Returns a cleanup. */ +/** + * How long a `'visible'` link must stay in the viewport before it prefetches. Long enough + * that a link scrolled past during a quick scroll or fling is skipped, short enough that + * the prefetch still lands well before the user reads the link and clicks. Astro's + * viewport prefetch uses the same dwell. Separate from `prefetch.hoverDelay`, whose 75 ms + * would only filter the fastest flings. + */ +export const LINK_VISIBLE_PREFETCH_DELAY = 300; + +/** + * Calls `onVisible` once, after `element` has stayed in the viewport for + * `LINK_VISIBLE_PREFETCH_DELAY` ms. Leaving the viewport cancels the wait and entering + * again restarts it. Returns a cleanup that also cancels a pending wait. + */ export function observeLinkVisible(element: Element, onVisible: () => void): () => void { + let timer: ReturnType | undefined; + // Set once the callback has run or the caller cleaned up. Entries queued before + // `disconnect()` can still be delivered, and must not start another wait. + let done = false; + const stop = () => { + done = true; + clearTimeout(timer); + timer = undefined; + observer.disconnect(); + }; + const observer = new IntersectionObserver( (entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - onVisible(); - observer.disconnect(); + if (done) { + return; } + // Entries are in time order, so the last one is the element's current state. + if (!entries.at(-1)?.isIntersecting) { + clearTimeout(timer); + timer = undefined; + return; + } + timer ??= setTimeout(() => { + stop(); + onVisible(); + }, LINK_VISIBLE_PREFETCH_DELAY); }, { rootMargin: '0px' }, ); observer.observe(element); - return () => observer.disconnect(); + return stop; } diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts index 9e2654ed4..0b66067af 100644 --- a/packages/void/test/unit/pages-link.test.ts +++ b/packages/void/test/unit/pages-link.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { createRouterFacade, createSsrRouter, type VoidRouter } from '../../src/pages/client.ts'; import { handleLinkClick, + LINK_VISIBLE_PREFETCH_DELAY, linkActiveAttributes, linkPrefetchStrategies, observeLinkVisible, @@ -307,7 +308,7 @@ describe('handleLinkClick', () => { }); describe('observeLinkVisible', () => { - it('calls back once when the element becomes visible', () => { + function observeStub() { let notify: (entries: Array<{ isIntersecting: boolean }>) => void = () => {}; const disconnect = vi.fn(); const observe = vi.fn(); @@ -324,15 +325,108 @@ describe('observeLinkVisible', () => { const element = {} as Element; const onVisible = vi.fn(); const stop = observeLinkVisible(element, onVisible); + const show = (isIntersecting: boolean) => notify([{ isIntersecting }]); + return { + element, + observe, + disconnect, + onVisible, + stop, + show, + notify: (...entries: Array) => + notify(entries.map((isIntersecting) => ({ isIntersecting }))), + }; + } - notify([{ isIntersecting: false }]); - expect(onVisible).not.toHaveBeenCalled(); - notify([{ isIntersecting: true }]); + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls back once the element has stayed visible for the delay', () => { + const { element, observe, disconnect, onVisible, stop, show } = observeStub(); expect(observe).toHaveBeenCalledWith(element); + show(false); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY); + expect(onVisible).not.toHaveBeenCalled(); + + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1); + expect(onVisible).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onVisible).toHaveBeenCalledOnce(); expect(disconnect).toHaveBeenCalledOnce(); stop(); expect(disconnect).toHaveBeenCalledTimes(2); }); + + it('skips an element that leaves before the delay', () => { + const { onVisible, disconnect, show } = observeStub(); + + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1); + show(false); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY * 2); + + expect(onVisible).not.toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it('restarts the delay when the element becomes visible again', () => { + const { onVisible, show } = observeStub(); + + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1); + show(false); + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY - 1); + expect(onVisible).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + + expect(onVisible).toHaveBeenCalledOnce(); + }); + + it('uses the latest entry when one callback reports several changes', () => { + const { onVisible, notify } = observeStub(); + + notify(false, true, false); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY); + expect(onVisible).not.toHaveBeenCalled(); + + notify(true, false, true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY); + expect(onVisible).toHaveBeenCalledOnce(); + }); + + it('calls back only once', () => { + const { onVisible, show } = observeStub(); + + show(true); + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY); + show(false); + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY * 2); + + expect(onVisible).toHaveBeenCalledOnce(); + }); + + it('cancels a pending callback on cleanup', () => { + const { onVisible, disconnect, stop, show } = observeStub(); + + show(true); + stop(); + expect(vi.getTimerCount()).toBe(0); + // An entry queued before the cleanup can still arrive. + show(true); + vi.advanceTimersByTime(LINK_VISIBLE_PREFETCH_DELAY); + + expect(onVisible).not.toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalledOnce(); + }); });