From 810fd8d8435be5cf2a7a808f1e629f6d2c656bab Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:57:53 -0400 Subject: [PATCH 1/3] fix(plugin-search): reindex documents published in non-default locales --- .../src/utilities/generateReindexHandler.ts | 50 ++++++++++++++--- test/plugin-search/int.spec.ts | 56 ++++++++++++++++++- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index ad6190fef3b..6ae8f5f5559 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -8,6 +8,7 @@ import { initTransaction, killTransaction, } from 'payload' +import { hasLocalizeStatusEnabled } from 'payload/shared' import type { SanitizedSearchPluginConfig } from '../types.js' @@ -93,13 +94,29 @@ export const generateReindexHandler = equals: 'published', }, } + // Localized `_status` tracks published state per-locale; match docs published in any locale. + function buildPublishedWhere(collection: string): Where { + const collectionConfig = payload.collections[collection]?.config + const localeCodes = payload.config.localization ? payload.config.localization.localeCodes : [] + + if (collectionConfig && hasLocalizeStatusEnabled(collectionConfig) && localeCodes.length) { + return { + or: localeCodes.map((localeCode) => ({ + [`_status.${localeCode}`]: { + equals: 'published', + }, + })), + } + } + + return whereStatusPublished + } async function countDocuments(collection: string, drafts?: boolean): Promise { const { totalDocs } = await payload.count({ collection, ...defaultLocalApiProps, - locale: defaultLocale, req: undefined, - where: drafts ? undefined : whereStatusPublished, + where: drafts ? undefined : buildPublishedWhere(collection), }) return totalDocs } @@ -117,7 +134,11 @@ export const generateReindexHandler = async function reindexCollection( collection: string, ): Promise<{ docs: number; docsWithDrafts: number; errors: number }> { - const draftsEnabled = Boolean(payload.collections[collection]?.config.versions?.drafts) + const collectionConfig = payload.collections[collection]?.config + const draftsEnabled = Boolean(collectionConfig?.versions?.drafts) + const localizeStatusEnabled = collectionConfig + ? hasLocalizeStatusEnabled(collectionConfig) + : false const totalDocsWithDrafts = await countDocuments(collection, true) const totalDocs = @@ -134,9 +155,10 @@ export const generateReindexHandler = collection, depth: 0, limit: batchSize, - locale: defaultLocale, + // Fetch all locales so each locale's `_status` is available for gating below + locale: localizeStatusEnabled ? 'all' : defaultLocale, page: i + 1, - where: syncDrafts || !draftsEnabled ? undefined : whereStatusPublished, + where: syncDrafts || !draftsEnabled ? undefined : buildPublishedWhere(collection), ...defaultLocalApiProps, }) @@ -172,14 +194,28 @@ export const generateReindexHandler = continue // Skip this locale } + // Skip locales that aren't published (unless syncing drafts) + let docToSync = doc + if (localizeStatusEnabled) { + const localeStatus = (doc as { _status?: Record })._status?.[ + localeToSync as string + ] + + if (!syncDrafts && localeStatus !== 'published') { + continue + } + + docToSync = { ...doc, _status: localeStatus } + } + // Sync this locale (create first index, then update with other locales accordingly) const operation = firstAllowedLocale ? 'create' : 'update' firstAllowedLocale = false await syncDocAsSearchIndex({ collection, - data: doc, - doc, + data: docToSync, + doc: docToSync, locale: localeToSync, onSyncError: () => operation === 'create' && localErrors++, operation, diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index 2f614517db0..d0babc2d309 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -580,13 +580,13 @@ describe('@payloadcms/plugin-search', () => { await payload.update({ collection: postsSlug, id: postId, - data: { slug: 'post-slug-es' }, + data: { _status: 'published', slug: 'post-slug-es' }, locale: 'es', }) await payload.update({ collection: postsSlug, id: postId, - data: { slug: 'post-slug-de' }, + data: { _status: 'published', slug: 'post-slug-de' }, locale: 'de', }) @@ -762,6 +762,58 @@ describe('@payloadcms/plugin-search', () => { expect(postAfterReindex?.slug).toStrictEqual(postBeforeReindex?.slug) }) + it('should reindex documents published only in a non-default locale when status is localized', async () => { + // Draft in the default locale ('en'), published only in a non-default locale ('de') + const post = await payload.create({ + collection: postsSlug, + locale: 'en', + data: { + title: 'Published in DE only', + _status: 'draft', + slug: 'de-only-en', + }, + }) + + await payload.update({ + collection: postsSlug, + id: post.id, + locale: 'de', + data: { + _status: 'published', + slug: 'de-only-de', + }, + }) + + const endpointRes = await restClient.POST(`/search/reindex`, { + body: JSON.stringify({ + collections: [postsSlug], + }), + headers: { + Authorization: `JWT ${token}`, + }, + }) + + expect(endpointRes.status).toBe(200) + + const { docs } = await payload.find({ + collection: 'search', + depth: 0, + pagination: false, + locale: 'all', + where: { + doc: { + equals: { + relationTo: postsSlug, + value: post.id, + }, + }, + }, + }) + + // The document is published in 'de', so it must be indexed even though 'en' is a draft + expect(docs).toHaveLength(1) + }) + it('should sync trashed documents correctly with search plugin', async () => { // Create a published post const publishedPost = await payload.create({ From d32af41bae2f1d166a63725e02201e6a2267b487 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:48:20 -0400 Subject: [PATCH 2/3] chore: scope reindex sync to published locale to avoid all-locales field maps --- .../src/utilities/generateReindexHandler.ts | 12 +++++++++++- test/plugin-search/int.spec.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index 6ae8f5f5559..e5c8f245b58 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -205,7 +205,17 @@ export const generateReindexHandler = continue } - docToSync = { ...doc, _status: localeStatus } + // The batch was fetched with `locale: 'all'`, so localized fields are locale maps. + // Re-fetch scoped to this locale so `syncDocAsSearchIndex` receives per-locale scalars. + docToSync = + (await payload.findByID({ + id: doc.id, + collection, + depth: 0, + disableErrors: true, + locale: localeToSync, + ...defaultLocalApiProps, + })) ?? doc } // Sync this locale (create first index, then update with other locales accordingly) diff --git a/test/plugin-search/int.spec.ts b/test/plugin-search/int.spec.ts index d0babc2d309..64e48a75c90 100644 --- a/test/plugin-search/int.spec.ts +++ b/test/plugin-search/int.spec.ts @@ -812,6 +812,16 @@ describe('@payloadcms/plugin-search', () => { // The document is published in 'de', so it must be indexed even though 'en' is a draft expect(docs).toHaveLength(1) + + // The indexed 'de' locale must hold that locale's scalar value, not the whole `locale: 'all'` map + const deSearchDoc = await payload.findByID({ + collection: 'search', + id: docs[0]!.id, + depth: 0, + locale: 'de', + }) + + expect(deSearchDoc.slug).toBe('de-only-de') }) it('should sync trashed documents correctly with search plugin', async () => { From 30da26946b41c53ee7b1cd86dc8fb7d7bb53410a Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:04:34 -0400 Subject: [PATCH 3/3] chore(plugin-search): scope reindex sync to published locale --- .../src/utilities/generateReindexHandler.ts | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/plugin-search/src/utilities/generateReindexHandler.ts b/packages/plugin-search/src/utilities/generateReindexHandler.ts index e5c8f245b58..02f2f9cbded 100644 --- a/packages/plugin-search/src/utilities/generateReindexHandler.ts +++ b/packages/plugin-search/src/utilities/generateReindexHandler.ts @@ -1,4 +1,4 @@ -import type { PayloadHandler, Where } from 'payload' +import type { JsonObject, PayloadHandler, TypeWithID, Where } from 'payload' import { addLocalesToRequestFromData, @@ -155,9 +155,12 @@ export const generateReindexHandler = collection, depth: 0, limit: batchSize, - // Fetch all locales so each locale's `_status` is available for gating below + // Fetch all locales so each locale's `_status` is available for gating below. locale: localizeStatusEnabled ? 'all' : defaultLocale, page: i + 1, + // Only `_status` is needed here; per-locale content is loaded scoped inside the loop. + // If that per-locale re-fetch is ever removed, widen this select to the indexed fields. + select: localizeStatusEnabled ? { _status: true } : undefined, where: syncDrafts || !draftsEnabled ? undefined : buildPublishedWhere(collection), ...defaultLocalApiProps, }) @@ -172,13 +175,37 @@ export const generateReindexHandler = // Loop through all locales and check each one let firstAllowedLocale = true for (const localeToSync of allLocales) { + // For localized status, skip locales that aren't published (unless syncing drafts), + // then load the doc scoped to this locale so downstream sees per-locale scalars + // (the batch only selected `_status`). + let docToSync: JsonObject & TypeWithID = doc + if (localizeStatusEnabled) { + const localeStatus = (doc as { _status?: Record })._status?.[ + localeToSync as string + ] + + if (!syncDrafts && localeStatus !== 'published') { + continue + } + + docToSync = + (await payload.findByID({ + id: doc.id, + collection, + depth: 0, + disableErrors: true, + locale: localeToSync, + ...defaultLocalApiProps, + })) ?? doc + } + // Check if we should skip this locale for this document let shouldSkip = false if (typeof pluginConfig.skipSync === 'function') { try { shouldSkip = await pluginConfig.skipSync({ collectionSlug: collection, - doc, + doc: docToSync, locale: localeToSync, req, }) @@ -194,30 +221,6 @@ export const generateReindexHandler = continue // Skip this locale } - // Skip locales that aren't published (unless syncing drafts) - let docToSync = doc - if (localizeStatusEnabled) { - const localeStatus = (doc as { _status?: Record })._status?.[ - localeToSync as string - ] - - if (!syncDrafts && localeStatus !== 'published') { - continue - } - - // The batch was fetched with `locale: 'all'`, so localized fields are locale maps. - // Re-fetch scoped to this locale so `syncDocAsSearchIndex` receives per-locale scalars. - docToSync = - (await payload.findByID({ - id: doc.id, - collection, - depth: 0, - disableErrors: true, - locale: localeToSync, - ...defaultLocalApiProps, - })) ?? doc - } - // Sync this locale (create first index, then update with other locales accordingly) const operation = firstAllowedLocale ? 'create' : 'update' firstAllowedLocale = false