From e24f65dfbdfed4ba44afef01f7f3d6fed1ebe18b Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Fri, 19 May 2023 15:00:09 -0400 Subject: [PATCH 01/10] feat: initial viewport prefetching Signed-off-by: Logan McAnsh --- packages/remix-react/components.tsx | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 17fbe9313ca..0fd3381bab2 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; @@ -314,21 +314,48 @@ let Link = React.forwardRef( ({ to, prefetch = "none", ...props }, forwardedRef) => { let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to); + let fallbackRef = React.useRef(null); + let href = useHref(to); let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior( prefetch, props ); + let [shouldActuallyPrefetch, setShouldActuallyPrefetch] = + React.useState(shouldPrefetch); + + React.useEffect(() => { + if (prefetch === "viewport") { + let callback: IntersectionObserverCallback = (entries, observer) => { + console.log("entries", entries); + entries.forEach((entry) => { + if (entry.isIntersecting) { + setShouldActuallyPrefetch(true); + } + }); + }; + let observer = new IntersectionObserver(callback, { threshold: 0.5 }); + observer.observe(fallbackRef.current!); + + return () => { + observer.disconnect(); + }; + } + }, [forwardedRef, prefetch]); + return ( <> { + forwardedRef = el; + fallbackRef.current = el; + }} to={to} {...props} {...prefetchHandlers} /> - {shouldPrefetch && !isAbsolute ? ( + {shouldActuallyPrefetch && !isAbsolute ? ( ) : null} From 331e8b32bf6d2fefc6e7efbe944ee5f65fc7236c Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Fri, 19 May 2023 17:55:28 -0400 Subject: [PATCH 02/10] feat: viewport prefetching Signed-off-by: Logan McAnsh --- packages/remix-react/components.tsx | 79 ++++++++++++++++------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 0fd3381bab2..801632207f1 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -219,19 +219,37 @@ 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) => { + console.log("entries", entries); + entries.forEach((entry) => { + if (entry.isIntersecting) setShouldPrefetch(true); + }); + }; + let observer = new IntersectionObserver(callback, { threshold: 0.5 }); + if (ref.current) observer.observe(ref.current); + else console.warn("No element to observe"); + + return () => { + observer.disconnect(); + }; + } }, [prefetch]); let setIntent = () => { @@ -260,6 +278,7 @@ function usePrefetchBehavior( return [ shouldPrefetch, + ref, { onFocus: composeEventHandlers(onFocus, setIntent), onBlur: composeEventHandlers(onBlur, cancelIntent), @@ -282,17 +301,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 ? ( @@ -314,48 +334,23 @@ let Link = React.forwardRef( ({ to, prefetch = "none", ...props }, forwardedRef) => { let isAbsolute = typeof to === "string" && ABSOLUTE_URL_REGEX.test(to); - let fallbackRef = React.useRef(null); - let href = useHref(to); - let [shouldPrefetch, prefetchHandlers] = usePrefetchBehavior( + let [shouldPrefetch, ref, prefetchHandlers] = usePrefetchBehavior( prefetch, props ); - let [shouldActuallyPrefetch, setShouldActuallyPrefetch] = - React.useState(shouldPrefetch); - - React.useEffect(() => { - if (prefetch === "viewport") { - let callback: IntersectionObserverCallback = (entries, observer) => { - console.log("entries", entries); - entries.forEach((entry) => { - if (entry.isIntersecting) { - setShouldActuallyPrefetch(true); - } - }); - }; - let observer = new IntersectionObserver(callback, { threshold: 0.5 }); - observer.observe(fallbackRef.current!); - - return () => { - observer.disconnect(); - }; - } - }, [forwardedRef, prefetch]); + console.log({ ref: ref.current, forwardedRef }); return ( <> { - forwardedRef = el; - fallbackRef.current = el; - }} - to={to} {...props} {...prefetchHandlers} + ref={mergeRefs(forwardedRef, ref)} + to={to} /> - {shouldActuallyPrefetch && !isAbsolute ? ( + {shouldPrefetch && !isAbsolute ? ( ) : null} @@ -1832,3 +1827,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; + } + }); + }; +} From 4520f0fd19e8e0eb00589fa70f85f7cb62a6a898 Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Fri, 19 May 2023 17:56:34 -0400 Subject: [PATCH 03/10] chore: remove debug console Signed-off-by: Logan McAnsh --- packages/remix-react/components.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 801632207f1..9ff0542310e 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -244,7 +244,6 @@ function usePrefetchBehavior( }; let observer = new IntersectionObserver(callback, { threshold: 0.5 }); if (ref.current) observer.observe(ref.current); - else console.warn("No element to observe"); return () => { observer.disconnect(); @@ -340,8 +339,6 @@ let Link = React.forwardRef( props ); - console.log({ ref: ref.current, forwardedRef }); - return ( <> Date: Fri, 19 May 2023 18:00:54 -0400 Subject: [PATCH 04/10] Create tidy-bears-turn.md --- .changeset/tidy-bears-turn.md | 6 ++++++ packages/remix-react/components.tsx | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-bears-turn.md diff --git a/.changeset/tidy-bears-turn.md b/.changeset/tidy-bears-turn.md new file mode 100644 index 00000000000..12e9b949c2c --- /dev/null +++ b/.changeset/tidy-bears-turn.md @@ -0,0 +1,6 @@ +--- +"remix": patch +"@remix-run/react": patch +--- + +adds support for viewport prefetching to ``. this allows you to prefetch a page when it gets into view via an [intersection observer](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 9ff0542310e..b8918f715ae 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -237,7 +237,6 @@ function usePrefetchBehavior( if (prefetch === "viewport") { let callback: IntersectionObserverCallback = (entries) => { - console.log("entries", entries); entries.forEach((entry) => { if (entry.isIntersecting) setShouldPrefetch(true); }); From ee74d1b44ccc5e9356030365e7f9b157e0593ebe Mon Sep 17 00:00:00 2001 From: Logan McAnsh Date: Fri, 19 May 2023 18:14:15 -0400 Subject: [PATCH 05/10] chore: remove prefetch links when scrolled out of vp Signed-off-by: Logan McAnsh --- packages/remix-react/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index b8918f715ae..77ba692eb7a 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -238,7 +238,7 @@ function usePrefetchBehavior( if (prefetch === "viewport") { let callback: IntersectionObserverCallback = (entries) => { entries.forEach((entry) => { - if (entry.isIntersecting) setShouldPrefetch(true); + setShouldPrefetch(entry.isIntersecting); }); }; let observer = new IntersectionObserver(callback, { threshold: 0.5 }); From fab2ccea1e6c0a8b79ce29548d78541a4802a6cf Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Jun 2023 13:52:04 -0400 Subject: [PATCH 06/10] Update changeset --- .changeset/prefetch-viewport.md | 6 ++++++ .changeset/tidy-bears-turn.md | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 .changeset/prefetch-viewport.md delete mode 100644 .changeset/tidy-bears-turn.md 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/.changeset/tidy-bears-turn.md b/.changeset/tidy-bears-turn.md deleted file mode 100644 index 12e9b949c2c..00000000000 --- a/.changeset/tidy-bears-turn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"remix": patch -"@remix-run/react": patch ---- - -adds support for viewport prefetching to ``. this allows you to prefetch a page when it gets into view via an [intersection observer](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) From 44f28f3a2e514e114a818e64c3ffdbca6d5be8f0 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Jun 2023 14:09:01 -0400 Subject: [PATCH 07/10] Only attach ref when prefetch=viewport is specified --- packages/remix-react/components.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 77ba692eb7a..7b8cb8161c6 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -222,7 +222,7 @@ interface PrefetchHandlers { function usePrefetchBehavior( prefetch: PrefetchBehavior, theirElementProps: PrefetchHandlers -): [boolean, React.RefObject, Required] { +): [boolean, React.RefObject | null, Required] { let [maybePrefetch, setMaybePrefetch] = React.useState(false); let [shouldPrefetch, setShouldPrefetch] = React.useState(false); let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } = @@ -276,7 +276,7 @@ function usePrefetchBehavior( return [ shouldPrefetch, - ref, + prefetch === "viewport" ? ref : null, { onFocus: composeEventHandlers(onFocus, setIntent), onBlur: composeEventHandlers(onBlur, cancelIntent), @@ -309,7 +309,7 @@ let NavLink = React.forwardRef( {shouldPrefetch && !isAbsolute ? ( @@ -343,7 +343,7 @@ let Link = React.forwardRef( {shouldPrefetch && !isAbsolute ? ( From c3e9dedbd3e35237d92c9928b2a4792892367ec3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Jun 2023 14:12:29 -0400 Subject: [PATCH 08/10] Docs --- docs/components/link.md | 2 ++ docs/components/nav-link.md | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 From 219057f476addb687e5f924650839db8fa7c2ef8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Jun 2023 14:41:01 -0400 Subject: [PATCH 09/10] Add E2E test --- integration/prefetch-test.ts | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) 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); + }); +}); From 748d6a0c781eb41746b29955ab739a5c4ab5b8db Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 15 Jun 2023 17:48:46 -0400 Subject: [PATCH 10/10] Revert "Only attach ref when prefetch=viewport is specified" This reverts commit 44f28f3a2e514e114a818e64c3ffdbca6d5be8f0. --- packages/remix-react/components.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 7b8cb8161c6..77ba692eb7a 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -222,7 +222,7 @@ interface PrefetchHandlers { function usePrefetchBehavior( prefetch: PrefetchBehavior, theirElementProps: PrefetchHandlers -): [boolean, React.RefObject | null, Required] { +): [boolean, React.RefObject, Required] { let [maybePrefetch, setMaybePrefetch] = React.useState(false); let [shouldPrefetch, setShouldPrefetch] = React.useState(false); let { onFocus, onBlur, onMouseEnter, onMouseLeave, onTouchStart } = @@ -276,7 +276,7 @@ function usePrefetchBehavior( return [ shouldPrefetch, - prefetch === "viewport" ? ref : null, + ref, { onFocus: composeEventHandlers(onFocus, setIntent), onBlur: composeEventHandlers(onBlur, cancelIntent), @@ -309,7 +309,7 @@ let NavLink = React.forwardRef( {shouldPrefetch && !isAbsolute ? ( @@ -343,7 +343,7 @@ let Link = React.forwardRef( {shouldPrefetch && !isAbsolute ? (