Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡

I'd prefer to make this cleaner, single sentences. And move the "Why?" to the PR description.

- `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
Expand Down
91 changes: 91 additions & 0 deletions react/__tests__/useFetchMore.test.js
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡

Shouldn't this be 7? 🤔

})

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)
})
100 changes: 100 additions & 0 deletions react/__tests__/useLazyItemsRearm.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
20 changes: 20 additions & 0 deletions react/components/SearchQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions react/hooks/useFetchMore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions react/hooks/useLazyItemsRearm.ts
Original file line number Diff line number Diff line change
@@ -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,
])
}
Loading