diff --git a/app/components/listing/MapOfLocations.tsx b/app/components/listing/MapOfLocations.tsx index 9c999e96e..b686e6080 100644 --- a/app/components/listing/MapOfLocations.tsx +++ b/app/components/listing/MapOfLocations.tsx @@ -10,6 +10,7 @@ import { UserLocationMarker, } from "../ui/MapElements"; import { useAppContext } from "../../utils"; +import { useInView } from "../../hooks/useInView"; export const MapOfLocations = ({ locationRenderer, @@ -19,6 +20,16 @@ export const MapOfLocations = ({ locationRenderer: (loc: LocationDetails) => ReactElement; }) => { const { userLocation } = useAppContext(); + // Location/organization detail pages render this component well below the + // fold (after About, Details, Contact Info, etc). Since every mount of + // is a billable Maps JavaScript API "map load", we defer + // mounting it until the map container is about to scroll into view. This + // avoids paying for a map load on every page view, including the many + // visitors who never scroll down that far. + const [mapContainerRef, isMapInView] = useInView({ + rootMargin: "300px", + }); + if (userLocation === null) { return ; } @@ -26,25 +37,29 @@ export const MapOfLocations = ({ return (
-
- - - {locations.map(({ address, id }, i) => ( - - ))} - +
+ {isMapInView ? ( + + + {locations.map(({ address, id }, i) => ( + + ))} + + ) : ( + + )}
{locationRenderer && ( diff --git a/app/components/search/SearchMap/SearchMap.tsx b/app/components/search/SearchMap/SearchMap.tsx index 8ca82f577..b06d8891b 100644 --- a/app/components/search/SearchMap/SearchMap.tsx +++ b/app/components/search/SearchMap/SearchMap.tsx @@ -15,6 +15,7 @@ import "./SearchMap.scss"; import { icon } from "assets"; import { SearchHit } from "../../../models"; import config from "../../../config"; +import { useInView } from "../../../hooks/useInView"; export const SearchMap = ({ hits, @@ -34,10 +35,22 @@ export const SearchMap = ({ overlayMapWithSearchResults: boolean; }) => { const { userLocation } = useAppContext(); - if (userLocation === null) { + // Note: this map sits directly alongside the results list (desktop) or + // above it (mobile), so it is typically within the initial viewport and + // this will load almost immediately for most visitors. It's included here + // mainly for consistency/defense-in-depth with MapOfLocations, and so this + // page doesn't pay for a map load on layouts/viewports where the map + // isn't immediately visible (e.g. a short viewport with a sticky header). + const [mapContainerRef, isMapInView] = useInView({ + rootMargin: "300px", + }); + + if (userLocation === null || !isMapInView) { return ( -
- +
+
+ +
); } @@ -45,7 +58,7 @@ export const SearchMap = ({ const { lat, lng } = userLocation; return ( -
+
{/* If map is being overlaid, hide the search area button. It is is neither clickable nor relevant in this mode. diff --git a/app/hooks/useInView.ts b/app/hooks/useInView.ts new file mode 100644 index 000000000..32c8c3bff --- /dev/null +++ b/app/hooks/useInView.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * React hook that reports whether a DOM element has scrolled into (or near) + * the viewport, using the native IntersectionObserver API. + * + * This is intended for lazy-loading expensive, one-time third-party + * embeds (e.g. Google Maps), so once the element has been observed as + * visible, observation stops permanently -- the returned boolean will + * never flip back to `false`. This is not meant for continuously tracking + * visibility (e.g. for animations that should replay every time an + * element scrolls into view). + * + * In environments without IntersectionObserver support, the hook + * immediately reports `true` so consumers never get stuck waiting on + * unsupported platforms. + */ +export function useInView( + options: IntersectionObserverInit = {} +): [React.RefObject, boolean] { + const ref = useRef(null); + const [isInView, setIsInView] = useState(false); + const { root, rootMargin, threshold } = options; + + useEffect(() => { + if (isInView) { + return undefined; + } + + const node = ref.current; + if (!node) { + return undefined; + } + + if (typeof IntersectionObserver === "undefined") { + setIsInView(true); + return undefined; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + } + }, + { root, rootMargin, threshold } + ); + + observer.observe(node); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isInView, root, rootMargin, threshold]); + + return [ref, isInView]; +}