From 062c1d7e896d4e05f271dede6ed8002ae66c545c Mon Sep 17 00:00:00 2001 From: Jessica Rynkar <67977755+jessrynkar@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:44:41 +0100 Subject: [PATCH] fix: pagination returning duplicate results when timestamps: false (#13920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What Fixes a bug where collection docs paginate incorrectly when `timestamps: false` is set — the same docs were appearing across multiple pages. ### Why The `find` query sanitizes the `sort` parameter. - With `timestamps: true`, it defaults to `createdAt` - With `timestamps: false`, it falls back to `_id` That logic is correct, but in `find.ts` we always passed `timestamps: true`, ignoring the collection config. With the right sort applied, pagination works as expected. ### How `find.ts` now passes `collectionConfig.timestamps` to `buildSortParam()`, ensuring the correct sort field is chosen. --- Fixes #13888 --- packages/db-mongodb/src/find.ts | 2 +- test/admin/collections/NoTimestamps.ts | 14 +++++++++++ test/admin/config.ts | 2 ++ test/admin/e2e/list-view/e2e.spec.ts | 33 +++++++++++++++++++++++++- test/admin/payload-types.ts | 21 ++++++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 test/admin/collections/NoTimestamps.ts diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index 6f1124e50..907bd7448 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -54,7 +54,7 @@ export const find: Find = async function find( locale, sort: sortArg || collectionConfig.defaultSort, sortAggregation, - timestamps: true, + timestamps: collectionConfig.timestamps || false, }) } diff --git a/test/admin/collections/NoTimestamps.ts b/test/admin/collections/NoTimestamps.ts new file mode 100644 index 000000000..f59356fe4 --- /dev/null +++ b/test/admin/collections/NoTimestamps.ts @@ -0,0 +1,14 @@ +import type { CollectionConfig } from 'payload' + +export const noTimestampsSlug = 'no-timestamps' + +export const NoTimestampsCollection: CollectionConfig = { + slug: noTimestampsSlug, + timestamps: false, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 34ead5ea9..9d161712b 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -21,6 +21,7 @@ import { CollectionHidden } from './collections/Hidden.js' import { ListDrawer } from './collections/ListDrawer.js' import { ListViewSelectAPI } from './collections/ListViewSelectAPI/index.js' import { CollectionNoApiView } from './collections/NoApiView.js' +import { NoTimestampsCollection } from './collections/NoTimestamps.js' import { CollectionNotInView } from './collections/NotInView.js' import { Placeholder } from './collections/Placeholder.js' import { Posts } from './collections/Posts.js' @@ -191,6 +192,7 @@ export default buildConfigWithDefaults({ CustomListDrawer, ListViewSelectAPI, Virtuals, + NoTimestampsCollection, ], globals: [ GlobalHidden, diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 620d1993f..dc10271f3 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -35,8 +35,8 @@ const description = 'Description' let payload: PayloadTestSDK import { listViewSelectAPISlug } from 'admin/collections/ListViewSelectAPI/index.js' +import { noTimestampsSlug } from 'admin/collections/NoTimestamps.js' import { devUser } from 'credentials.js' -import { getRowByCellValueAndAssert } from 'helpers/e2e/getRowByCellValueAndAssert.js' import { openListColumns, reorderColumns, @@ -45,6 +45,7 @@ import { waitForColumnInURL, } from 'helpers/e2e/columns/index.js' import { addListFilter, openListFilters } from 'helpers/e2e/filters/index.js' +import { getRowByCellValueAndAssert } from 'helpers/e2e/getRowByCellValueAndAssert.js' import { goToNextPage, goToPreviousPage } from 'helpers/e2e/goToNextPage.js' import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js' import { deletePreferences } from 'helpers/e2e/preferences.js' @@ -76,6 +77,7 @@ describe('List View', () => { let disableBulkEditUrl: AdminUrlUtil let user: any let virtualsUrl: AdminUrlUtil + let noTimestampsUrl: AdminUrlUtil let serverURL: string let adminRoutes: ReturnType @@ -101,6 +103,7 @@ describe('List View', () => { placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug) disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit') virtualsUrl = new AdminUrlUtil(serverURL, virtualsSlug) + noTimestampsUrl = new AdminUrlUtil(serverURL, noTimestampsSlug) const context = await browser.newContext() page = await context.newPage() initPageConsoleErrorCatch(page) @@ -1568,6 +1571,24 @@ describe('List View', () => { await expect(page.locator('.per-page')).toContainText('Per Page: 15') // ensure this hasn't changed await expect(page.locator('.page-controls__page-info')).toHaveText('16-16 of 16') }) + + test('should paginate when timestamps are disabled', async () => { + await mapAsync([...Array(6)], async () => { + await createNoTimestampPost() + }) + + await page.goto(noTimestampsUrl.list) + + await page.locator('.per-page .popup-button').click() + await page.getByRole('button', { name: '5', exact: true }).click() + await page.waitForURL(/limit=5/) + + const firstPageIds = await page.locator('.cell-id').allInnerTexts() + await goToNextPage(page) + const secondPageIds = await page.locator('.cell-id').allInnerTexts() + + expect(firstPageIds).not.toContain(secondPageIds[0]) + }) }) // TODO: Troubleshoot flaky suite @@ -1918,6 +1939,16 @@ async function createGeo(overrides?: Partial): Promise { }) as unknown as Promise } +async function createNoTimestampPost(overrides?: Partial): Promise { + return payload.create({ + collection: noTimestampsSlug, + data: { + title, + ...overrides, + }, + }) as unknown as Promise +} + async function createArray() { return payload.create({ collection: arrayCollectionSlug, diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index b77ea07cc..9b7cefab1 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -96,6 +96,7 @@ export interface Config { 'custom-list-drawer': CustomListDrawer; 'list-view-select-api': ListViewSelectApi; virtuals: Virtual; + 'no-timestamps': NoTimestamp; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -131,6 +132,7 @@ export interface Config { 'custom-list-drawer': CustomListDrawerSelect | CustomListDrawerSelect; 'list-view-select-api': ListViewSelectApiSelect | ListViewSelectApiSelect; virtuals: VirtualsSelect | VirtualsSelect; + 'no-timestamps': NoTimestampsSelect | NoTimestampsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -612,6 +614,14 @@ export interface Virtual { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "no-timestamps". + */ +export interface NoTimestamp { + id: string; + title?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -734,6 +744,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'virtuals'; value: string | Virtual; + } | null) + | ({ + relationTo: 'no-timestamps'; + value: string | NoTimestamp; } | null); globalSlug?: string | null; user: { @@ -1175,6 +1189,13 @@ export interface VirtualsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "no-timestamps_select". + */ +export interface NoTimestampsSelect { + title?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select".