Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ Add `prefetch` to a `Link` to load its page before the user clicks, so the visit
</Link>
```

`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:

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 38 additions & 5 deletions packages/void/src/pages/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | 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;
}
102 changes: 98 additions & 4 deletions packages/void/test/unit/pages-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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<boolean>) =>
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();
});
});
Loading