From 545ddbe82ef69553833cb7d50e32b118b154f14e Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 7 Jul 2026 09:37:11 +0200 Subject: [PATCH] feat(runtime): add viewport prefetch behavior to PrefetchLink --- .changeset/viewport-prefetch-link.md | 5 + .../src/router/runtime/PrefetchLink.tsx | 118 ++++++++++++++---- .../tests/router/prefetch.test.tsx | 82 ++++++++++++ 3 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 .changeset/viewport-prefetch-link.md diff --git a/.changeset/viewport-prefetch-link.md b/.changeset/viewport-prefetch-link.md new file mode 100644 index 000000000000..88da3facbbf5 --- /dev/null +++ b/.changeset/viewport-prefetch-link.md @@ -0,0 +1,5 @@ +--- +'@modern-js/runtime': patch +--- + +Add `prefetch="viewport"` support to runtime router links. Use it on `Link` or `NavLink` to prefetch matching route data and chunks when the link enters the viewport. diff --git a/packages/runtime/plugin-runtime/src/router/runtime/PrefetchLink.tsx b/packages/runtime/plugin-runtime/src/router/runtime/PrefetchLink.tsx index 1d8a79e540c5..2906ea9c239a 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/PrefetchLink.tsx +++ b/packages/runtime/plugin-runtime/src/router/runtime/PrefetchLink.tsx @@ -15,11 +15,16 @@ import React, { useContext, useMemo } from 'react'; import type { FocusEventHandler, MouseEventHandler, + Ref, TouchEventHandler, } from 'react'; import { InternalRuntimeContext } from '../../core/context'; import type { RouteAssets, RouteManifest } from './types'; +declare const WEBPACK_CHUNK_LOAD: + | ((chunkId: string | number) => Promise) + | undefined; + interface PrefetchHandlers { onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; @@ -53,10 +58,13 @@ function composeEventHandlers( * * - "intent": Fetched when the user focuses or hovers the link * - "render": Fetched when the link is rendered + * - "viewport": Fetched when the link enters the viewport * - "none": Never fetched */ -type PrefetchBehavior = 'intent' | 'render' | 'none'; +type PrefetchBehavior = 'intent' | 'render' | 'viewport' | 'none'; const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; +const INTENT_DELAY = 100; +const VIEWPORT_ROOT_MARGIN = '200px'; export interface LinkProps extends RouterLinkProps { prefetch?: PrefetchBehavior; } @@ -64,6 +72,19 @@ export interface NavLinkProps extends RouterNavLinkProps { prefetch?: PrefetchBehavior; } +const setRef = (ref: Ref | undefined, value: T | null) => { + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(value); + return; + } + + (ref as React.MutableRefObject).current = value; +}; + /** * Modified from https://github.com/remix-run/remix/blob/9a0601bd704d2f3ee622e0ddacab9b611eb0c5bc/packages/remix-react/components.tsx#L236 * @@ -75,9 +96,15 @@ export interface NavLinkProps extends RouterNavLinkProps { function usePrefetchBehavior( prefetch: PrefetchBehavior, theirElementProps: PrefetchHandlers, -): [boolean, Required] { +): [ + boolean, + Required, + (element: HTMLAnchorElement | null) => void, +] { const [maybePrefetch, setMaybePrefetch] = React.useState(false); const [shouldPrefetch, setShouldPrefetch] = React.useState(false); + const [viewportElement, setViewportElement] = + React.useState(null); const { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } = theirElementProps; @@ -103,13 +130,45 @@ function usePrefetchBehavior( React.useEffect(() => { if (maybePrefetch) { const id = setTimeout(() => { - setShouldPrefetch(true); - }, 100); + if (prefetch === 'intent') { + setShouldPrefetch(true); + } + }, INTENT_DELAY); return () => { clearTimeout(id); }; } - }, [maybePrefetch]); + }, [maybePrefetch, prefetch]); + + React.useEffect(() => { + if ( + !viewportElement || + prefetch !== 'viewport' || + typeof IntersectionObserver === 'undefined' + ) { + return; + } + + const observer = new IntersectionObserver( + entries => { + if (!entries.some(entry => entry.isIntersecting)) { + return; + } + + setShouldPrefetch(true); + observer.disconnect(); + }, + { + rootMargin: VIEWPORT_ROOT_MARGIN, + }, + ); + + observer.observe(viewportElement); + + return () => { + observer.disconnect(); + }; + }, [prefetch, viewportElement]); return [ shouldPrefetch, @@ -120,6 +179,7 @@ function usePrefetchBehavior( onMouseLeave: composeEventHandlers(onMouseLeave, cancelIntent), onTouchStart: composeEventHandlers(onTouchStart, setIntent), }, + setViewportElement, ]; } @@ -138,17 +198,16 @@ async function loadRouteModule( const { chunkIds } = routeAssets[routeId]; - if (!chunkIds) { + if ( + !Array.isArray(chunkIds) || + chunkIds.length === 0 || + typeof WEBPACK_CHUNK_LOAD !== 'function' + ) { return; } try { - await Promise.all( - chunkIds.map(chunkId => { - // @ts-ignore - return WEBPACK_CHUNK_LOAD?.(chunkId); - }), - ); + await Promise.all(chunkIds.map(chunkId => WEBPACK_CHUNK_LOAD(chunkId))); } catch (error) { console.error(error); } @@ -186,10 +245,20 @@ const PrefetchPageLinks: React.FC<{ path: Path }> = ({ path }) => { const context = useContext(InternalRuntimeContext); const { routeManifest, routes } = context; const { routeAssets } = routeManifest || {}; - const matches = Array.isArray(routes) ? matchRoutes(routes, pathname) : []; - if (Array.isArray(matches) && routeAssets) { - matches?.forEach(match => loadRouteModule(match.route, routeAssets)); - } + const matches = useMemo( + () => (Array.isArray(routes) ? matchRoutes(routes, pathname) : []), + [pathname, routes], + ); + + React.useEffect(() => { + if (!Array.isArray(matches) || !routeAssets) { + return; + } + + matches.forEach(match => { + void loadRouteModule(match.route, routeAssets); + }); + }, [matches, routeAssets]); if (!window._SSR_DATA) { return null; @@ -275,23 +344,26 @@ const createPrefetchLink = ( return React.forwardRef>( ({ to, prefetch = 'none', ...props }, forwardedRef) => { const isAbsolute = typeof to === 'string' && ABSOLUTE_URL_REGEX.test(to); - const [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior( - prefetch, - props, + const [shouldPrefetch, prefetchHandlers, setViewportElement] = + usePrefetchBehavior(prefetch, props); + const setAnchorRef = React.useCallback( + (element: HTMLAnchorElement | null) => { + setViewportElement(element); + setRef(forwardedRef, element); + }, + [forwardedRef, setViewportElement], ); const resolvedPath = useResolvedPath(to); return ( <> - {shouldPrefetch && // @ts-ignore - WEBPACK_CHUNK_LOAD && - !isAbsolute ? ( + {shouldPrefetch && !isAbsolute ? ( ) : null} diff --git a/packages/runtime/plugin-runtime/tests/router/prefetch.test.tsx b/packages/runtime/plugin-runtime/tests/router/prefetch.test.tsx index 0c85c2fd2dcf..985f42ec1d0d 100644 --- a/packages/runtime/plugin-runtime/tests/router/prefetch.test.tsx +++ b/packages/runtime/plugin-runtime/tests/router/prefetch.test.tsx @@ -148,4 +148,86 @@ describe('prefetch', () => { }); unmount(); }); + + test('support viewport', async () => { + const originalIntersectionObserver = global.IntersectionObserver; + const observeMock = rstest.fn(); + const disconnectMock = rstest.fn(); + let observerCallback: IntersectionObserverCallback | undefined; + rstest.useRealTimers(); + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin = '200px'; + readonly thresholds: ReadonlyArray = []; + + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + + disconnect() { + disconnectMock(); + } + + observe(target: Element) { + observeMock(target); + } + + takeRecords() { + return []; + } + + unobserve() {} + } + + global.IntersectionObserver = MockIntersectionObserver; + + const mockRoutes = [ + { + id: 'root', + path: '/', + element: , + }, + { + id: 'aa', + path: 'aa', + loader: ({ request }: LoaderFunctionArgs) => null, + element:

idk

, + }, + ]; + + let router; + act(() => { + router = createMemoryRouter(mockRoutes); + }); + const { container, unmount } = render( + , + ); + + await waitFor(() => { + expect(observeMock).toHaveBeenCalledWith(container.firstChild); + }); + expect(global.__webpack_chunk_load_test__).not.toHaveBeenCalled(); + + await act(async () => { + observerCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); + + await waitFor(() => { + expect(global.__webpack_chunk_load_test__).toBeCalledTimes(1); + const dataHref = document.head + .querySelector('link[rel="prefetch"][as="fetch"]') + ?.getAttribute('href'); + expect( + dataHref?.includes('aa?__loader=aa&__ssrDirect=true'), + ).toBeTruthy(); + }); + expect(disconnectMock).toHaveBeenCalled(); + + unmount(); + global.IntersectionObserver = originalIntersectionObserver; + }); });