diff --git a/src/components/main-component.tsx b/src/components/main-component.tsx index e8fe271a..10b87771 100644 --- a/src/components/main-component.tsx +++ b/src/components/main-component.tsx @@ -3,7 +3,7 @@ import { useViewStore } from "@/lib/store"; import classNames from "classnames"; import { usePathname } from "next/navigation"; -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { LOCATION_ROUTE } from "./common"; import FiltersPopup from "./filters-popup"; import { MapLoadingAnimation } from "./map-loading-animation"; @@ -15,18 +15,23 @@ export function MainComponent({ mapContainer: React.ReactNode; sidePanel: React.ReactNode; }) { - const currentPath = usePathname() as string; - const [ignore, firstPathComponent, secondPathComponent] = - currentPath.split("/"); - const isLocationDetailPage = - firstPathComponent === LOCATION_ROUTE && - typeof secondPathComponent === "string"; - const showMapViewOnMobile = useViewStore( (state) => state.showMapViewOnMobile, ); + const setShowMapViewOnMobile = useViewStore( + (state) => state.setShowMapViewOnMobile, + ); + const currentPath = usePathname(); + const [, firstPathComponent, secondPathComponent] = currentPath.split("/"); + const isLocationDetailPage = + firstPathComponent === LOCATION_ROUTE && + typeof secondPathComponent === "string"; - const showMapView = showMapViewOnMobile && !isLocationDetailPage; + useEffect(() => { + if (isLocationDetailPage && showMapViewOnMobile) { + setShowMapViewOnMobile(false); + } + }, [isLocationDetailPage, setShowMapViewOnMobile, showMapViewOnMobile]); const classnames = classNames([ "flex-1", @@ -34,7 +39,9 @@ export function MainComponent({ "flex", "flex-col", "md:flex-row", - showMapView ? "showMapOnMobile" : "hideMapOnMobile", + showMapViewOnMobile && !isLocationDetailPage + ? "showMapOnMobile" + : "hideMapOnMobile", ]); return ( diff --git a/src/components/map-loading.ts b/src/components/map-loading.ts new file mode 100644 index 00000000..bd91c301 --- /dev/null +++ b/src/components/map-loading.ts @@ -0,0 +1,11 @@ +export function shouldLoadGoogleMap({ + viewportWidth, + showMapViewOnMobile, + isLocationDetail, +}: { + viewportWidth: number; + showMapViewOnMobile: boolean; + isLocationDetail: boolean; +}): boolean { + return viewportWidth >= 768 || (showMapViewOnMobile && !isLocationDetail); +} diff --git a/src/components/map.tsx b/src/components/map.tsx index 0cc7d288..38cd0480 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -28,6 +28,7 @@ import { defaultZoom, mapStyles, myLocationIcon } from "./map-common"; import { MobileTray } from "./mobile-tray"; import { getUrlWithNewFilterParameter } from "./navigation"; import { shouldAutoRedirectToNearby } from "./nearby-redirect"; +import { shouldLoadGoogleMap } from "./map-loading"; function isMobile(): boolean { return window.innerWidth < 768; @@ -382,6 +383,29 @@ export default function LocationsMap({ useState(cookieLocationSlugClickedOnMobile); const [locationStubClickedOnMobile, setLocationStubClickedOnMobile] = useState(); + const [shouldLoadMap, setShouldLoadMap] = useState(false); + const showMapViewOnMobile = useViewStore( + (state) => state.showMapViewOnMobile, + ); + + useEffect(() => { + const updateMapLoading = () => { + const canLoadMap = shouldLoadGoogleMap({ + viewportWidth: window.innerWidth, + showMapViewOnMobile, + isLocationDetail: !!locationDetailStub, + }); + + // Deferring the initial load protects mobile LCP. Once the user has + // opened the map, keep it mounted so a list/map toggle preserves its + // pan and zoom state instead of constructing a new map each time. + setShouldLoadMap((hasLoadedMap) => hasLoadedMap || canLoadMap); + }; + + updateMapLoading(); + window.addEventListener("resize", updateMapLoading); + return () => window.removeEventListener("resize", updateMapLoading); + }, [locationDetailStub, showMapViewOnMobile]); useEffect(() => { if (locationSlugClickedOnMobile) { @@ -432,15 +456,17 @@ export default function LocationsMap({ return ( <>
- - - + {shouldLoadMap && ( + + + + )}
{locationStubClickedOnMobile ? ( { + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-api-key"; +}); + +vi.mock("next-client-cookies", () => ({ + useCookies: () => ({ + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/locations/example", + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@vis.gl/react-google-maps", () => ({ + APIProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Map: ({ children }: { children: ReactNode }) =>
{children}
, + Marker: () => null, + useMap: () => null, +})); + +vi.mock("@/components/location-stub-marker", () => ({ default: () => null })); +vi.mock("@/components/mobile-tray", () => ({ MobileTray: () => null })); + +import LocationsMap from "@/components/map"; +import { GeoCoordinatesContext } from "@/components/geo-context"; +import { useViewStore } from "@/lib/store"; +import { type SimplifiedLocationData } from "@/components/common"; + +function setViewportWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + }); +} + +function renderMap(locationDetailStub?: SimplifiedLocationData) { + return render( + + + , + ); +} + +describe("LocationsMap responsive loading", () => { + beforeEach(() => { + useViewStore.setState({ showMapViewOnMobile: false }); + }); + + it("initializes Google Maps on desktop", () => { + setViewportWidth(1024); + renderMap(); + + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + }); + + it("defers Google Maps on mobile until the map toggle is selected", () => { + setViewportWidth(390); + renderMap(); + + expect(screen.queryByTestId("google-map-provider")).not.toBeInTheDocument(); + + act(() => useViewStore.getState().setShowMapViewOnMobile(true)); + + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + }); + + it("does not initialize a hidden map on a mobile location detail route", () => { + setViewportWidth(390); + useViewStore.setState({ showMapViewOnMobile: true }); + renderMap({} as SimplifiedLocationData); + + expect(screen.queryByTestId("google-map-provider")).not.toBeInTheDocument(); + }); + + it("preserves an initialized map while resizing in both directions", () => { + setViewportWidth(1024); + renderMap(); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + + act(() => { + setViewportWidth(390); + window.dispatchEvent(new Event("resize")); + }); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + + act(() => { + setViewportWidth(1024); + window.dispatchEvent(new Event("resize")); + }); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + }); + + it("preserves an initialized map when mobile map view is toggled", () => { + setViewportWidth(390); + renderMap(); + + act(() => useViewStore.getState().setShowMapViewOnMobile(true)); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + + act(() => useViewStore.getState().setShowMapViewOnMobile(false)); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + + act(() => useViewStore.getState().setShowMapViewOnMobile(true)); + expect(screen.getByTestId("google-map-provider")).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/main-component.test.tsx b/tests/unit/main-component.test.tsx new file mode 100644 index 00000000..fcb0e6e1 --- /dev/null +++ b/tests/unit/main-component.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const route = vi.hoisted(() => ({ pathname: "/locations" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => route.pathname, +})); + +vi.mock("@/components/filters-popup", () => ({ default: () => null })); + +import { MainComponent } from "@/components/main-component"; +import { useViewStore } from "@/lib/store"; + +describe("MainComponent mobile route transitions", () => { + beforeEach(() => { + useViewStore.setState({ showMapViewOnMobile: false }); + route.pathname = "/locations"; + }); + + it("resets map view and displays location details after navigating from the mobile map", () => { + route.pathname = "/locations/example-location"; + useViewStore.setState({ showMapViewOnMobile: true }); + + render(} sidePanel={
} />); + + expect(screen.getByRole("main")).toHaveClass("hideMapOnMobile"); + expect(useViewStore.getState().showMapViewOnMobile).toBe(false); + }); + + it("continues to show the map for non-detail mobile routes", () => { + useViewStore.setState({ showMapViewOnMobile: true }); + + render(} sidePanel={
} />); + + expect(screen.getByRole("main")).toHaveClass("showMapOnMobile"); + }); +}); diff --git a/tests/unit/map-loading.test.ts b/tests/unit/map-loading.test.ts new file mode 100644 index 00000000..8cb5c636 --- /dev/null +++ b/tests/unit/map-loading.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { shouldLoadGoogleMap } from "@/components/map-loading"; + +describe("shouldLoadGoogleMap", () => { + it("does not load a map behind a hidden mobile location detail panel", () => { + expect( + shouldLoadGoogleMap({ + viewportWidth: 390, + showMapViewOnMobile: false, + isLocationDetail: true, + }), + ).toBe(false); + }); + + it("does not load the hidden map when a mobile location detail route is opened", () => { + expect( + shouldLoadGoogleMap({ + viewportWidth: 390, + showMapViewOnMobile: true, + isLocationDetail: true, + }), + ).toBe(false); + }); + + it("loads the map for a mobile list page after the map toggle changes", () => { + const mobileList = { viewportWidth: 390, isLocationDetail: false }; + + expect( + shouldLoadGoogleMap({ ...mobileList, showMapViewOnMobile: false }), + ).toBe(false); + expect( + shouldLoadGoogleMap({ ...mobileList, showMapViewOnMobile: true }), + ).toBe(true); + }); + + it("re-evaluates to load the map after a resize to desktop", () => { + expect( + shouldLoadGoogleMap({ + viewportWidth: 1024, + showMapViewOnMobile: false, + isLocationDetail: true, + }), + ).toBe(true); + }); + + it("loads the map on desktop regardless of the mobile toggle state", () => { + expect( + shouldLoadGoogleMap({ + viewportWidth: 768, + showMapViewOnMobile: false, + isLocationDetail: false, + }), + ).toBe(true); + }); +}); diff --git a/tests/unit/street-view-component.test.tsx b/tests/unit/street-view-component.test.tsx new file mode 100644 index 00000000..d087c240 --- /dev/null +++ b/tests/unit/street-view-component.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-api-key"; +}); + +vi.mock("@vis.gl/react-google-maps", () => ({ + APIProvider: ({ children }: { children: ReactNode }) => <>{children}, + Map: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Marker: () =>
, +})); + +vi.mock("@/components/location-stub-marker", () => ({ default: () => null })); + +import StreetView from "@/components/location-detail/street-view"; +import { type YourPeerLegacyLocationData } from "@/components/common"; + +const LOCATION = { + lat: 40.6319, + lng: -74.0298, + closed: false, + name: "Example location", + streetview: null, +} as YourPeerLegacyLocationData; + +describe("StreetView", () => { + it("keeps the interactive location map and marker for the mobile layout", () => { + render(); + + expect(screen.getByTestId("location-mini-map")).toBeInTheDocument(); + expect(screen.getByTestId("location-marker")).toBeInTheDocument(); + expect( + document.querySelector("#miniMap")?.parentElement?.className, + ).toContain("md:hidden"); + expect( + screen.getAllByRole("link", { name: "Open Street View" })[1], + ).toHaveAttribute("href", expect.stringContaining("google.com/maps")); + }); + + it("keeps the static Street View image for desktop", () => { + render(); + + const image = document.querySelector("img"); + expect(image).not.toBeNull(); + expect(image).toHaveAttribute("loading", "lazy"); + expect(image?.className).toContain("object-cover"); + }); + + it("does not render a map or preview for closed locations", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +});