Skip to content

Commit

Permalink
feat: add prefetch="viewport" support to <Link> (#6433)
Browse files Browse the repository at this point in the history
Signed-off-by: Logan McAnsh <logan@mcan.sh>
Co-authored-by: Matt Brophy <matt@brophy.org>
  • Loading branch information
mcansh and brophdawg11 authored Jun 20, 2023
1 parent aa896a4 commit de88b54
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 10 deletions.
6 changes: 6 additions & 0 deletions .changeset/prefetch-viewport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"remix": minor
"@remix-run/react": minor
---

Add support for `<Link prefetch="viewport">` to prefetch links when they enter the viewport via an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver)
2 changes: 2 additions & 0 deletions docs/components/link.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ In the effort to remove all loading states from your UI, `Link` can automaticall
<Link prefetch="none" />
<Link prefetch="intent" />
<Link prefetch="render" />
<Link prefetch="viewport" />
</>
```

- **"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"`, `<link rel="prefetch">` elements will be inserted on hover/focus and removed if the `<Link>` 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

<docs-error>You may need to use the <code>:last-of-type</code> selector instead of <code>:last-child</code> when styling child elements inside of your links</docs-error>

Expand Down
3 changes: 2 additions & 1 deletion docs/components/nav-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ toc: false

# `<NavLink>`

A `<NavLink>` 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.
A `<NavLink>` is a special kind of [`<Link>`][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";
Expand Down Expand Up @@ -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 `<a aria-current="page">` 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
71 changes: 71 additions & 0 deletions integration/prefetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<h1>Index Page - Scroll Down</h1>
<div style={{ marginTop: "150vh" }}>
<Link to="/test" prefetch="viewport">Click me!</Link>
</div>
</>
);
}
`,

"app/routes/test.jsx": js`
export function loader() {
return null;
}
export default function Component() {
return <h1>Test Page</h1>;
}
`,
},
});

// 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);
});
});
50 changes: 41 additions & 9 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -219,19 +219,35 @@ interface PrefetchHandlers {
onTouchStart?: TouchEventHandler;
}

function usePrefetchBehavior(
function usePrefetchBehavior<T extends HTMLAnchorElement>(
prefetch: PrefetchBehavior,
theirElementProps: PrefetchHandlers
): [boolean, Required<PrefetchHandlers>] {
): [boolean, React.RefObject<T>, Required<PrefetchHandlers>] {
let [maybePrefetch, setMaybePrefetch] = React.useState(false);
let [shouldPrefetch, setShouldPrefetch] = React.useState(false);
let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } =
theirElementProps;

let ref = React.useRef<T>(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 = () => {
Expand Down Expand Up @@ -260,6 +276,7 @@ function usePrefetchBehavior(

return [
shouldPrefetch,
ref,
{
onFocus: composeEventHandlers(onFocus, setIntent),
onBlur: composeEventHandlers(onBlur, cancelIntent),
Expand All @@ -282,17 +299,18 @@ let NavLink = React.forwardRef<HTMLAnchorElement, RemixNavLinkProps>(
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 (
<>
<RouterNavLink
ref={forwardedRef}
to={to}
{...props}
{...prefetchHandlers}
ref={mergeRefs(forwardedRef, ref)}
to={to}
/>
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks page={href} />
Expand All @@ -315,18 +333,18 @@ let Link = React.forwardRef<HTMLAnchorElement, RemixLinkProps>(
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 (
<>
<RouterLink
ref={forwardedRef}
to={to}
{...props}
{...prefetchHandlers}
ref={mergeRefs(forwardedRef, ref)}
to={to}
/>
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks page={href} />
Expand Down Expand Up @@ -1820,3 +1838,17 @@ export const LiveReload =
/>
);
};

function mergeRefs<T = any>(
...refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>
): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref != null) {
(ref as React.MutableRefObject<T | null>).current = value;
}
});
};
}

0 comments on commit de88b54

Please sign in to comment.