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/__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) +}) 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() +}) 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/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 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, + ]) +}