From 616c0b0d92e4dda06b0e3187babd65f85eb0c1df Mon Sep 17 00:00:00 2001 From: doobneek Date: Mon, 9 Mar 2026 18:52:17 -0400 Subject: [PATCH 1/4] Cache nearby locations for local pagination --- src/app/api/locations-pagination/route.ts | 155 +++++++ src/components/locations-container-pager.tsx | 58 ++- src/components/locations-container.tsx | 285 +++++++------ src/components/side-panel-component.tsx | 47 ++- .../use-cached-locations-pagination.ts | 392 ++++++++++++++++++ 5 files changed, 783 insertions(+), 154 deletions(-) create mode 100644 src/app/api/locations-pagination/route.ts create mode 100644 src/components/use-cached-locations-pagination.ts diff --git a/src/app/api/locations-pagination/route.ts b/src/app/api/locations-pagination/route.ts new file mode 100644 index 00000000..9bda37c0 --- /dev/null +++ b/src/app/api/locations-pagination/route.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + REQUIREMENT_PARAM, + RESOURCE_ROUTES, + SHELTER_PARAM_SINGLE_VALUE, + SHELTER_PARAM_YOUTH_VALUE, + SORT_BY_QUERY_PARAM, + SearchParams, + SubRouteParams, + parseCategoryFromRoute, + parseRequest, +} from "@/components/common"; +import { + getFullLocationData, + getTaxonomies, + map_gogetta_to_yourpeer, +} from "@/components/streetlives-api-service"; + +const DEFAULT_PAGE_SIZE = 200; +const MAX_PAGE_SIZE = 200; + +function toSearchParamsObject( + urlSearchParams: URLSearchParams, + excludedKeys: Set, +): SearchParams { + const entries = new Map(); + + urlSearchParams.forEach((value, key) => { + if (excludedKeys.has(key)) { + return; + } + + const existing = entries.get(key); + if (existing) { + existing.push(value); + return; + } + + entries.set(key, [value]); + }); + + return Object.fromEntries( + Array.from(entries.entries()).map(([key, values]) => [ + key, + values.length === 1 ? values[0] : values, + ]), + ); +} + +function parsePositiveInteger( + rawValue: string | null, + fallbackValue: number, +): number { + if (!rawValue) { + return fallbackValue; + } + + const parsedValue = Number.parseInt(rawValue, 10); + return Number.isFinite(parsedValue) && parsedValue >= 0 + ? parsedValue + : fallbackValue; +} + +export async function GET(request: NextRequest) { + const requestUrl = new URL(request.url); + const route = requestUrl.searchParams.get("route"); + + if (!route || !RESOURCE_ROUTES.includes(route)) { + return NextResponse.json( + { error: "Expected a valid route query parameter." }, + { status: 400 }, + ); + } + + const locationSlugOrPersonalCareSubCategory = + requestUrl.searchParams.get("locationSlugOrPersonalCareSubCategory") || + undefined; + + const params: SubRouteParams | { route: string } = + locationSlugOrPersonalCareSubCategory + ? { + route, + locationSlugOrPersonalCareSubCategory, + } + : { route }; + + const page = parsePositiveInteger( + requestUrl.searchParams.get("pageNumber"), + 0, + ); + const pageSize = Math.min( + parsePositiveInteger( + requestUrl.searchParams.get("pageSize"), + DEFAULT_PAGE_SIZE, + ), + MAX_PAGE_SIZE, + ); + + const searchParams = toSearchParamsObject( + requestUrl.searchParams, + new Set([ + "route", + "locationSlugOrPersonalCareSubCategory", + "page", + "pageNumber", + "pageSize", + ]), + ); + + const parsedSearchParams = parseRequest({ + params, + searchParams, + cookies: await cookies(), + }); + const category = parseCategoryFromRoute(route); + const taxonomiesResults = await getTaxonomies(category, parsedSearchParams); + + const locationParams = { + ...parsedSearchParams, + ...parsedSearchParams[REQUIREMENT_PARAM], + ...taxonomiesResults, + page, + pageSize, + sortBy: parsedSearchParams[SORT_BY_QUERY_PARAM], + ...(locationSlugOrPersonalCareSubCategory === SHELTER_PARAM_YOUTH_VALUE && { + ageMin: 16, + ageMax: 24, + }), + ...(locationSlugOrPersonalCareSubCategory === + SHELTER_PARAM_SINGLE_VALUE && { + ageMin: 18, + ageMax: 99, + }), + }; + + const { locations, numberOfPages, resultCount } = + await getFullLocationData(locationParams); + + return NextResponse.json({ + pageNumber: page, + pageSize, + numberOfPages, + resultCount, + locations: locations.map((location) => + map_gogetta_to_yourpeer(location, false), + ), + }); +} diff --git a/src/components/locations-container-pager.tsx b/src/components/locations-container-pager.tsx index 7b42abdf..a18e09dc 100644 --- a/src/components/locations-container-pager.tsx +++ b/src/components/locations-container-pager.tsx @@ -19,21 +19,47 @@ import { ChevronsLeft, ChevronsRight, } from "lucide-react"; +import React from "react"; export function LocationsContainerPager({ - resultCount, numberOfPages, currentPage, + onPageChange, }: { - resultCount: number; numberOfPages: number; currentPage: number; + onPageChange?: (pageNumber: number) => void; }) { const pathname = usePathname(); const searchParams = useSearchParams(); const hasPreviousPage = currentPage > 0; const hasNextPage = currentPage < numberOfPages; + const firstPageHref = hasPreviousPage + ? getFirstPageHref(pathname, searchParams) + : undefined; + const previousPageHref = hasPreviousPage + ? getUrlToNextOrPreviousPage(pathname, searchParams, false) + : undefined; + const nextPageHref = hasNextPage + ? getUrlToNextOrPreviousPage(pathname, searchParams, true) + : undefined; + const lastPageHref = hasNextPage + ? getLastPageHref(pathname, searchParams, numberOfPages) + : undefined; + + function handlePageChange( + event: React.MouseEvent, + pageNumber: number, + ) { + if (!onPageChange || pageNumber < 0 || pageNumber > numberOfPages) { + return; + } + + event.preventDefault(); + onPageChange(pageNumber); + } + return (
@@ -42,11 +68,8 @@ export function LocationsContainerPager({ className={`text-dark inline-flex space-x-1 disabled:text-muted ${ !hasPreviousPage ? "text-muted cursor-not-allowed" : "" }`} - href={ - hasPreviousPage - ? getFirstPageHref(pathname, searchParams) - : undefined - } + href={firstPageHref} + onClick={(event) => handlePageChange(event, 0)} > @@ -55,11 +78,8 @@ export function LocationsContainerPager({ className={`text-dark inline-flex space-x-1 disabled:text-muted ${ !hasPreviousPage ? "text-muted cursor-not-allowed" : "" }`} - href={ - hasPreviousPage - ? getUrlToNextOrPreviousPage(pathname, searchParams, false) - : undefined - } + href={previousPageHref} + onClick={(event) => handlePageChange(event, currentPage - 1)} > @@ -77,11 +97,8 @@ export function LocationsContainerPager({ className={`inline-flex space-x-1 disabled:text-muted ${ hasNextPage ? "text-dark" : "text-muted cursor-not-allowed" }`} - href={ - hasNextPage - ? getUrlToNextOrPreviousPage(pathname, searchParams, true) - : undefined - } + href={nextPageHref} + onClick={(event) => handlePageChange(event, currentPage + 1)} > @@ -91,11 +108,8 @@ export function LocationsContainerPager({ className={`inline-flex space-x-1 disabled:text-muted ${ hasNextPage ? "text-dark" : "text-muted cursor-not-allowed" }`} - href={ - hasNextPage - ? getLastPageHref(pathname, searchParams, numberOfPages) - : undefined - } + href={lastPageHref} + onClick={(event) => handlePageChange(event, numberOfPages)} > diff --git a/src/components/locations-container.tsx b/src/components/locations-container.tsx index 0d639084..32249a94 100644 --- a/src/components/locations-container.tsx +++ b/src/components/locations-container.tsx @@ -24,9 +24,10 @@ import { import { LocationsContainerPager } from "./locations-container-pager"; import classNames from "classnames"; import { getUrlWithNewCategory } from "./navigation"; +import { SidebarLoadingAnimation } from "./sidebar-loading-animation"; import { SortDropdown } from "./sort-dropdown"; import { TranslatableText } from "./translatable-text"; -import { useGTranslateCookie } from "./use-translated-text-hook"; +import { useCachedLocationsPagination } from "./use-cached-locations-pagination"; import React from "react"; function NoLocationsFound({ searchParams }: { searchParams: SearchParams }) { @@ -104,23 +105,25 @@ export function ServicesList({ export default function LocationsContainer({ searchParams, + route, + locationSlugOrPersonalCareSubCategory, category, - yourPeerLegacyLocationData, + initialPageLocations, resultCount, numberOfPages, - currentPage, + initialPage, subCategory, }: { searchParams: SearchParams; + route: string; + locationSlugOrPersonalCareSubCategory?: string; category: Category; subCategory?: SubCategory | null; - yourPeerLegacyLocationData: YourPeerLegacyLocationData[]; + initialPageLocations: YourPeerLegacyLocationData[]; resultCount: number; numberOfPages: number; - currentPage: number; + initialPage: number; }) { - const gTranslateCookie = useGTranslateCookie(); - const classnames = classNames([ "md:flex", "flex-col", @@ -136,12 +139,29 @@ export default function LocationsContainer({ "block", ]); + const { + currentPage, + isPageLoading, + navigateToPage, + numberOfPages: cachedNumberOfPages, + resultCount: cachedResultCount, + visibleLocations, + } = useCachedLocationsPagination({ + route, + locationSlugOrPersonalCareSubCategory, + searchParams, + initialPage, + initialPageLocations, + resultCount, + numberOfPages, + }); + function getCategoryHeaderText( category: Category, subCategory?: SubCategory | null, ): string { if (searchParams[SEARCH_PARAM]) { - return `${resultCount} results for "${searchParams[SEARCH_PARAM]}"`; + return `${cachedResultCount} results for "${searchParams[SEARCH_PARAM]}"`; } switch (category) { case "shelters-housing": @@ -187,135 +207,144 @@ export default function LocationsContainer({
- {yourPeerLegacyLocationData.length ? ( + {visibleLocations.length ? ( <> -
    - {yourPeerLegacyLocationData.map((location) => ( -
  • -
    + {isPageLoading ? ( +
    + +
    + ) : undefined} +
      + {visibleLocations.map((location) => ( +
    • - {location.name} -
    -
    - {location.name ? ( -

    - {location.location_name} -

    - ) : undefined} -

    - - {location.area} - - - {" "} - ✓ Validated{" "} - {location.last_updated} - -

    -
    - {location.closed ? ( -
    -
    - - - - +
    + {location.name} +
    +
    + {location.name ? ( +

    + {location.location_name} +

    + ) : undefined} +

    + + {location.area} + + {" "} + ✓ Validated{" "} + {location.last_updated} + +

    +
    + {location.closed ? ( +
    +
    + + + + + -
    -

    - Closed -

    - {location.info ? ( -

    - ) : undefined} +
    +

    + Closed +

    + {location.info ? ( +

    + ) : undefined} +
    -
    - ) : ( -
      - {CATEGORIES.map((serviceCategory) => { - const servicesWrapper = getServicesWrapper( - serviceCategory, - location, - ); - return servicesWrapper?.services.length ? ( -
    • - -

      - -

      -
    • - ) : undefined; - })} -
    - )} -
    - - More Details - + {CATEGORIES.map((serviceCategory) => { + const servicesWrapper = getServicesWrapper( + serviceCategory, + location, + ); + return servicesWrapper?.services.length ? ( +
  • + +

    + +

    +
  • + ) : undefined; + })} +
+ )} +
+ - - - -
- - ))} - + More Details + + + + +
+ + ))} + + ) : ( diff --git a/src/components/side-panel-component.tsx b/src/components/side-panel-component.tsx index 5a9567b1..913d8330 100644 --- a/src/components/side-panel-component.tsx +++ b/src/components/side-panel-component.tsx @@ -18,6 +18,33 @@ import { useEffect } from "react"; import { SidePanelComponentData } from "./get-side-panel-component-data"; import { useFilters } from "@/lib/store"; import { SidebarLoadingAnimation } from "./sidebar-loading-animation"; +import { useSearchParams } from "next/navigation"; + +function getLiveSearchParamsObject( + liveSearchParams: ReturnType, +): SearchParams { + if (!liveSearchParams) { + return {}; + } + + const entries = new Map(); + liveSearchParams.forEach((value, key) => { + const existing = entries.get(key); + if (existing) { + existing.push(value); + return; + } + + entries.set(key, [value]); + }); + + return Object.fromEntries( + Array.from(entries.entries()).map(([key, values]) => [ + key, + values.length === 1 ? values[0] : values, + ]), + ); +} export function SidePanelComponent({ searchParams, @@ -38,6 +65,7 @@ export function SidePanelComponent({ const updateResultsCount = useFilters((state) => state.updateResultCount); const setLoading = useFilters((state) => state.setLoading); const isLoading = useFilters((state) => state.isLoading); + const liveSearchParams = useSearchParams(); useEffect(() => { updateResultsCount(resultCount); @@ -46,10 +74,17 @@ export function SidePanelComponent({ LAST_SET_PARAMS_COOKIE_NAME, JSON.stringify({ params, - searchParams, + searchParams: getLiveSearchParamsObject(liveSearchParams), }), ); - }, [params, searchParams]); + }, [ + cookies, + liveSearchParams, + params, + resultCount, + setLoading, + updateResultsCount, + ]); return ( <> @@ -69,12 +104,16 @@ export function SidePanelComponent({ /> diff --git a/src/components/use-cached-locations-pagination.ts b/src/components/use-cached-locations-pagination.ts new file mode 100644 index 00000000..c8c6a22f --- /dev/null +++ b/src/components/use-cached-locations-pagination.ts @@ -0,0 +1,392 @@ +"use client"; + +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { + PAGE_PARAM, + SearchParams, + YourPeerLegacyLocationData, + parsePageParam, +} from "./common"; + +const DISPLAY_PAGE_SIZE = 20; +const BACKGROUND_PAGE_SIZE = 200; +const DISPLAY_PAGES_PER_BACKGROUND_PAGE = + BACKGROUND_PAGE_SIZE / DISPLAY_PAGE_SIZE; +const MAX_PARALLEL_BACKGROUND_REQUESTS = 3; + +interface CachedLocationsDataset { + pages: Record; + loadedBackgroundPages: number[]; + loadingBackgroundPages: number[]; + resultCount: number; + numberOfPages: number; +} + +interface BackgroundLocationsResponse { + locations: YourPeerLegacyLocationData[]; + pageNumber: number; + pageSize: number; + resultCount: number; + numberOfPages: number; +} + +interface UseCachedLocationsPaginationArgs { + route: string; + locationSlugOrPersonalCareSubCategory?: string; + searchParams: SearchParams; + initialPage: number; + initialPageLocations: YourPeerLegacyLocationData[]; + resultCount: number; + numberOfPages: number; +} + +function getSearchParamEntries(searchParams: SearchParams): string[][] { + return Object.entries(searchParams) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .flatMap(([key, value]): string[][] => { + if (key === PAGE_PARAM || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value + .slice() + .sort((left, right) => left.localeCompare(right)) + .map((item) => [key, item]); + } + + return [[key, value]]; + }); +} + +function splitLocationsIntoDisplayPages( + backgroundPageNumber: number, + locations: YourPeerLegacyLocationData[], +): Record { + const nextPages: Record = {}; + const startingDisplayPage = + backgroundPageNumber * DISPLAY_PAGES_PER_BACKGROUND_PAGE; + + for (let offset = 0; offset < locations.length; offset += DISPLAY_PAGE_SIZE) { + const displayPageNumber = startingDisplayPage + offset / DISPLAY_PAGE_SIZE; + nextPages[displayPageNumber] = locations.slice( + offset, + offset + DISPLAY_PAGE_SIZE, + ); + } + + return nextPages; +} + +function mergeBackgroundPage( + currentDataset: CachedLocationsDataset, + response: BackgroundLocationsResponse, +): CachedLocationsDataset { + return { + pages: { + ...currentDataset.pages, + ...splitLocationsIntoDisplayPages( + response.pageNumber, + response.locations, + ), + }, + loadedBackgroundPages: Array.from( + new Set(currentDataset.loadedBackgroundPages.concat(response.pageNumber)), + ).sort((left, right) => left - right), + loadingBackgroundPages: currentDataset.loadingBackgroundPages.filter( + (pageNumber) => pageNumber !== response.pageNumber, + ), + resultCount: response.resultCount, + numberOfPages: response.numberOfPages, + }; +} + +async function fetchBackgroundPage({ + route, + locationSlugOrPersonalCareSubCategory, + serializedSearchParams, + pageNumber, +}: { + route: string; + locationSlugOrPersonalCareSubCategory?: string; + serializedSearchParams: string; + pageNumber: number; +}): Promise { + const params = new URLSearchParams(serializedSearchParams); + params.set("route", route); + params.set("pageNumber", pageNumber.toString()); + params.set("pageSize", BACKGROUND_PAGE_SIZE.toString()); + + if (locationSlugOrPersonalCareSubCategory) { + params.set( + "locationSlugOrPersonalCareSubCategory", + locationSlugOrPersonalCareSubCategory, + ); + } + + const response = await fetch( + `/api/locations-pagination?${params.toString()}`, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch cached locations page ${pageNumber}: ${response.status}`, + ); + } + + return response.json(); +} + +export function useCachedLocationsPagination({ + route, + locationSlugOrPersonalCareSubCategory, + searchParams, + initialPage, + initialPageLocations, + resultCount, + numberOfPages, +}: UseCachedLocationsPaginationArgs) { + const liveSearchParams = useSearchParams(); + const queryClient = useQueryClient(); + const [lastResolvedPage, setLastResolvedPage] = useState(initialPage); + + const serializedSearchParams = useMemo( + () => new URLSearchParams(getSearchParamEntries(searchParams)).toString(), + [searchParams], + ); + + const queryKey = useMemo( + () => [ + "cached-locations-pagination", + route, + locationSlugOrPersonalCareSubCategory || "", + serializedSearchParams, + ], + [route, locationSlugOrPersonalCareSubCategory, serializedSearchParams], + ); + + const initialDataset = useMemo( + () => ({ + pages: { + [initialPage]: initialPageLocations, + }, + loadedBackgroundPages: [], + loadingBackgroundPages: [], + resultCount, + numberOfPages, + }), + [initialPage, initialPageLocations, numberOfPages, resultCount], + ); + + const { data: cachedDataset } = useQuery({ + queryKey, + queryFn: async () => initialDataset, + initialData: initialDataset, + staleTime: Number.POSITIVE_INFINITY, + gcTime: 1000 * 60 * 60, + }); + + useEffect(() => { + setLastResolvedPage(initialPage); + }, [initialPage, queryKey]); + + useEffect(() => { + queryClient.setQueryData(queryKey, (previous) => { + const dataset = previous || initialDataset; + return { + ...dataset, + pages: { + ...dataset.pages, + [initialPage]: initialPageLocations, + }, + resultCount, + numberOfPages, + }; + }); + }, [ + initialDataset, + initialPage, + initialPageLocations, + numberOfPages, + queryClient, + queryKey, + resultCount, + ]); + + const ensureBackgroundPage = useCallback( + async (pageNumber: number) => { + let shouldFetch = false; + + queryClient.setQueryData(queryKey, (previous) => { + const dataset = previous || initialDataset; + if ( + dataset.loadedBackgroundPages.includes(pageNumber) || + dataset.loadingBackgroundPages.includes(pageNumber) + ) { + return dataset; + } + + shouldFetch = true; + return { + ...dataset, + loadingBackgroundPages: + dataset.loadingBackgroundPages.concat(pageNumber), + }; + }); + + if (!shouldFetch) { + return; + } + + try { + const response = await fetchBackgroundPage({ + route, + locationSlugOrPersonalCareSubCategory, + serializedSearchParams, + pageNumber, + }); + + queryClient.setQueryData(queryKey, (previous) => + mergeBackgroundPage(previous || initialDataset, response), + ); + } catch (error) { + queryClient.setQueryData( + queryKey, + (previous) => { + const dataset = previous || initialDataset; + return { + ...dataset, + loadingBackgroundPages: dataset.loadingBackgroundPages.filter( + (loadingPageNumber) => loadingPageNumber !== pageNumber, + ), + }; + }, + ); + console.error(error); + } + }, + [ + initialDataset, + locationSlugOrPersonalCareSubCategory, + queryClient, + queryKey, + route, + serializedSearchParams, + ], + ); + + const currentPage = parsePageParam(liveSearchParams?.get(PAGE_PARAM)); + const currentPageLocations = cachedDataset.pages[currentPage]; + + useEffect(() => { + if (currentPageLocations) { + setLastResolvedPage(currentPage); + return; + } + + const backgroundPageNumber = Math.floor( + currentPage / DISPLAY_PAGES_PER_BACKGROUND_PAGE, + ); + void ensureBackgroundPage(backgroundPageNumber); + }, [currentPage, currentPageLocations, ensureBackgroundPage]); + + useEffect(() => { + if (numberOfPages <= 0) { + return; + } + + const totalBackgroundPages = Math.ceil( + (numberOfPages + 1) / DISPLAY_PAGES_PER_BACKGROUND_PAGE, + ); + const missingBackgroundPages = Array.from( + { length: totalBackgroundPages }, + (_, pageNumber) => pageNumber, + ).filter( + (pageNumber) => + !cachedDataset.loadedBackgroundPages.includes(pageNumber) && + !cachedDataset.loadingBackgroundPages.includes(pageNumber), + ); + + if (!missingBackgroundPages.length) { + return; + } + + let cancelled = false; + + async function fillCacheInBackground() { + for ( + let offset = 0; + offset < missingBackgroundPages.length; + offset += MAX_PARALLEL_BACKGROUND_REQUESTS + ) { + if (cancelled) { + return; + } + + const batch = missingBackgroundPages.slice( + offset, + offset + MAX_PARALLEL_BACKGROUND_REQUESTS, + ); + await Promise.all( + batch.map((pageNumber) => ensureBackgroundPage(pageNumber)), + ); + } + } + + void fillCacheInBackground(); + + return () => { + cancelled = true; + }; + }, [ + cachedDataset.loadedBackgroundPages, + cachedDataset.loadingBackgroundPages, + ensureBackgroundPage, + numberOfPages, + ]); + + const visibleLocations = + currentPageLocations || cachedDataset.pages[lastResolvedPage] || []; + const isPageLoading = !currentPageLocations; + + const navigateToPage = useCallback( + (pageNumber: number) => { + const nextSearchParams = new URLSearchParams( + liveSearchParams ? Array.from(liveSearchParams.entries()) : [], + ); + + if (pageNumber > 0) { + nextSearchParams.set(PAGE_PARAM, (pageNumber + 1).toString()); + } else { + nextSearchParams.delete(PAGE_PARAM); + } + + const nextUrl = `${window.location.pathname}${ + nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "" + }`; + window.history.pushState(null, "", nextUrl); + + const locationsContainer = document.getElementById("locations_container"); + if (locationsContainer) { + locationsContainer.scrollTop = 0; + } + }, + [liveSearchParams], + ); + + return { + currentPage, + isPageLoading, + navigateToPage, + numberOfPages: cachedDataset.numberOfPages, + resultCount: cachedDataset.resultCount, + visibleLocations, + }; +} From 8dae7d37c42732fb5635253a6e228d141900739b Mon Sep 17 00:00:00 2001 From: doobneek Date: Mon, 9 Mar 2026 19:02:33 -0400 Subject: [PATCH 2/4] Fix pagination metadata and add cache tests --- package.json | 1 + src/app/api/locations-pagination/route.ts | 35 ++--- .../locations-pagination-cache.test.ts | 126 ++++++++++++++++++ src/components/locations-pagination-cache.ts | 113 ++++++++++++++++ .../use-cached-locations-pagination.ts | 109 +++------------ src/lib/locations-pagination-request.test.ts | 35 +++++ src/lib/locations-pagination-request.ts | 45 +++++++ 7 files changed, 348 insertions(+), 116 deletions(-) create mode 100644 src/components/locations-pagination-cache.test.ts create mode 100644 src/components/locations-pagination-cache.ts create mode 100644 src/lib/locations-pagination-request.test.ts create mode 100644 src/lib/locations-pagination-request.ts diff --git a/package.json b/package.json index 8f2ae312..accc8b84 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start -p $PORT", "lint": "next lint", "check-types": "tsc --noEmit", + "test": "tsx --test src/components/locations-pagination-cache.test.ts src/lib/locations-pagination-request.test.ts", "format": "prettier . --write", "check-format": "prettier --check .", "all-checks": "npm run check-format && npm run check-types && npm run lint", diff --git a/src/app/api/locations-pagination/route.ts b/src/app/api/locations-pagination/route.ts index 9bda37c0..d4ee982a 100644 --- a/src/app/api/locations-pagination/route.ts +++ b/src/app/api/locations-pagination/route.ts @@ -22,9 +22,10 @@ import { getTaxonomies, map_gogetta_to_yourpeer, } from "@/components/streetlives-api-service"; - -const DEFAULT_PAGE_SIZE = 200; -const MAX_PAGE_SIZE = 200; +import { + parseLocationsBackgroundPageSize, + parseNonNegativeInteger, +} from "@/lib/locations-pagination-request"; function toSearchParamsObject( urlSearchParams: URLSearchParams, @@ -54,20 +55,6 @@ function toSearchParamsObject( ); } -function parsePositiveInteger( - rawValue: string | null, - fallbackValue: number, -): number { - if (!rawValue) { - return fallbackValue; - } - - const parsedValue = Number.parseInt(rawValue, 10); - return Number.isFinite(parsedValue) && parsedValue >= 0 - ? parsedValue - : fallbackValue; -} - export async function GET(request: NextRequest) { const requestUrl = new URL(request.url); const route = requestUrl.searchParams.get("route"); @@ -91,16 +78,12 @@ export async function GET(request: NextRequest) { } : { route }; - const page = parsePositiveInteger( + const page = parseNonNegativeInteger( requestUrl.searchParams.get("pageNumber"), 0, ); - const pageSize = Math.min( - parsePositiveInteger( - requestUrl.searchParams.get("pageSize"), - DEFAULT_PAGE_SIZE, - ), - MAX_PAGE_SIZE, + const pageSize = parseLocationsBackgroundPageSize( + requestUrl.searchParams.get("pageSize"), ); const searchParams = toSearchParamsObject( @@ -140,13 +123,11 @@ export async function GET(request: NextRequest) { }), }; - const { locations, numberOfPages, resultCount } = - await getFullLocationData(locationParams); + const { locations, resultCount } = await getFullLocationData(locationParams); return NextResponse.json({ pageNumber: page, pageSize, - numberOfPages, resultCount, locations: locations.map((location) => map_gogetta_to_yourpeer(location, false), diff --git a/src/components/locations-pagination-cache.test.ts b/src/components/locations-pagination-cache.test.ts new file mode 100644 index 00000000..4e4945d1 --- /dev/null +++ b/src/components/locations-pagination-cache.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { YourPeerLegacyLocationData } from "./common"; +import { + getDisplayNumberOfPages, + getTotalBackgroundPages, + getVisibleLocationsForPage, + mergeBackgroundPage, + splitLocationsIntoDisplayPages, +} from "./locations-pagination-cache"; + +function createLocation(id: number): YourPeerLegacyLocationData { + return { + id: id.toString(), + location_name: `Location ${id}`, + organization_id: null, + email: null, + address: null, + city: null, + region: null, + state: null, + zip: null, + lat: 0, + lng: 0, + area: null, + info: null, + slug: `/locations/${id}`, + partners: false, + last_updated: "today", + last_updated_date: new Date("2026-03-09T00:00:00.000Z"), + name: `Organization ${id}`, + phones: null, + url: null, + streetview_url: null, + accommodation_services: { services: [] }, + food_services: { services: [] }, + clothing_services: { services: [] }, + personal_care_services: { services: [] }, + health_services: { services: [] }, + other_services: { services: [] }, + legal_services: { services: [] }, + mental_health_services: { services: [] }, + employment_services: { services: [] }, + closed: false, + }; +} + +function createLocations(count: number): YourPeerLegacyLocationData[] { + return Array.from({ length: count }, (_, index) => createLocation(index + 1)); +} + +test("getDisplayNumberOfPages uses the display page size", () => { + assert.equal(getDisplayNumberOfPages(0), 0); + assert.equal(getDisplayNumberOfPages(20), 0); + assert.equal(getDisplayNumberOfPages(21), 1); + assert.equal(getDisplayNumberOfPages(1221), 61); +}); + +test("splitLocationsIntoDisplayPages slices a background chunk into display pages", () => { + const splitPages = splitLocationsIntoDisplayPages(1, createLocations(45)); + + assert.deepEqual(Object.keys(splitPages), ["10", "11", "12"]); + assert.equal(splitPages[10]?.length, 20); + assert.equal(splitPages[11]?.length, 20); + assert.equal(splitPages[12]?.length, 5); +}); + +test("mergeBackgroundPage keeps display-page bounds derived from resultCount", () => { + const mergedDataset = mergeBackgroundPage( + { + pages: { 0: createLocations(20) }, + loadedBackgroundPages: [], + loadingBackgroundPages: [1], + resultCount: 1221, + numberOfPages: 61, + }, + { + pageNumber: 1, + pageSize: 200, + resultCount: 1221, + locations: createLocations(45), + }, + ); + + assert.equal(mergedDataset.numberOfPages, 61); + assert.deepEqual(mergedDataset.loadedBackgroundPages, [1]); + assert.deepEqual(mergedDataset.loadingBackgroundPages, []); + assert.equal(mergedDataset.pages[10]?.length, 20); + assert.equal(mergedDataset.pages[12]?.length, 5); +}); + +test("getTotalBackgroundPages converts display-page bounds to background chunks", () => { + assert.equal(getTotalBackgroundPages(0), 1); + assert.equal(getTotalBackgroundPages(9), 1); + assert.equal(getTotalBackgroundPages(10), 2); + assert.equal(getTotalBackgroundPages(61), 7); +}); + +test("getVisibleLocationsForPage falls back to the last resolved page while loading", () => { + const firstPage = createLocations(20); + const fallbackPage = createLocations(5); + + assert.equal( + getVisibleLocationsForPage({ + pages: { 0: firstPage, 3: fallbackPage }, + currentPage: 3, + lastResolvedPage: 0, + }), + fallbackPage, + ); + + assert.equal( + getVisibleLocationsForPage({ + pages: { 0: firstPage, 3: fallbackPage }, + currentPage: 5, + lastResolvedPage: 0, + }), + firstPage, + ); +}); diff --git a/src/components/locations-pagination-cache.ts b/src/components/locations-pagination-cache.ts new file mode 100644 index 00000000..10cad8ef --- /dev/null +++ b/src/components/locations-pagination-cache.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import { PAGE_PARAM, SearchParams, YourPeerLegacyLocationData } from "./common"; + +export const DISPLAY_PAGE_SIZE = 20; +export const BACKGROUND_PAGE_SIZE = 200; +export const DISPLAY_PAGES_PER_BACKGROUND_PAGE = + BACKGROUND_PAGE_SIZE / DISPLAY_PAGE_SIZE; +export const MAX_PARALLEL_BACKGROUND_REQUESTS = 3; + +export interface CachedLocationsDataset { + pages: Record; + loadedBackgroundPages: number[]; + loadingBackgroundPages: number[]; + resultCount: number; + numberOfPages: number; +} + +export interface BackgroundLocationsResponse { + locations: YourPeerLegacyLocationData[]; + pageNumber: number; + pageSize: number; + resultCount: number; +} + +export function getSearchParamEntries(searchParams: SearchParams): string[][] { + return Object.entries(searchParams) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .flatMap(([key, value]): string[][] => { + if (key === PAGE_PARAM || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value + .slice() + .sort((left, right) => left.localeCompare(right)) + .map((item) => [key, item]); + } + + return [[key, value]]; + }); +} + +export function getDisplayNumberOfPages(resultCount: number): number { + if (!Number.isFinite(resultCount) || resultCount <= 0) { + return 0; + } + + return Math.max(0, Math.ceil(resultCount / DISPLAY_PAGE_SIZE) - 1); +} + +export function splitLocationsIntoDisplayPages( + backgroundPageNumber: number, + locations: YourPeerLegacyLocationData[], +): Record { + const nextPages: Record = {}; + const startingDisplayPage = + backgroundPageNumber * DISPLAY_PAGES_PER_BACKGROUND_PAGE; + + for (let offset = 0; offset < locations.length; offset += DISPLAY_PAGE_SIZE) { + const displayPageNumber = startingDisplayPage + offset / DISPLAY_PAGE_SIZE; + nextPages[displayPageNumber] = locations.slice( + offset, + offset + DISPLAY_PAGE_SIZE, + ); + } + + return nextPages; +} + +export function mergeBackgroundPage( + currentDataset: CachedLocationsDataset, + response: BackgroundLocationsResponse, +): CachedLocationsDataset { + return { + pages: { + ...currentDataset.pages, + ...splitLocationsIntoDisplayPages( + response.pageNumber, + response.locations, + ), + }, + loadedBackgroundPages: Array.from( + new Set(currentDataset.loadedBackgroundPages.concat(response.pageNumber)), + ).sort((left, right) => left - right), + loadingBackgroundPages: currentDataset.loadingBackgroundPages.filter( + (pageNumber) => pageNumber !== response.pageNumber, + ), + resultCount: response.resultCount, + numberOfPages: getDisplayNumberOfPages(response.resultCount), + }; +} + +export function getTotalBackgroundPages(numberOfPages: number): number { + return Math.ceil((numberOfPages + 1) / DISPLAY_PAGES_PER_BACKGROUND_PAGE); +} + +export function getVisibleLocationsForPage({ + pages, + currentPage, + lastResolvedPage, +}: { + pages: Record; + currentPage: number; + lastResolvedPage: number; +}): YourPeerLegacyLocationData[] { + return pages[currentPage] || pages[lastResolvedPage] || []; +} diff --git a/src/components/use-cached-locations-pagination.ts b/src/components/use-cached-locations-pagination.ts index c8c6a22f..f2a6b8ef 100644 --- a/src/components/use-cached-locations-pagination.ts +++ b/src/components/use-cached-locations-pagination.ts @@ -15,28 +15,17 @@ import { YourPeerLegacyLocationData, parsePageParam, } from "./common"; - -const DISPLAY_PAGE_SIZE = 20; -const BACKGROUND_PAGE_SIZE = 200; -const DISPLAY_PAGES_PER_BACKGROUND_PAGE = - BACKGROUND_PAGE_SIZE / DISPLAY_PAGE_SIZE; -const MAX_PARALLEL_BACKGROUND_REQUESTS = 3; - -interface CachedLocationsDataset { - pages: Record; - loadedBackgroundPages: number[]; - loadingBackgroundPages: number[]; - resultCount: number; - numberOfPages: number; -} - -interface BackgroundLocationsResponse { - locations: YourPeerLegacyLocationData[]; - pageNumber: number; - pageSize: number; - resultCount: number; - numberOfPages: number; -} +import { + BACKGROUND_PAGE_SIZE, + CachedLocationsDataset, + BackgroundLocationsResponse, + DISPLAY_PAGES_PER_BACKGROUND_PAGE, + MAX_PARALLEL_BACKGROUND_REQUESTS, + getSearchParamEntries, + getTotalBackgroundPages, + getVisibleLocationsForPage, + mergeBackgroundPage, +} from "./locations-pagination-cache"; interface UseCachedLocationsPaginationArgs { route: string; @@ -48,67 +37,6 @@ interface UseCachedLocationsPaginationArgs { numberOfPages: number; } -function getSearchParamEntries(searchParams: SearchParams): string[][] { - return Object.entries(searchParams) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .flatMap(([key, value]): string[][] => { - if (key === PAGE_PARAM || value === undefined) { - return []; - } - - if (Array.isArray(value)) { - return value - .slice() - .sort((left, right) => left.localeCompare(right)) - .map((item) => [key, item]); - } - - return [[key, value]]; - }); -} - -function splitLocationsIntoDisplayPages( - backgroundPageNumber: number, - locations: YourPeerLegacyLocationData[], -): Record { - const nextPages: Record = {}; - const startingDisplayPage = - backgroundPageNumber * DISPLAY_PAGES_PER_BACKGROUND_PAGE; - - for (let offset = 0; offset < locations.length; offset += DISPLAY_PAGE_SIZE) { - const displayPageNumber = startingDisplayPage + offset / DISPLAY_PAGE_SIZE; - nextPages[displayPageNumber] = locations.slice( - offset, - offset + DISPLAY_PAGE_SIZE, - ); - } - - return nextPages; -} - -function mergeBackgroundPage( - currentDataset: CachedLocationsDataset, - response: BackgroundLocationsResponse, -): CachedLocationsDataset { - return { - pages: { - ...currentDataset.pages, - ...splitLocationsIntoDisplayPages( - response.pageNumber, - response.locations, - ), - }, - loadedBackgroundPages: Array.from( - new Set(currentDataset.loadedBackgroundPages.concat(response.pageNumber)), - ).sort((left, right) => left - right), - loadingBackgroundPages: currentDataset.loadingBackgroundPages.filter( - (pageNumber) => pageNumber !== response.pageNumber, - ), - resultCount: response.resultCount, - numberOfPages: response.numberOfPages, - }; -} - async function fetchBackgroundPage({ route, locationSlugOrPersonalCareSubCategory, @@ -298,12 +226,12 @@ export function useCachedLocationsPagination({ }, [currentPage, currentPageLocations, ensureBackgroundPage]); useEffect(() => { - if (numberOfPages <= 0) { + if (cachedDataset.numberOfPages <= 0) { return; } - const totalBackgroundPages = Math.ceil( - (numberOfPages + 1) / DISPLAY_PAGES_PER_BACKGROUND_PAGE, + const totalBackgroundPages = getTotalBackgroundPages( + cachedDataset.numberOfPages, ); const missingBackgroundPages = Array.from( { length: totalBackgroundPages }, @@ -348,12 +276,15 @@ export function useCachedLocationsPagination({ }, [ cachedDataset.loadedBackgroundPages, cachedDataset.loadingBackgroundPages, + cachedDataset.numberOfPages, ensureBackgroundPage, - numberOfPages, ]); - const visibleLocations = - currentPageLocations || cachedDataset.pages[lastResolvedPage] || []; + const visibleLocations = getVisibleLocationsForPage({ + pages: cachedDataset.pages, + currentPage, + lastResolvedPage, + }); const isPageLoading = !currentPageLocations; const navigateToPage = useCallback( diff --git a/src/lib/locations-pagination-request.test.ts b/src/lib/locations-pagination-request.test.ts new file mode 100644 index 00000000..7606c6a8 --- /dev/null +++ b/src/lib/locations-pagination-request.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseLocationsBackgroundPageSize, + parseNonNegativeInteger, + parsePositiveInteger, +} from "./locations-pagination-request"; + +test("parseNonNegativeInteger accepts zero and rejects negative values", () => { + assert.equal(parseNonNegativeInteger("0", 7), 0); + assert.equal(parseNonNegativeInteger("15", 7), 15); + assert.equal(parseNonNegativeInteger("-1", 7), 7); + assert.equal(parseNonNegativeInteger(null, 7), 7); +}); + +test("parsePositiveInteger requires values greater than zero", () => { + assert.equal(parsePositiveInteger("1", 9), 1); + assert.equal(parsePositiveInteger("0", 9), 9); + assert.equal(parsePositiveInteger("-2", 9), 9); + assert.equal(parsePositiveInteger(null, 9), 9); +}); + +test("parseLocationsBackgroundPageSize clamps malformed and oversized values", () => { + assert.equal(parseLocationsBackgroundPageSize(null), 200); + assert.equal(parseLocationsBackgroundPageSize("0"), 200); + assert.equal(parseLocationsBackgroundPageSize("-5"), 200); + assert.equal(parseLocationsBackgroundPageSize("50"), 50); + assert.equal(parseLocationsBackgroundPageSize("500"), 200); +}); diff --git a/src/lib/locations-pagination-request.ts b/src/lib/locations-pagination-request.ts new file mode 100644 index 00000000..3f3cf87c --- /dev/null +++ b/src/lib/locations-pagination-request.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +export const DEFAULT_BACKGROUND_PAGE_SIZE = 200; +export const MAX_BACKGROUND_PAGE_SIZE = 200; + +export function parseNonNegativeInteger( + rawValue: string | null, + fallbackValue: number, +): number { + if (!rawValue) { + return fallbackValue; + } + + const parsedValue = Number.parseInt(rawValue, 10); + return Number.isFinite(parsedValue) && parsedValue >= 0 + ? parsedValue + : fallbackValue; +} + +export function parsePositiveInteger( + rawValue: string | null, + fallbackValue: number, +): number { + if (!rawValue) { + return fallbackValue; + } + + const parsedValue = Number.parseInt(rawValue, 10); + return Number.isFinite(parsedValue) && parsedValue > 0 + ? parsedValue + : fallbackValue; +} + +export function parseLocationsBackgroundPageSize( + rawValue: string | null, +): number { + return Math.min( + parsePositiveInteger(rawValue, DEFAULT_BACKGROUND_PAGE_SIZE), + MAX_BACKGROUND_PAGE_SIZE, + ); +} From 54fa96c650d2acf1b0ec0b4738f0590f1fde92b7 Mon Sep 17 00:00:00 2001 From: doobneek Date: Mon, 9 Mar 2026 19:11:55 -0400 Subject: [PATCH 3/4] Bound background page requests and test URL sync --- package.json | 2 +- src/app/api/locations-pagination/route.ts | 13 +++-- src/components/locations-pagination-cache.ts | 50 +++++++++++++++++ .../locations-pagination-navigation.test.ts | 53 +++++++++++++++++++ .../use-cached-locations-pagination.ts | 29 ++++------ src/lib/locations-pagination-request.test.ts | 8 +++ src/lib/locations-pagination-request.ts | 8 +++ 7 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 src/components/locations-pagination-navigation.test.ts diff --git a/package.json b/package.json index accc8b84..2e4fd53d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "next start -p $PORT", "lint": "next lint", "check-types": "tsc --noEmit", - "test": "tsx --test src/components/locations-pagination-cache.test.ts src/lib/locations-pagination-request.test.ts", + "test": "tsx --test src/components/locations-pagination-cache.test.ts src/components/locations-pagination-navigation.test.ts src/lib/locations-pagination-request.test.ts", "format": "prettier . --write", "check-format": "prettier --check .", "all-checks": "npm run check-format && npm run check-types && npm run lint", diff --git a/src/app/api/locations-pagination/route.ts b/src/app/api/locations-pagination/route.ts index d4ee982a..5a43cec6 100644 --- a/src/app/api/locations-pagination/route.ts +++ b/src/app/api/locations-pagination/route.ts @@ -23,8 +23,8 @@ import { map_gogetta_to_yourpeer, } from "@/components/streetlives-api-service"; import { + parseLocationsBackgroundPageNumber, parseLocationsBackgroundPageSize, - parseNonNegativeInteger, } from "@/lib/locations-pagination-request"; function toSearchParamsObject( @@ -78,10 +78,17 @@ export async function GET(request: NextRequest) { } : { route }; - const page = parseNonNegativeInteger( + const page = parseLocationsBackgroundPageNumber( requestUrl.searchParams.get("pageNumber"), - 0, ); + if (page === null) { + return NextResponse.json( + { + error: "pageNumber exceeds the supported background pagination range.", + }, + { status: 400 }, + ); + } const pageSize = parseLocationsBackgroundPageSize( requestUrl.searchParams.get("pageSize"), ); diff --git a/src/components/locations-pagination-cache.ts b/src/components/locations-pagination-cache.ts index 10cad8ef..a50211b8 100644 --- a/src/components/locations-pagination-cache.ts +++ b/src/components/locations-pagination-cache.ts @@ -111,3 +111,53 @@ export function getVisibleLocationsForPage({ }): YourPeerLegacyLocationData[] { return pages[currentPage] || pages[lastResolvedPage] || []; } + +export function getPaginationUrl({ + pathname, + searchParams, + pageNumber, +}: { + pathname: string; + searchParams: Iterable<[string, string]>; + pageNumber: number; +}): string { + const nextSearchParams = new URLSearchParams(Array.from(searchParams)); + + if (pageNumber > 0) { + nextSearchParams.set(PAGE_PARAM, (pageNumber + 1).toString()); + } else { + nextSearchParams.delete(PAGE_PARAM); + } + + return `${pathname}${ + nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "" + }`; +} + +export function applyPaginationNavigation({ + pathname, + searchParams, + pageNumber, + pushState, + locationsContainer, +}: { + pathname: string; + searchParams: Iterable<[string, string]>; + pageNumber: number; + pushState: (url: string) => void; + locationsContainer?: { scrollTop: number } | null; +}): string { + const nextUrl = getPaginationUrl({ + pathname, + searchParams, + pageNumber, + }); + + pushState(nextUrl); + + if (locationsContainer) { + locationsContainer.scrollTop = 0; + } + + return nextUrl; +} diff --git a/src/components/locations-pagination-navigation.test.ts b/src/components/locations-pagination-navigation.test.ts new file mode 100644 index 00000000..1deacc86 --- /dev/null +++ b/src/components/locations-pagination-navigation.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2024 Streetlives, Inc. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + applyPaginationNavigation, + getPaginationUrl, +} from "./locations-pagination-cache"; + +test("getPaginationUrl preserves existing filters and updates the display page", () => { + const searchParams = new URLSearchParams("search=&sortBy=nearby&page=6"); + + assert.equal( + getPaginationUrl({ + pathname: "/locations", + searchParams: searchParams.entries(), + pageNumber: 6, + }), + "/locations?search=&sortBy=nearby&page=7", + ); + + assert.equal( + getPaginationUrl({ + pathname: "/locations", + searchParams: searchParams.entries(), + pageNumber: 0, + }), + "/locations?search=&sortBy=nearby", + ); +}); + +test("applyPaginationNavigation pushes the new URL and resets the list scroll position", () => { + const pushedUrls: string[] = []; + const locationsContainer = { scrollTop: 125 }; + + const nextUrl = applyPaginationNavigation({ + pathname: "/locations", + searchParams: new URLSearchParams("sortBy=nearby&page=6").entries(), + pageNumber: 7, + pushState: (url) => { + pushedUrls.push(url); + }, + locationsContainer, + }); + + assert.equal(nextUrl, "/locations?sortBy=nearby&page=8"); + assert.deepEqual(pushedUrls, ["/locations?sortBy=nearby&page=8"]); + assert.equal(locationsContainer.scrollTop, 0); +}); diff --git a/src/components/use-cached-locations-pagination.ts b/src/components/use-cached-locations-pagination.ts index f2a6b8ef..74a49f06 100644 --- a/src/components/use-cached-locations-pagination.ts +++ b/src/components/use-cached-locations-pagination.ts @@ -21,6 +21,7 @@ import { BackgroundLocationsResponse, DISPLAY_PAGES_PER_BACKGROUND_PAGE, MAX_PARALLEL_BACKGROUND_REQUESTS, + applyPaginationNavigation, getSearchParamEntries, getTotalBackgroundPages, getVisibleLocationsForPage, @@ -289,25 +290,15 @@ export function useCachedLocationsPagination({ const navigateToPage = useCallback( (pageNumber: number) => { - const nextSearchParams = new URLSearchParams( - liveSearchParams ? Array.from(liveSearchParams.entries()) : [], - ); - - if (pageNumber > 0) { - nextSearchParams.set(PAGE_PARAM, (pageNumber + 1).toString()); - } else { - nextSearchParams.delete(PAGE_PARAM); - } - - const nextUrl = `${window.location.pathname}${ - nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "" - }`; - window.history.pushState(null, "", nextUrl); - - const locationsContainer = document.getElementById("locations_container"); - if (locationsContainer) { - locationsContainer.scrollTop = 0; - } + applyPaginationNavigation({ + pathname: window.location.pathname, + searchParams: liveSearchParams ? liveSearchParams.entries() : [], + pageNumber, + pushState: (nextUrl) => { + window.history.pushState(null, "", nextUrl); + }, + locationsContainer: document.getElementById("locations_container"), + }); }, [liveSearchParams], ); diff --git a/src/lib/locations-pagination-request.test.ts b/src/lib/locations-pagination-request.test.ts index 7606c6a8..6187cac3 100644 --- a/src/lib/locations-pagination-request.test.ts +++ b/src/lib/locations-pagination-request.test.ts @@ -7,6 +7,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + parseLocationsBackgroundPageNumber, parseLocationsBackgroundPageSize, parseNonNegativeInteger, parsePositiveInteger, @@ -33,3 +34,10 @@ test("parseLocationsBackgroundPageSize clamps malformed and oversized values", ( assert.equal(parseLocationsBackgroundPageSize("50"), 50); assert.equal(parseLocationsBackgroundPageSize("500"), 200); }); + +test("parseLocationsBackgroundPageNumber rejects page numbers above the supported cap", () => { + assert.equal(parseLocationsBackgroundPageNumber(null), 0); + assert.equal(parseLocationsBackgroundPageNumber("0"), 0); + assert.equal(parseLocationsBackgroundPageNumber("100"), 100); + assert.equal(parseLocationsBackgroundPageNumber("101"), null); +}); diff --git a/src/lib/locations-pagination-request.ts b/src/lib/locations-pagination-request.ts index 3f3cf87c..54dee629 100644 --- a/src/lib/locations-pagination-request.ts +++ b/src/lib/locations-pagination-request.ts @@ -6,6 +6,7 @@ export const DEFAULT_BACKGROUND_PAGE_SIZE = 200; export const MAX_BACKGROUND_PAGE_SIZE = 200; +export const MAX_BACKGROUND_PAGE_NUMBER = 100; export function parseNonNegativeInteger( rawValue: string | null, @@ -43,3 +44,10 @@ export function parseLocationsBackgroundPageSize( MAX_BACKGROUND_PAGE_SIZE, ); } + +export function parseLocationsBackgroundPageNumber( + rawValue: string | null, +): number | null { + const pageNumber = parseNonNegativeInteger(rawValue, 0); + return pageNumber <= MAX_BACKGROUND_PAGE_NUMBER ? pageNumber : null; +} From fe270cabc5e9416a14264d2bcfee5221f4c88ef7 Mon Sep 17 00:00:00 2001 From: doobneek Date: Mon, 9 Mar 2026 21:37:21 -0400 Subject: [PATCH 4/4] Handle local pagination state explicitly --- src/components/locations-container-pager.tsx | 41 +++++++++--- src/components/locations-container.tsx | 1 + .../locations-pagination-cache.test.ts | 26 ++++++++ src/components/locations-pagination-cache.ts | 52 ++++++++++++++- .../locations-pagination-navigation.test.ts | 32 ++++++++++ .../use-cached-locations-pagination.ts | 64 ++++++++++++++----- 6 files changed, 186 insertions(+), 30 deletions(-) diff --git a/src/components/locations-container-pager.tsx b/src/components/locations-container-pager.tsx index a18e09dc..cb5e671b 100644 --- a/src/components/locations-container-pager.tsx +++ b/src/components/locations-container-pager.tsx @@ -6,12 +6,12 @@ "use client"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; +import { SearchParams } from "./common"; import { - getFirstPageHref, - getLastPageHref, - getUrlToNextOrPreviousPage, -} from "./navigation"; + getPaginationUrl, + getSearchParamEntries, +} from "./locations-pagination-cache"; import { TranslatableText } from "./translatable-text"; import { ChevronLeft, @@ -22,30 +22,51 @@ import { import React from "react"; export function LocationsContainerPager({ + searchParams, numberOfPages, currentPage, onPageChange, }: { + searchParams: SearchParams; numberOfPages: number; currentPage: number; onPageChange?: (pageNumber: number) => void; }) { const pathname = usePathname(); - const searchParams = useSearchParams(); + const baseSearchParams = React.useMemo( + () => getSearchParamEntries(searchParams), + [searchParams], + ); const hasPreviousPage = currentPage > 0; const hasNextPage = currentPage < numberOfPages; const firstPageHref = hasPreviousPage - ? getFirstPageHref(pathname, searchParams) + ? getPaginationUrl({ + pathname, + searchParams: baseSearchParams, + pageNumber: 0, + }) : undefined; const previousPageHref = hasPreviousPage - ? getUrlToNextOrPreviousPage(pathname, searchParams, false) + ? getPaginationUrl({ + pathname, + searchParams: baseSearchParams, + pageNumber: currentPage - 1, + }) : undefined; const nextPageHref = hasNextPage - ? getUrlToNextOrPreviousPage(pathname, searchParams, true) + ? getPaginationUrl({ + pathname, + searchParams: baseSearchParams, + pageNumber: currentPage + 1, + }) : undefined; const lastPageHref = hasNextPage - ? getLastPageHref(pathname, searchParams, numberOfPages) + ? getPaginationUrl({ + pathname, + searchParams: baseSearchParams, + pageNumber: numberOfPages, + }) : undefined; function handlePageChange( diff --git a/src/components/locations-container.tsx b/src/components/locations-container.tsx index 32249a94..f67de2a7 100644 --- a/src/components/locations-container.tsx +++ b/src/components/locations-container.tsx @@ -342,6 +342,7 @@ export default function LocationsContainer({ { + assert.equal(getSupportedBackgroundPageCount(61), 7); + assert.equal( + getSupportedBackgroundPageCount(5000), + MAX_SUPPORTED_BACKGROUND_PAGE_COUNT, + ); +}); + +test("getBackgroundPageNumberForDisplayPage rejects unsupported display pages", () => { + assert.equal(getBackgroundPageNumberForDisplayPage(0), 0); + assert.equal(getBackgroundPageNumberForDisplayPage(1009), 100); + assert.equal(getBackgroundPageNumberForDisplayPage(1010), null); +}); + +test("canServeDisplayPageLocally falls back once a page exceeds the cacheable range", () => { + assert.equal(canServeDisplayPageLocally({}, 1010), false); + assert.equal( + canServeDisplayPageLocally({ 1010: createLocations(1) }, 1010), + true, + ); +}); + test("getVisibleLocationsForPage falls back to the last resolved page while loading", () => { const firstPage = createLocations(20); const fallbackPage = createLocations(5); diff --git a/src/components/locations-pagination-cache.ts b/src/components/locations-pagination-cache.ts index a50211b8..1f25e697 100644 --- a/src/components/locations-pagination-cache.ts +++ b/src/components/locations-pagination-cache.ts @@ -4,13 +4,21 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import { PAGE_PARAM, SearchParams, YourPeerLegacyLocationData } from "./common"; +import { + PAGE_PARAM, + SearchParams, + YourPeerLegacyLocationData, + parsePageParam, +} from "./common"; +import { MAX_BACKGROUND_PAGE_NUMBER } from "../lib/locations-pagination-request"; export const DISPLAY_PAGE_SIZE = 20; export const BACKGROUND_PAGE_SIZE = 200; export const DISPLAY_PAGES_PER_BACKGROUND_PAGE = BACKGROUND_PAGE_SIZE / DISPLAY_PAGE_SIZE; export const MAX_PARALLEL_BACKGROUND_REQUESTS = 3; +export const MAX_SUPPORTED_BACKGROUND_PAGE_COUNT = + MAX_BACKGROUND_PAGE_NUMBER + 1; export interface CachedLocationsDataset { pages: Record; @@ -27,10 +35,12 @@ export interface BackgroundLocationsResponse { resultCount: number; } -export function getSearchParamEntries(searchParams: SearchParams): string[][] { +export function getSearchParamEntries( + searchParams: SearchParams, +): [string, string][] { return Object.entries(searchParams) .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - .flatMap(([key, value]): string[][] => { + .flatMap(([key, value]): [string, string][] => { if (key === PAGE_PARAM || value === undefined) { return []; } @@ -100,6 +110,35 @@ export function getTotalBackgroundPages(numberOfPages: number): number { return Math.ceil((numberOfPages + 1) / DISPLAY_PAGES_PER_BACKGROUND_PAGE); } +export function getSupportedBackgroundPageCount(numberOfPages: number): number { + return Math.min( + getTotalBackgroundPages(numberOfPages), + MAX_SUPPORTED_BACKGROUND_PAGE_COUNT, + ); +} + +export function getBackgroundPageNumberForDisplayPage( + displayPageNumber: number, +): number | null { + const backgroundPageNumber = Math.floor( + Math.max(displayPageNumber, 0) / DISPLAY_PAGES_PER_BACKGROUND_PAGE, + ); + + return backgroundPageNumber <= MAX_BACKGROUND_PAGE_NUMBER + ? backgroundPageNumber + : null; +} + +export function canServeDisplayPageLocally( + pages: Record, + displayPageNumber: number, +): boolean { + return ( + Boolean(pages[displayPageNumber]) || + getBackgroundPageNumberForDisplayPage(displayPageNumber) !== null + ); +} + export function getVisibleLocationsForPage({ pages, currentPage, @@ -134,6 +173,13 @@ export function getPaginationUrl({ }`; } +export function getPaginationPageFromSearch(search: string): number { + const searchParams = new URLSearchParams( + search.startsWith("?") ? search.slice(1) : search, + ); + return parsePageParam(searchParams.get(PAGE_PARAM)); +} + export function applyPaginationNavigation({ pathname, searchParams, diff --git a/src/components/locations-pagination-navigation.test.ts b/src/components/locations-pagination-navigation.test.ts index 1deacc86..37795944 100644 --- a/src/components/locations-pagination-navigation.test.ts +++ b/src/components/locations-pagination-navigation.test.ts @@ -8,6 +8,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { applyPaginationNavigation, + getPaginationPageFromSearch, getPaginationUrl, } from "./locations-pagination-cache"; @@ -51,3 +52,34 @@ test("applyPaginationNavigation pushes the new URL and resets the list scroll po assert.deepEqual(pushedUrls, ["/locations?sortBy=nearby&page=8"]); assert.equal(locationsContainer.scrollTop, 0); }); + +test("pagination URLs round-trip into page state for back and forward navigation", () => { + const forwardUrl = getPaginationUrl({ + pathname: "/locations", + searchParams: new URLSearchParams("search=&sortBy=nearby").entries(), + pageNumber: 7, + }); + + assert.equal( + getPaginationPageFromSearch( + new URL(forwardUrl, "https://yourpeer.nyc").search, + ), + 7, + ); + + const backwardUrl = getPaginationUrl({ + pathname: "/locations", + searchParams: new URL( + forwardUrl, + "https://yourpeer.nyc", + ).searchParams.entries(), + pageNumber: 0, + }); + + assert.equal( + getPaginationPageFromSearch( + new URL(backwardUrl, "https://yourpeer.nyc").search, + ), + 0, + ); +}); diff --git a/src/components/use-cached-locations-pagination.ts b/src/components/use-cached-locations-pagination.ts index 74a49f06..d2bff330 100644 --- a/src/components/use-cached-locations-pagination.ts +++ b/src/components/use-cached-locations-pagination.ts @@ -8,22 +8,20 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { - PAGE_PARAM, - SearchParams, - YourPeerLegacyLocationData, - parsePageParam, -} from "./common"; +import { SearchParams, YourPeerLegacyLocationData } from "./common"; import { BACKGROUND_PAGE_SIZE, CachedLocationsDataset, BackgroundLocationsResponse, - DISPLAY_PAGES_PER_BACKGROUND_PAGE, + MAX_SUPPORTED_BACKGROUND_PAGE_COUNT, MAX_PARALLEL_BACKGROUND_REQUESTS, applyPaginationNavigation, + canServeDisplayPageLocally, + getBackgroundPageNumberForDisplayPage, + getPaginationPageFromSearch, + getPaginationUrl, getSearchParamEntries, - getTotalBackgroundPages, + getSupportedBackgroundPageCount, getVisibleLocationsForPage, mergeBackgroundPage, } from "./locations-pagination-cache"; @@ -82,8 +80,8 @@ export function useCachedLocationsPagination({ resultCount, numberOfPages, }: UseCachedLocationsPaginationArgs) { - const liveSearchParams = useSearchParams(); const queryClient = useQueryClient(); + const [currentPage, setCurrentPage] = useState(initialPage); const [lastResolvedPage, setLastResolvedPage] = useState(initialPage); const serializedSearchParams = useMemo( @@ -123,9 +121,21 @@ export function useCachedLocationsPagination({ }); useEffect(() => { + setCurrentPage(initialPage); setLastResolvedPage(initialPage); }, [initialPage, queryKey]); + useEffect(() => { + function handlePopState() { + setCurrentPage(getPaginationPageFromSearch(window.location.search)); + } + + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + useEffect(() => { queryClient.setQueryData(queryKey, (previous) => { const dataset = previous || initialDataset; @@ -151,6 +161,10 @@ export function useCachedLocationsPagination({ const ensureBackgroundPage = useCallback( async (pageNumber: number) => { + if (pageNumber < 0 || pageNumber >= MAX_SUPPORTED_BACKGROUND_PAGE_COUNT) { + return; + } + let shouldFetch = false; queryClient.setQueryData(queryKey, (previous) => { @@ -211,7 +225,6 @@ export function useCachedLocationsPagination({ ], ); - const currentPage = parsePageParam(liveSearchParams?.get(PAGE_PARAM)); const currentPageLocations = cachedDataset.pages[currentPage]; useEffect(() => { @@ -220,9 +233,12 @@ export function useCachedLocationsPagination({ return; } - const backgroundPageNumber = Math.floor( - currentPage / DISPLAY_PAGES_PER_BACKGROUND_PAGE, - ); + const backgroundPageNumber = + getBackgroundPageNumberForDisplayPage(currentPage); + if (backgroundPageNumber === null) { + return; + } + void ensureBackgroundPage(backgroundPageNumber); }, [currentPage, currentPageLocations, ensureBackgroundPage]); @@ -231,7 +247,7 @@ export function useCachedLocationsPagination({ return; } - const totalBackgroundPages = getTotalBackgroundPages( + const totalBackgroundPages = getSupportedBackgroundPageCount( cachedDataset.numberOfPages, ); const missingBackgroundPages = Array.from( @@ -290,17 +306,31 @@ export function useCachedLocationsPagination({ const navigateToPage = useCallback( (pageNumber: number) => { + const currentSearchParams = new URLSearchParams(window.location.search); + + if (!canServeDisplayPageLocally(cachedDataset.pages, pageNumber)) { + window.location.assign( + getPaginationUrl({ + pathname: window.location.pathname, + searchParams: currentSearchParams.entries(), + pageNumber, + }), + ); + return; + } + applyPaginationNavigation({ pathname: window.location.pathname, - searchParams: liveSearchParams ? liveSearchParams.entries() : [], + searchParams: currentSearchParams.entries(), pageNumber, pushState: (nextUrl) => { window.history.pushState(null, "", nextUrl); }, locationsContainer: document.getElementById("locations_container"), }); + setCurrentPage(pageNumber); }, - [liveSearchParams], + [cachedDataset.pages], ); return {