Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/viewport-prefetch-link.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 95 additions & 23 deletions packages/runtime/plugin-runtime/src/router/runtime/PrefetchLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>)
| undefined;

interface PrefetchHandlers {
onFocus?: FocusEventHandler<Element>;
onBlur?: FocusEventHandler<Element>;
Expand Down Expand Up @@ -53,17 +58,33 @@ function composeEventHandlers<EventType extends React.SyntheticEvent | Event>(
*
* - "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;
}
export interface NavLinkProps extends RouterNavLinkProps {
prefetch?: PrefetchBehavior;
}

const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (!ref) {
return;
}

if (typeof ref === 'function') {
ref(value);
return;
}

(ref as React.MutableRefObject<T | null>).current = value;
};

/**
* Modified from https://github.com/remix-run/remix/blob/9a0601bd704d2f3ee622e0ddacab9b611eb0c5bc/packages/remix-react/components.tsx#L236
*
Expand All @@ -75,9 +96,15 @@ export interface NavLinkProps extends RouterNavLinkProps {
function usePrefetchBehavior(
prefetch: PrefetchBehavior,
theirElementProps: PrefetchHandlers,
): [boolean, Required<PrefetchHandlers>] {
): [
boolean,
Required<PrefetchHandlers>,
(element: HTMLAnchorElement | null) => void,
] {
const [maybePrefetch, setMaybePrefetch] = React.useState(false);
const [shouldPrefetch, setShouldPrefetch] = React.useState(false);
const [viewportElement, setViewportElement] =
React.useState<HTMLAnchorElement | null>(null);
const { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } =
theirElementProps;

Expand All @@ -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,
Expand All @@ -120,6 +179,7 @@ function usePrefetchBehavior(
onMouseLeave: composeEventHandlers(onMouseLeave, cancelIntent),
onTouchStart: composeEventHandlers(onTouchStart, setIntent),
},
setViewportElement,
];
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -275,23 +344,26 @@ const createPrefetchLink = <T extends typeof RouterLink | typeof RouterNavLink>(
return React.forwardRef<HTMLAnchorElement, InputLinkProps<T>>(
({ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid viewport state updates for non-viewport links

Because setAnchorRef is installed on every Link/NavLink, this state update runs on mount even for the default prefetch="none" and for intent/render links where viewportElement is never used. Each link schedules an extra render just to store its DOM node, so pages with large navs or link lists pay this overhead without enabling viewport prefetch; only update this state when prefetch === 'viewport'.

Useful? React with 馃憤聽/ 馃憥.

setRef(forwardedRef, element);
Comment thread
zllkjc marked this conversation as resolved.
},
[forwardedRef, setViewportElement],
);

const resolvedPath = useResolvedPath(to);
return (
<>
<Link
ref={forwardedRef}
ref={setAnchorRef}
to={to}
{...(props as any)}
{...prefetchHandlers}
/>
{shouldPrefetch && // @ts-ignore
WEBPACK_CHUNK_LOAD &&
!isAbsolute ? (
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks path={resolvedPath} />
) : null}
</>
Expand Down
82 changes: 82 additions & 0 deletions packages/runtime/plugin-runtime/tests/router/prefetch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = [];

constructor(callback: IntersectionObserverCallback) {
observerCallback = callback;
}

disconnect() {
disconnectMock();
}

observe(target: Element) {
observeMock(target);
}

takeRecords() {
return [];
}

unobserve() {}
}

global.IntersectionObserver = MockIntersectionObserver;

const mockRoutes = [
{
id: 'root',
path: '/',
element: <Link {...{ to: 'aa', prefetch: 'viewport' }} />,
},
{
id: 'aa',
path: 'aa',
loader: ({ request }: LoaderFunctionArgs) => null,
element: <h1>idk</h1>,
},
];

let router;
act(() => {
router = createMemoryRouter(mockRoutes);
});
const { container, unmount } = render(
<RouterProvider router={router as any} />,
);

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;
});
});