Skip to content

Add prefetching support to Link/NavLink #11402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 3, 2024
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
5 changes: 5 additions & 0 deletions .changeset/link-prefetching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-dom": minor
---

Add prefetching support to `Link`/`NavLink` when using Remix SSR
4 changes: 3 additions & 1 deletion packages/react-router-dom/__tests__/ssr/components-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import * as React from "react";

import {
createMemoryRouter,
Link,
NavLink,
Outlet,
RouterProvider,
_setSsrInfoForTests,
} from "../../index";
import type { LiveReload as ActualLiveReload } from "../../ssr/components";
import { Link, NavLink, RemixContext } from "../../ssr/components";
import { RemixContext } from "../../ssr/components";
import invariant from "../../ssr/invariant";
import { RemixServer } from "../../ssr/server";
import "@testing-library/jest-dom/extend-expect";
Expand Down
34 changes: 28 additions & 6 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ import {
shouldProcessLinkClick,
} from "./dom";

import type { ScriptProps, UIMatch } from "./ssr/components";
import { RemixContext } from "./ssr/components";
import type { PrefetchBehavior, ScriptProps, UIMatch } from "./ssr/components";
import {
PrefetchPageLinks,
RemixContext,
mergeRefs,
usePrefetchBehavior,
} from "./ssr/components";
import type {
AssetsManifest,
FutureConfig as RemixFutureConfig,
Expand Down Expand Up @@ -1401,6 +1406,7 @@ export { HistoryRouter as unstable_HistoryRouter };

export interface LinkProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
prefetch?: PrefetchBehavior;
reloadDocument?: boolean;
replace?: boolean;
state?: any;
Expand All @@ -1424,6 +1430,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
function LinkWithRef(
{
onClick,
prefetch = "none",
relative,
reloadDocument,
replace,
Expand All @@ -1434,15 +1441,16 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
unstable_viewTransition,
...rest
},
ref
forwardedRef
) {
let { basename } = React.useContext(NavigationContext);
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);

// Rendered into <a href> for absolute URLs
let absoluteHref;
let isExternal = false;

if (typeof to === "string" && ABSOLUTE_URL_REGEX.test(to)) {
if (typeof to === "string" && isAbsolute) {
// Render the absolute href server- and client-side
absoluteHref = to;

Expand Down Expand Up @@ -1474,6 +1482,10 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(

// Rendered into <a href> for relative URLs
let href = useHref(to, { relative });
let [shouldPrefetch, prefetchRef, prefetchHandlers] = usePrefetchBehavior(
prefetch,
rest
);

let internalOnClick = useLinkClickHandler(to, {
replace,
Expand All @@ -1492,16 +1504,26 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
}
}

return (
let link = (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
{...rest}
{...prefetchHandlers}
href={absoluteHref || href}
onClick={isExternal || reloadDocument ? onClick : handleClick}
ref={ref}
ref={mergeRefs(forwardedRef, prefetchRef)}
target={target}
/>
);

return shouldPrefetch && !isAbsolute ? (
<>
{link}
<PrefetchPageLinks page={href} />
</>
) : (
link
);
}
);

Expand Down
127 changes: 26 additions & 101 deletions packages/react-router-dom/ssr/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,10 @@ import {
useRouteLoaderData as useRouteLoaderDataRR,
useLocation,
useNavigation,
useHref,
} from "react-router";

import type { FetcherWithComponents, LinkProps, NavLinkProps } from "../index";
import {
Link as RouterLink,
NavLink as RouterNavLink,
useFetcher as useFetcherRR,
} from "../index";
import type { FetcherWithComponents } from "../index";
import { useFetcher as useFetcherRR } from "../index";
import type { AppData } from "./data";
import type { RemixContextObject } from "./entry";
import invariant from "./invariant";
Expand Down Expand Up @@ -100,15 +95,7 @@ export function useRemixContext(): RemixContextObject {
* - "render": Fetched when the link is rendered
* - "viewport": Fetched when the link is in the viewport
*/
type PrefetchBehavior = "intent" | "render" | "none" | "viewport";

export interface RemixLinkProps extends LinkProps {
prefetch?: PrefetchBehavior;
}

export interface RemixNavLinkProps extends NavLinkProps {
prefetch?: PrefetchBehavior;
}
export type PrefetchBehavior = "intent" | "render" | "none" | "viewport";

interface PrefetchHandlers {
onFocus?: FocusEventHandler;
Expand All @@ -118,10 +105,11 @@ interface PrefetchHandlers {
onTouchStart?: TouchEventHandler;
}

function usePrefetchBehavior<T extends HTMLAnchorElement>(
export function usePrefetchBehavior<T extends HTMLAnchorElement>(
prefetch: PrefetchBehavior,
theirElementProps: PrefetchHandlers
): [boolean, React.RefObject<T>, Required<PrefetchHandlers>] {
): [boolean, React.RefObject<T>, PrefetchHandlers] {
let remixContext = React.useContext(RemixContext);
let [maybePrefetch, setMaybePrefetch] = React.useState(false);
let [shouldPrefetch, setShouldPrefetch] = React.useState(false);
let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } =
Expand Down Expand Up @@ -149,19 +137,6 @@ function usePrefetchBehavior<T extends HTMLAnchorElement>(
}
}, [prefetch]);

let setIntent = () => {
if (prefetch === "intent") {
setMaybePrefetch(true);
}
};

let cancelIntent = () => {
if (prefetch === "intent") {
setMaybePrefetch(false);
setShouldPrefetch(false);
}
};

React.useEffect(() => {
if (maybePrefetch) {
let id = setTimeout(() => {
Expand All @@ -173,6 +148,25 @@ function usePrefetchBehavior<T extends HTMLAnchorElement>(
}
}, [maybePrefetch]);

let setIntent = () => {
setMaybePrefetch(true);
};

let cancelIntent = () => {
setMaybePrefetch(false);
setShouldPrefetch(false);
};

// No prefetching if not using Remix-style SSR
if (!remixContext) {
return [false, ref, {}];
}

if (prefetch !== "intent") {
return [shouldPrefetch, ref, {}];
}

// When using prefetch="intent" we need to attach focus/hover listeners
return [
shouldPrefetch,
ref,
Expand All @@ -186,75 +180,6 @@ function usePrefetchBehavior<T extends HTMLAnchorElement>(
];
}

const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;

/**
* A special kind of `<Link>` that knows whether it is "active".
*
* @see https://remix.run/components/nav-link
*/
let NavLink = React.forwardRef<HTMLAnchorElement, RemixNavLinkProps>(
({ to, prefetch = "none", ...props }, forwardedRef) => {
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);

let href = useHref(to);
let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior(
prefetch,
props
);

return (
<>
<RouterNavLink
{...props}
{...prefetchHandlers}
ref={mergeRefs(forwardedRef, ref)}
to={to}
/>
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks page={href} />
) : null}
</>
);
}
);
NavLink.displayName = "NavLink";
export { NavLink };

/**
* This component renders an anchor tag and is the primary way the user will
* navigate around your website.
*
* @see https://remix.run/components/link
*/
let Link = React.forwardRef<HTMLAnchorElement, RemixLinkProps>(
({ to, prefetch = "none", ...props }, forwardedRef) => {
let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to);

let href = useHref(to);
let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior(
prefetch,
props
);

return (
<>
<RouterLink
{...props}
{...prefetchHandlers}
ref={mergeRefs(forwardedRef, ref)}
to={to}
/>
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks page={href} />
) : null}
</>
);
}
);
Link.displayName = "Link";
export { Link };

export function composeEventHandlers<
EventType extends React.SyntheticEvent | Event
>(
Expand Down Expand Up @@ -1257,7 +1182,7 @@ export const LiveReload =
);
};

function mergeRefs<T = any>(
export function mergeRefs<T = any>(
...refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>
): React.RefCallback<T> {
return (value) => {
Expand Down