diff --git a/.changeset/prefetch-viewport.md b/.changeset/prefetch-viewport.md new file mode 100644 index 00000000000..139e10676d2 --- /dev/null +++ b/.changeset/prefetch-viewport.md @@ -0,0 +1,6 @@ +--- +"remix": minor +"@remix-run/react": minor +--- + +Add support for `` to prefetch links when they enter the viewport via an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) diff --git a/docs/components/link.md b/docs/components/link.md index dbc52b29f68..373f6d00489 100644 --- a/docs/components/link.md +++ b/docs/components/link.md @@ -30,12 +30,14 @@ In the effort to remove all loading states from your UI, `Link` can automaticall + ``` - **"none"** - Default behavior. This will prevent any prefetching from happening. This is recommended when linking to pages that require a user session that the browser won't be able to prefetch anyway. - **"intent"** - Recommended if you want to prefetch. Fetches when Remix thinks the user intends to visit the link. Right now the behavior is simple: if they hover or focus the link it will prefetch the resources. In the future we hope to make this even smarter. Links with large click areas/padding get a bit of a head start. It is worth noting that when using `prefetch="intent"`, `` elements will be inserted on hover/focus and removed if the `` loses hover/focus. Without proper `cache-control` headers on your loaders, this could result in repeated prefetch loads if a user continually hovers on and off a link. - **"render"** - Fetches when the link is rendered. +- **"viewport"** - Fetches while the link is in the viewport You may need to use the :last-of-type selector instead of :last-child when styling child elements inside of your links diff --git a/docs/components/nav-link.md b/docs/components/nav-link.md index 24fa75e3d8a..a9cad56540f 100644 --- a/docs/components/nav-link.md +++ b/docs/components/nav-link.md @@ -5,7 +5,7 @@ toc: false # `` -A `` is a special kind of `` that knows whether or not it is "active" or "pending". This is useful when building a navigation menu, such as a breadcrumb or a set of tabs where you'd like to show which of them is currently selected. It also provides useful context for assistive technology like screen readers. +A `` is a special kind of [``][link] that knows whether or not it is "active" or "pending". This is useful when building a navigation menu, such as a breadcrumb or a set of tabs where you'd like to show which of them is currently selected. It also provides useful context for assistive technology like screen readers. ```tsx import { NavLink } from "@remix-run/react"; @@ -122,3 +122,4 @@ Adding the `caseSensitive` prop changes the matching logic to make it case sensi When a `NavLink` is active it will automatically apply `` to the underlying anchor tag. See [aria-current][aria-current] on MDN. [aria-current]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current +[link]: ./link.md diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts index b005c46a774..c07b00abc6e 100644 --- a/integration/prefetch-test.ts +++ b/integration/prefetch-test.ts @@ -271,3 +271,74 @@ test.describe("prefetch=intent (focus)", () => { expect(await page.locator("#nav link").count()).toBe(1); }); }); + +test.describe("prefetch=viewport", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + config: { + future: { v2_routeConvention: true }, + }, + files: { + "app/routes/_index.jsx": js` + import { Link } from "@remix-run/react"; + + export default function Component() { + return ( + <> +

Index Page - Scroll Down

+
+ Click me! +
+ + ); + } + `, + + "app/routes/test.jsx": js` + export function loader() { + return null; + } + export default function Component() { + return

Test Page

; + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should prefetch when the link enters the viewport", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // No preloads to start + await expect(page.locator("div link")).toHaveCount(0); + + // Preloads render on scroll down + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await page.waitForSelector( + "div link[rel='prefetch'][as='fetch'][href='/test?_data=routes%2Ftest']", + { state: "attached" } + ); + await page.waitForSelector( + "div link[rel='modulepreload'][href^='/build/routes/test-']", + { state: "attached" } + ); + + // Preloads removed on scroll up + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator("div link")).toHaveCount(0); + }); +}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 17fbe9313ca..77ba692eb7a 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -201,7 +201,7 @@ export function RemixRouteError({ id }: { id: string }) { * - "render": Fetched when the link is rendered * - "none": Never fetched */ -type PrefetchBehavior = "intent" | "render" | "none"; +type PrefetchBehavior = "intent" | "render" | "none" | "viewport"; export interface RemixLinkProps extends LinkProps { prefetch?: PrefetchBehavior; @@ -219,19 +219,35 @@ interface PrefetchHandlers { onTouchStart?: TouchEventHandler; } -function usePrefetchBehavior( +function usePrefetchBehavior( prefetch: PrefetchBehavior, theirElementProps: PrefetchHandlers -): [boolean, Required] { +): [boolean, React.RefObject, Required] { let [maybePrefetch, setMaybePrefetch] = React.useState(false); let [shouldPrefetch, setShouldPrefetch] = React.useState(false); let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } = theirElementProps; + let ref = React.useRef(null); + React.useEffect(() => { if (prefetch === "render") { setShouldPrefetch(true); } + + if (prefetch === "viewport") { + let callback: IntersectionObserverCallback = (entries) => { + entries.forEach((entry) => { + setShouldPrefetch(entry.isIntersecting); + }); + }; + let observer = new IntersectionObserver(callback, { threshold: 0.5 }); + if (ref.current) observer.observe(ref.current); + + return () => { + observer.disconnect(); + }; + } }, [prefetch]); let setIntent = () => { @@ -260,6 +276,7 @@ function usePrefetchBehavior( return [ shouldPrefetch, + ref, { onFocus: composeEventHandlers(onFocus, setIntent), onBlur: composeEventHandlers(onBlur, cancelIntent), @@ -282,17 +299,18 @@ let NavLink = React.forwardRef( let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to); let href = useHref(to); - let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior( + let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior( prefetch, props ); + return ( <> {shouldPrefetch && !isAbsolute ? ( @@ -315,7 +333,7 @@ let Link = React.forwardRef( let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to); let href = useHref(to); - let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior( + let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior( prefetch, props ); @@ -323,10 +341,10 @@ let Link = React.forwardRef( return ( <> {shouldPrefetch && !isAbsolute ? ( @@ -1805,3 +1823,17 @@ export const LiveReload = /> ); }; + +function mergeRefs( + ...refs: Array | React.LegacyRef> +): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject).current = value; + } + }); + }; +}