From 818547f3be59440cab8ea9c050b5cf0cd73e2bc2 Mon Sep 17 00:00:00 2001 From: Hiago Lucas Cardeal de Melo Silva Date: Thu, 11 Jun 2026 09:15:00 -0300 Subject: [PATCH 1/4] test(zipcode): assert useFetchMore resets to page 1 on external page clear Co-authored-by: Cursor --- react/__tests__/useFetchMore.test.js | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 react/__tests__/useFetchMore.test.js diff --git a/react/__tests__/useFetchMore.test.js b/react/__tests__/useFetchMore.test.js new file mode 100644 index 000000000..b8f16d4d4 --- /dev/null +++ b/react/__tests__/useFetchMore.test.js @@ -0,0 +1,91 @@ +import { renderHook, act } from '@testing-library/react-hooks' + +// eslint-disable-next-line jest/no-mocks-import +import { useRuntime } from '../__mocks__/vtex.render-runtime' +import { useFetchMore } from '../hooks/useFetchMore' + +jest.mock('vtex.search-page-context/SearchPageContext', () => ({ + useSearchPageState: () => ({ isFetchingMore: false }), + useSearchPageStateDispatch: () => jest.fn(), +})) + +jest.mock('../hooks/useSearchState', () => ({ + __esModule: true, + default: () => ({ + fuzzy: undefined, + operator: undefined, + searchState: undefined, + }), +})) + +const mockUseRuntime = useRuntime +const mockSetQuery = jest.fn() + +const setRuntimePage = page => { + mockUseRuntime.mockImplementation(() => ({ + setQuery: mockSetQuery, + query: page === undefined ? {} : { page: String(page) }, + })) +} + +const baseProps = { + maxItemsPerPage: 24, + fetchMore: jest.fn(), + products: [{ id: 1 }], + queryData: { query: 'q', map: 'm', orderBy: 'o', priceRange: undefined }, +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +test('resets pagination to page 1 when the runtime page is externally cleared', () => { + setRuntimePage(5) + + const { result, rerender } = renderHook(props => useFetchMore(props), { + initialProps: { ...baseProps, page: 5 }, + }) + + // Seeded from the URL: shopper is on page 5, so "load more" would go to page 6. + expect(result.current.nextPage).toBe(6) + + // A location change clears the `page` query param through render-runtime. + setRuntimePage(undefined) + act(() => { + rerender({ ...baseProps, page: 5 }) + }) + + // The reducer must snap back to the first page. + expect(result.current.nextPage).toBe(2) + expect(result.current.from).toBe(0) + expect(result.current.to).toBe(23) +}) + +test('does not reset when the shopper advances the page (load more)', () => { + setRuntimePage(5) + + const { result, rerender } = renderHook(props => useFetchMore(props), { + initialProps: { ...baseProps, page: 5 }, + }) + + expect(result.current.nextPage).toBe(6) + + // The shopper clicks "load more": the URL page advances in lockstep — no reset. + setRuntimePage(6) + act(() => { + rerender({ ...baseProps, page: 5 }) + }) + + expect(result.current.nextPage).toBe(6) +}) + +test('does not reset on initial mount on the first page', () => { + setRuntimePage(1) + + const { result } = renderHook(props => useFetchMore(props), { + initialProps: { ...baseProps, page: 1 }, + }) + + expect(result.current.nextPage).toBe(2) + expect(result.current.from).toBe(0) +}) From 1a7e051feaa11b46b31026e3e8977445923bf619 Mon Sep 17 00:00:00 2001 From: Hiago Lucas Cardeal de Melo Silva Date: Thu, 11 Jun 2026 09:15:12 -0300 Subject: [PATCH 2/4] feat(zipcode): reset useFetchMore reducer to page 1 on external page clear When vtex.delivery-promise-components clears the `page` query param through render-runtime after a location change, the useFetchMore reducer was left on the stale page. A shopper on page 5 would see page-1 results but the next "load more" fetched page 6. Detect the external transition (`runtimeQuery.page` going absent/1 while the reducer is past page 1) and dispatch RESET so the next "load more" correctly fetches page 2. Shopper- driven "load more"/"fetch previous" and initial mount are unaffected. Co-authored-by: Cursor --- react/hooks/useFetchMore.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/react/hooks/useFetchMore.js b/react/hooks/useFetchMore.js index a5c89f173..e7bf49b12 100644 --- a/react/hooks/useFetchMore.js +++ b/react/hooks/useFetchMore.js @@ -14,6 +14,8 @@ export const FETCH_TYPE = { PREVIOUS: 'previous', } +const DEFAULT_PAGE = 1 + function reducer(state, action) { const { maxItemsPerPage, to, from, rollbackState } = action.args @@ -185,6 +187,31 @@ export const useFetchMore = props => { isFirstRender.current = false }, [maxItemsPerPage, query, map, orderBy, priceRange]) + /* A location/segment change (e.g. vtex.delivery-promise-components updating the + zipcode or pickup point) resets the PLP to the first page by clearing the `page` + query param through render-runtime. The reducer is seeded once at mount and is not + re-read from the URL afterwards, so without this it would keep paginating from the + stale page (a shopper on page 5 would "load more" into page 6). Snap the reducer + back to page 1 only on an *external* transition to the first page while we are past + it — the shopper's own "load more"/"fetch previous" moves `currentPage` in lockstep + with the reducer, so this never fires for normal navigation or on initial mount. */ + const urlPage = runtimeQuery.page ? Number(runtimeQuery.page) : DEFAULT_PAGE + const previousUrlPageRef = useRef(urlPage) + + useEffect(() => { + const wasExternallyResetToFirstPage = + urlPage === DEFAULT_PAGE && previousUrlPageRef.current !== DEFAULT_PAGE + + previousUrlPageRef.current = urlPage + + if (wasExternallyResetToFirstPage && pageState.page !== DEFAULT_PAGE) { + pageDispatch({ type: 'RESET', args: { maxItemsPerPage } }) + } + // pageState.page is read as a guard only; depending on it would re-run this + // effect on every paginate and is unnecessary for the external-reset signal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlPage, maxItemsPerPage]) + const handleFetchMoreNext = async () => { const rollbackState = pageState const from = pageState.to + 1 From 19e9bae72eac7a878aef13104b12b088ba49ad2c Mon Sep 17 00:00:00 2001 From: Hiago Lucas Cardeal de Melo Silva Date: Thu, 11 Jun 2026 09:15:20 -0300 Subject: [PATCH 3/4] test(zipcode): assert useLazyItemsRearm re-arms refill on list shrink Co-authored-by: Cursor --- react/__tests__/useLazyItemsRearm.test.js | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 react/__tests__/useLazyItemsRearm.test.js diff --git a/react/__tests__/useLazyItemsRearm.test.js b/react/__tests__/useLazyItemsRearm.test.js new file mode 100644 index 000000000..633656db6 --- /dev/null +++ b/react/__tests__/useLazyItemsRearm.test.js @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react-hooks' + +import { useLazyItemsRearm } from '../hooks/useLazyItemsRearm' + +const baseProps = { + itemsLimit: 18, + maxItemsPerPage: 24, + from: 0, + shouldLimitItems: true, +} + +test('re-arms the lazy refill when the list shrinks back to the first window', () => { + const setLazyItemsRemaining = jest.fn() + const { rerender } = renderHook(props => useLazyItemsRearm(props), { + initialProps: { ...baseProps, productsCount: 24, setLazyItemsRemaining }, + }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() + + // An external refetch (zipcode change) replaced the cached list with only + // the initial lazy window — items 18..23 must be fetched again. + rerender({ ...baseProps, productsCount: 18, setLazyItemsRemaining }) + + expect(setLazyItemsRemaining).toHaveBeenCalledWith(6) +}) + +test('does not re-arm on initial mount', () => { + const setLazyItemsRemaining = jest.fn() + + renderHook(props => useLazyItemsRearm(props), { + initialProps: { ...baseProps, productsCount: 18, setLazyItemsRemaining }, + }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() +}) + +test('does not re-arm when the list grows (lazy refill or load more)', () => { + const setLazyItemsRemaining = jest.fn() + const { rerender } = renderHook(props => useLazyItemsRearm(props), { + initialProps: { ...baseProps, productsCount: 18, setLazyItemsRemaining }, + }) + + rerender({ ...baseProps, productsCount: 24, setLazyItemsRemaining }) + rerender({ ...baseProps, productsCount: 48, setLazyItemsRemaining }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() +}) + +test('does not re-arm when the list empties', () => { + const setLazyItemsRemaining = jest.fn() + const { rerender } = renderHook(props => useLazyItemsRearm(props), { + initialProps: { ...baseProps, productsCount: 24, setLazyItemsRemaining }, + }) + + rerender({ ...baseProps, productsCount: 0, setLazyItemsRemaining }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() +}) + +test('does not re-arm off the first page window (from > 0)', () => { + const setLazyItemsRemaining = jest.fn() + const { rerender } = renderHook(props => useLazyItemsRearm(props), { + initialProps: { + ...baseProps, + from: 96, + productsCount: 24, + setLazyItemsRemaining, + }, + }) + + rerender({ + ...baseProps, + from: 96, + productsCount: 18, + setLazyItemsRemaining, + }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() +}) + +test('does not re-arm when lazy loading is disabled', () => { + const setLazyItemsRemaining = jest.fn() + const { rerender } = renderHook(props => useLazyItemsRearm(props), { + initialProps: { + ...baseProps, + shouldLimitItems: false, + productsCount: 24, + setLazyItemsRemaining, + }, + }) + + rerender({ + ...baseProps, + shouldLimitItems: false, + productsCount: 18, + setLazyItemsRemaining, + }) + + expect(setLazyItemsRemaining).not.toHaveBeenCalled() +}) From 859c79347b25c8248495065c5cfff6ce1e5e091a Mon Sep 17 00:00:00 2001 From: Hiago Lucas Cardeal de Melo Silva Date: Thu, 11 Jun 2026 09:15:34 -0300 Subject: [PATCH 4/4] feat(zipcode): re-arm lazy-items refill after external soft refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With enableLazySearchQuery on and maxItemsPerPage > 18, SearchQuery fetches the first 18 items and lazily loads items 18..N-1 once. An external refetch (vtex.delivery-promise-components location change) resets the Apollo cache to the initial 18-item window, but lazyItemsRemaining was already consumed at mount — items 18..N-1 would never be fetched again, leaving a permanent gap before the next "load more" page. The new useLazyItemsRearm hook detects the cached list shrinking back to (or below) the initial window while on the first page and resets lazyItemsRemaining, re-triggering the existing 500ms lazy-refill effect. Guards prevent false positives on initial mount, list growth, empty lists, and pages past the first. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++ react/components/SearchQuery.js | 20 ++++++++++ react/hooks/useLazyItemsRearm.ts | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 react/hooks/useLazyItemsRearm.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff04d2328..fdadec3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed + +- `useFetchMore` (gallery "load more"): the in-memory pagination reducer now resets to the first page when the `page` query param is **externally** brought back to page 1 (e.g. `vtex.delivery-promise-components` clearing it through render-runtime after a zipcode / pickup-point change). Previously, because the reducer is seeded once at mount and only resets on `query`/`map`/`orderBy`/`priceRange` changes, a location change left it on the stale page: a shopper on page 5 would see page-1 results but the next "load more" fetched page 6 (and the URL showed `page=6`). The reset fires only on an external transition to the first page while the reducer is past it, so the shopper's own "load more"/"fetch previous" navigation and initial mount are unaffected. +- `SearchQuery` (lazy search query): the lazy-items refill is re-armed when an external refetch (e.g. `vtex.delivery-promise-components` after a zipcode / pickup-point change) replaces the cached product list with only the initial lazy window. Previously, with `enableLazySearchQuery` on and `maxItemsPerPage` above the initial limit (18), `lazyItemsRemaining` was already consumed at mount, so items 19–24 were never fetched again after a soft refresh — leaving a permanent gap before the next "load more" page. The new `useLazyItemsRearm` hook detects the list shrinking back to the initial window (first page only) and re-arms the existing refill effect. + ## [3.147.2] - 2026-06-05 ### Fixed diff --git a/react/components/SearchQuery.js b/react/components/SearchQuery.js index 2085692e2..899be077e 100644 --- a/react/components/SearchQuery.js +++ b/react/components/SearchQuery.js @@ -7,6 +7,7 @@ import productSearchQuery from 'vtex.store-resources/QueryProductSearchV3' import searchMetadataQuery from 'vtex.store-resources/QuerySearchMetadataV2' import { FACETS_RENDER_THRESHOLD } from '../constants/filterConstants' +import { useLazyItemsRearm } from '../hooks/useLazyItemsRearm' import useRedirect from '../hooks/useRedirect' import useSession from '../hooks/useSession' import { @@ -422,6 +423,25 @@ const SearchQuery = ({ variables.hideUnavailableItems, ]) + const lazyProductsCount = + (data && + data.productSearch && + data.productSearch.products && + data.productSearch.products.length) || + 0 + + // An external refetch (e.g. delivery-promise location change) resets the + // cached list back to the initial lazy window; re-arm the refill below so + // items `itemsLimit..maxItemsPerPage - 1` are fetched again. + useLazyItemsRearm({ + productsCount: lazyProductsCount, + itemsLimit, + maxItemsPerPage, + from, + shouldLimitItems, + setLazyItemsRemaining, + }) + useEffect(() => { if (!shouldLimitItems) { return diff --git a/react/hooks/useLazyItemsRearm.ts b/react/hooks/useLazyItemsRearm.ts new file mode 100644 index 000000000..0cf91bfd2 --- /dev/null +++ b/react/hooks/useLazyItemsRearm.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react' + +interface UseLazyItemsRearmArgs { + /** Current number of products held by the productSearch cache entry. */ + productsCount: number + /** Size of the initial lazy window (INITIAL_ITEMS_LIMIT when lazy is on). */ + itemsLimit: number + maxItemsPerPage: number + /** Offset of the first item of the current page (0 on the first page). */ + from: number + /** Whether the lazy search query mode is active. */ + shouldLimitItems: boolean + setLazyItemsRemaining: (remaining: number) => void +} + +/** + * Re-arms the lazy-items refill after an external refetch resets the cached + * product list back to the initial lazy window. + * + * With `enableLazySearchQuery`, `SearchQuery` fetches the first + * `INITIAL_ITEMS_LIMIT` items and lazily refills the rest of the page once. + * When another app (e.g. `vtex.delivery-promise-components` on a location + * change) refetches the observable query, Apollo replaces the cache entry with + * only the initial window — but `lazyItemsRemaining` is already 0, so items + * `itemsLimit..maxItemsPerPage-1` would never be fetched again, leaving a + * permanent gap before the next "load more" page. Detecting the list shrinking + * back to the initial window re-arms the refill. + */ +export const useLazyItemsRearm = ({ + productsCount, + itemsLimit, + maxItemsPerPage, + from, + shouldLimitItems, + setLazyItemsRemaining, +}: UseLazyItemsRearmArgs) => { + const previousCountRef = useRef(productsCount) + + useEffect(() => { + const previousCount = previousCountRef.current + + previousCountRef.current = productsCount + + // Only the first page window is refilled with `from`-relative offsets; + // off page 1 the refill window would not match the refreshed list. + if (!shouldLimitItems || from !== 0) { + return + } + + const shrankBackToInitialWindow = + previousCount > itemsLimit && + productsCount > 0 && + productsCount <= itemsLimit + + if (shrankBackToInitialWindow) { + setLazyItemsRemaining(maxItemsPerPage - itemsLimit) + } + }, [ + productsCount, + itemsLimit, + maxItemsPerPage, + from, + shouldLimitItems, + setLazyItemsRemaining, + ]) +}