Skip to content
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

feat: add prefetch="viewport" support to <Link> #6433

Merged
merged 10 commits into from
Jun 20, 2023
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);
});
};
Comment on lines +239 to +243
Copy link
Member

Choose a reason for hiding this comment

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

Should the observer be disconnected after prefetch is set? That way the IntersectionObserver doesn't need to keep observing if ref.current is in the viewport

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

mostly depends on if we should clean up link preloads when the link leaves the viewport. another thing would be that if it re-enters the vp, should it refetch the loader?

Copy link
Contributor

Choose a reason for hiding this comment

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

Removing/re-adding link tags if an element scrolled out of and back into the viewport would be consistent with our prefetch="intent" behavior

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)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any internal react overhead in attaching a ref? This feels potentially troublesome since we now attach a ref to every Link, even if the user didn't forward a ref and if they're not using prefetch="viewport". Does a page with hundreds of links get immediately "slower" with no way for the user to opt out of the refs?

I took a stab at an opt-in ref approach in 44f28f3

Copy link
Contributor

Choose a reason for hiding this comment

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

There's no perf hit per @jacob-ebey so going to back out that commit

to={to}
/>
{shouldPrefetch && !isAbsolute ? (
<PrefetchPageLinks page={href} />
Expand Down Expand Up @@ -1805,3 +1823,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;
}
});
};
}