From b8bb57575d1ed589fe56450847e4900833f7781a Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:26:30 -0400 Subject: [PATCH 01/21] refactor: move admin locale resolution to payload --- .../src/admin/getRequestLocale.spec.ts | 96 +++++++++++++++++++ .../payload/src/admin/getRequestLocale.ts | 60 ++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 packages/payload/src/admin/getRequestLocale.spec.ts create mode 100644 packages/payload/src/admin/getRequestLocale.ts diff --git a/packages/payload/src/admin/getRequestLocale.spec.ts b/packages/payload/src/admin/getRequestLocale.spec.ts new file mode 100644 index 00000000000..64aa5945058 --- /dev/null +++ b/packages/payload/src/admin/getRequestLocale.spec.ts @@ -0,0 +1,96 @@ +import type { PayloadRequest, SanitizedLocalizationConfig } from '../index.js' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getRequestLocale } from './getRequestLocale.js' + +const { findPreference, updatePreference } = vi.hoisted(() => ({ + findPreference: vi.fn(), + updatePreference: vi.fn(), +})) + +vi.mock('../preferences/operations/findOne.js', () => ({ + findOne: findPreference, +})) + +vi.mock('../preferences/operations/update.js', () => ({ + update: updatePreference, +})) + +const localization = { + defaultLocale: 'en', + locales: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Spanish' }, + ], +} as SanitizedLocalizationConfig + +const createRequest = ({ + isLocalized = true, + locale, + user = true, +}: { + isLocalized?: boolean + locale?: string + user?: boolean +} = {}): PayloadRequest => + ({ + payload: { + config: { + localization: isLocalized ? localization : false, + }, + }, + query: locale ? { locale } : {}, + user: user + ? { + collection: 'users', + id: 'user-id', + } + : null, + }) as PayloadRequest + +describe('getRequestLocale', () => { + beforeEach(() => { + findPreference.mockReset() + updatePreference.mockReset() + }) + + it('should persist and return a valid locale from the request query', async () => { + const req = createRequest({ locale: 'es' }) + + await expect(getRequestLocale({ req })).resolves.toMatchObject({ code: 'es' }) + expect(updatePreference).toHaveBeenCalledWith({ + key: 'locale', + req, + user: req.user, + value: 'es', + }) + expect(findPreference).not.toHaveBeenCalled() + }) + + it('should return a valid stored locale when the request query omits locale', async () => { + const req = createRequest() + findPreference.mockResolvedValue({ value: 'es' }) + + await expect(getRequestLocale({ req })).resolves.toMatchObject({ code: 'es' }) + expect(findPreference).toHaveBeenCalledWith({ + key: 'locale', + req, + user: req.user, + }) + }) + + it('should fall back to the configured default locale', async () => { + findPreference.mockResolvedValue(null) + + await expect(getRequestLocale({ req: createRequest() })).resolves.toMatchObject({ code: 'en' }) + }) + + it('should return undefined when localization is disabled', async () => { + await expect( + getRequestLocale({ req: createRequest({ isLocalized: false }) }), + ).resolves.toBeUndefined() + expect(findPreference).not.toHaveBeenCalled() + expect(updatePreference).not.toHaveBeenCalled() + }) +}) diff --git a/packages/payload/src/admin/getRequestLocale.ts b/packages/payload/src/admin/getRequestLocale.ts new file mode 100644 index 00000000000..f92fa887271 --- /dev/null +++ b/packages/payload/src/admin/getRequestLocale.ts @@ -0,0 +1,60 @@ +import type { Locale, PayloadRequest, SanitizedLocalizationConfig } from '../index.js' + +import { findOne as findPreference } from '../preferences/operations/findOne.js' +import { update as updatePreference } from '../preferences/operations/update.js' + +export async function getRequestLocale({ + req, +}: { + req: PayloadRequest +}): Promise { + const localization = req.payload.config.localization + + if (!localization) { + return undefined + } + + const localeFromParams = req.query.locale as string | undefined + + if (req.user && localeFromParams) { + await updatePreference({ + key: 'locale', + req, + user: req.user, + value: localeFromParams, + }) + } + + const preference = + req.user && !localeFromParams + ? await findPreference({ + key: 'locale', + req, + user: req.user, + }) + : undefined + const localeCode = localeFromParams || (preference?.value as string | undefined) + + return ( + findLocaleFromCode({ localeCode, localization }) ?? + findLocaleFromCode({ + localeCode: localization.defaultLocale || 'en', + localization, + }) ?? + undefined + ) +} + +function findLocaleFromCode({ + localeCode, + localization, +}: { + localeCode?: string + localization: SanitizedLocalizationConfig +}): Locale | null { + if (!localeCode || !localization.locales.length) { + return null + } + + return localization.locales.find(({ code }) => code === localeCode) ?? null +} From 5eefd8f1e87d42e5b058233d14a402731dc0096f Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:29:30 -0400 Subject: [PATCH 02/21] refactor: centralize initReq in payload --- packages/payload/src/admin/initReq.spec.ts | 262 +++++++++++++++++++++ packages/payload/src/admin/initReq.ts | 161 +++++++++++++ packages/payload/src/index.ts | 6 + 3 files changed, 429 insertions(+) create mode 100644 packages/payload/src/admin/initReq.spec.ts create mode 100644 packages/payload/src/admin/initReq.ts diff --git a/packages/payload/src/admin/initReq.spec.ts b/packages/payload/src/admin/initReq.spec.ts new file mode 100644 index 00000000000..76cd2a77446 --- /dev/null +++ b/packages/payload/src/admin/initReq.spec.ts @@ -0,0 +1,262 @@ +import type { I18nClient } from '@payloadcms/translations' +import type { ImportMap } from '../bin/generateImportMap/index.js' +import type { SanitizedConfig } from '../config/types.js' +import type { Payload } from '../index.js' +import type { ServerAdapter } from './adapters/server.js' +import type { InitReqCache, InitReqPartialResult } from './initReq.js' +import type { InitReqResult } from './functions/index.js' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { initReq } from './initReq.js' + +const { getAccessResults, getPayload, initI18n } = vi.hoisted(() => ({ + getAccessResults: vi.fn(), + getPayload: vi.fn(), + initI18n: vi.fn(), +})) + +vi.mock('../index.js', () => ({ + getPayload, +})) + +vi.mock('../auth/getAccessResults.js', () => ({ + getAccessResults, +})) + +vi.mock('@payloadcms/translations', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + initI18n, + } +}) + +const config = { + cookiePrefix: 'payload', + i18n: { + fallbackLanguage: 'en', + supportedLanguages: { + en: {}, + }, + }, + localization: false, + serverURL: 'https://configured.example.com', +} as SanitizedConfig + +const importMap = {} as ImportMap +const permissions = { + canAccessAdmin: true, + collections: {}, + globals: {}, +} +const i18n = { + language: 'en', + t: (key: string) => key, + translations: {}, +} as unknown as I18nClient +const authenticate = vi.fn().mockResolvedValue({ + responseHeaders: new Headers({ 'x-auth': 'authenticated' }), + user: { + collection: 'users', + id: 'user-id', + }, +}) +const payload = { + authStrategies: [ + { + authenticate, + name: 'test', + }, + ], + config, + logger: { + error: vi.fn(), + }, +} as unknown as Payload +const headers = new Headers({ + cookie: 'payload-lng=en; token=abc', + host: 'example.com', +}) +const serverAdapter = { + forbidden: vi.fn(), + getCookies: vi.fn(), + getHeaders: vi.fn().mockResolvedValue(headers), + notFound: vi.fn(), + permanentRedirect: vi.fn(), + redirect: vi.fn(), + setCookie: vi.fn(), + unauthorized: vi.fn(), +} as unknown as ServerAdapter + +const createExecutingCache = (): InitReqCache => ({ + getPartial: vi.fn((factory: () => Promise) => factory()), + getRequest: vi.fn((factory: () => Promise) => factory()), +}) + +const createReusingCache = (): InitReqCache => { + let partialResult: InitReqPartialResult | undefined + const requestResults = new Map() + + return { + getPartial: vi.fn(async (factory) => { + partialResult ??= await factory() + return partialResult + }), + getRequest: vi.fn(async (factory, key) => { + if (!requestResults.has(key)) { + requestResults.set(key, await factory()) + } + return requestResults.get(key)! + }), + } +} + +describe('initReq', () => { + beforeEach(() => { + authenticate.mockClear() + getAccessResults.mockReset().mockResolvedValue(permissions) + getPayload.mockReset().mockResolvedValue(payload) + initI18n.mockReset().mockResolvedValue(i18n) + }) + + it('should derive the URL and a nested query from requestURL', async () => { + const result = await initReq({ + configPromise: config, + importMap, + requestURL: 'https://example.com/admin?locale=es&where%5Btitle%5D%5Bequals%5D=Hello', + serverAdapter, + }) + + expect(result.req.url).toBe( + 'https://example.com/admin?locale=es&where%5Btitle%5D%5Bequals%5D=Hello', + ) + expect(result.req.query).toEqual({ + locale: 'es', + where: { + title: { + equals: 'Hello', + }, + }, + }) + }) + + it('should prefer explicit request overrides over requestURL', async () => { + const result = await initReq({ + configPromise: config, + importMap, + overrides: { + req: { + query: { + locale: 'de', + }, + url: 'https://override.example.com/admin', + }, + }, + requestURL: 'https://example.com/admin?locale=es', + serverAdapter, + }) + + expect(result.req.url).toBe('https://override.example.com/admin') + expect(result.req.query).toEqual({ + locale: 'de', + }) + }) + + it('should ignore an invalid requestURL', async () => { + const result = await initReq({ + configPromise: config, + importMap, + requestURL: 'not a valid URL', + serverAdapter, + }) + + expect(result.req.url).toBe('https://configured.example.com/') + expect(result.req.query).toEqual({}) + }) + + it('should initialize each request directly when no cache is supplied', async () => { + await initReq({ configPromise: config, importMap, serverAdapter }) + await initReq({ configPromise: config, importMap, serverAdapter }) + + expect(authenticate).toHaveBeenCalledTimes(2) + expect(getAccessResults).toHaveBeenCalledTimes(2) + }) + + it('should use both cache stages when a cache is supplied', async () => { + const cache = createExecutingCache() + + await initReq({ + cache, + configPromise: config, + importMap, + key: 'initPage', + serverAdapter, + }) + + expect(cache.getPartial).toHaveBeenCalledOnce() + expect(cache.getRequest).toHaveBeenCalledWith(expect.any(Function), 'initPage') + }) + + it('should reject a cache without a request key', async () => { + await expect( + initReq({ + cache: createExecutingCache(), + configPromise: config, + importMap, + serverAdapter, + }), + ).rejects.toThrow('initReq requires a key when cache is provided') + }) + + it('should enable cron and forward canSetHeaders to auth strategies', async () => { + await initReq({ + canSetHeaders: true, + configPromise: config, + importMap, + serverAdapter, + }) + + expect(getPayload).toHaveBeenCalledWith({ + config, + cron: true, + importMap, + }) + expect(authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + canSetHeaders: true, + headers, + payload, + }), + ) + }) + + it('should clone req and req.context before returning cached state', async () => { + const cache = createReusingCache() + const args = { + cache, + configPromise: config, + importMap, + key: 'initPage', + overrides: { + req: { + context: { + source: 'cached', + }, + }, + }, + serverAdapter, + } + const first = await initReq(args) + first.req.context.source = 'mutated' + + const second = await initReq(args) + + expect(first.req).not.toBe(second.req) + expect(first.req.context).not.toBe(second.req.context) + expect(second.req.context).toEqual({ + source: 'cached', + }) + }) +}) diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts new file mode 100644 index 00000000000..2917ac4f277 --- /dev/null +++ b/packages/payload/src/admin/initReq.ts @@ -0,0 +1,161 @@ +import type { I18n, I18nClient } from '@payloadcms/translations' + +import { initI18n } from '@payloadcms/translations' +import * as qs from 'qs-esm' + +import type { ImportMap } from '../bin/generateImportMap/index.js' +import type { SanitizedConfig } from '../config/types.js' +import type { PayloadRequest } from '../types/index.js' +import type { ServerAdapter } from './adapters/server.js' +import type { InitReqResult } from './functions/index.js' + +import { executeAuthStrategies } from '../auth/executeAuthStrategies.js' +import { getAccessResults } from '../auth/getAccessResults.js' +import { getPayload } from '../index.js' +import { createLocalReq } from '../utilities/createLocalReq.js' +import { getRequestLanguage } from '../utilities/getRequestLanguage.js' +import { parseCookies } from '../utilities/parseCookies.js' +import { getRequestLocale } from './getRequestLocale.js' + +export type InitReqPartialResult = { + i18n: I18nClient +} & Pick & + Pick + +export type InitReqCache = { + getPartial: (factory: () => Promise) => Promise + getRequest: (factory: () => Promise, key: string) => Promise +} + +export type InitReqArgs = { + cache?: InitReqCache + canSetHeaders?: boolean + configPromise: Promise | SanitizedConfig + importMap: ImportMap + key?: string + overrides?: Parameters[0] + requestURL?: string + serverAdapter: ServerAdapter +} + +export async function initReq({ + cache, + canSetHeaders, + configPromise, + importMap, + key, + overrides, + requestURL, + serverAdapter, +}: InitReqArgs): Promise { + if (cache && !key) { + throw new Error('initReq requires a key when cache is provided') + } + + const headers = await serverAdapter.getHeaders() + const cookies = parseCookies(headers) + const partialFactory = async (): Promise => { + const config = await configPromise + const payload = await getPayload({ config, cron: true, importMap }) + const languageCode = getRequestLanguage({ + config, + cookies, + headers, + }) + const i18n = await initI18n({ + config: config.i18n, + context: 'client', + language: languageCode, + }) + const { responseHeaders, user } = await executeAuthStrategies({ + canSetHeaders, + headers, + payload, + }) + + return { + i18n, + languageCode, + payload, + responseHeaders, + user, + } + } + const partialResult = cache ? await cache.getPartial(partialFactory) : await partialFactory() + const requestFactory = async (): Promise => { + const { i18n, languageCode, payload, responseHeaders, user } = partialResult + const { req: reqOverrides, ...optionsOverrides } = overrides || {} + const requestDefaults = getRequestDefaults({ requestURL }) + const req = await createLocalReq( + { + req: { + headers, + host: headers.get('host') ?? undefined, + i18n: i18n as I18n, + responseHeaders, + server: serverAdapter, + user, + ...requestDefaults, + ...(reqOverrides || {}), + }, + ...(optionsOverrides || {}), + }, + payload, + ) + const locale = await getRequestLocale({ req }) + + req.locale = locale?.code + + const permissions = await getAccessResults({ req }) + + return { + cookies, + headers, + languageCode, + locale, + permissions, + req, + } + } + const result = cache ? await cache.getRequest(requestFactory, key!) : await requestFactory() + + return { + ...result, + req: { + ...result.req, + ...(result.req.context + ? { + context: { ...result.req.context }, + } + : {}), + }, + } +} + +function getRequestDefaults({ + requestURL, +}: { + requestURL?: string +}): Partial> { + if (!requestURL) { + return {} + } + + try { + const url = new URL(requestURL) + + return { + ...(url.search + ? { + query: qs.parse(url.search, { + depth: 10, + ignoreQueryPrefix: true, + }) as PayloadRequest['query'], + } + : {}), + url: requestURL, + } + } catch { + return {} + } +} diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f1d04c962d1..1bec21fc4d1 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -121,6 +121,12 @@ import { type Options as UpdateGlobalOptions, } from './globals/operations/local/update.js' export type { FieldState } from './admin/forms/Form.js' +export { + initReq, + type InitReqArgs, + type InitReqCache, + type InitReqPartialResult, +} from './admin/initReq.js' export type * from './admin/types.js' export { EntityType } from './admin/views/dashboard.js' /** From 56c543569497547999c41b07241489087750f43a Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:31:43 -0400 Subject: [PATCH 03/21] refactor(next): own initReq request caching --- .../src/adapters/handleServerFunctions.ts | 5 +- packages/next/src/adapters/layout.tsx | 6 +- packages/next/src/adapters/views.tsx | 11 +- packages/next/src/utilities/initReq.spec.ts | 48 ++++++ packages/next/src/utilities/initReq.ts | 21 +++ .../src/utilities/selectiveCache.ts | 0 packages/ui/package.json | 5 - packages/ui/src/layouts/Root/index.tsx | 14 +- packages/ui/src/utilities/getRequestLocale.ts | 41 ----- packages/ui/src/utilities/initReq.ts | 141 ------------------ 10 files changed, 82 insertions(+), 210 deletions(-) create mode 100644 packages/next/src/utilities/initReq.spec.ts create mode 100644 packages/next/src/utilities/initReq.ts rename packages/{ui => next}/src/utilities/selectiveCache.ts (100%) delete mode 100644 packages/ui/src/utilities/getRequestLocale.ts delete mode 100644 packages/ui/src/utilities/initReq.ts diff --git a/packages/next/src/adapters/handleServerFunctions.ts b/packages/next/src/adapters/handleServerFunctions.ts index 7fdbb67aabe..1cde77691b2 100644 --- a/packages/next/src/adapters/handleServerFunctions.ts +++ b/packages/next/src/adapters/handleServerFunctions.ts @@ -1,9 +1,8 @@ import { createServerFunctionHandler } from '@payloadcms/ui/utilities/handleServerFunctions' -import { initReq } from '@payloadcms/ui/utilities/initReq' -import { nextServerAdapter } from './server.js' +import { initReq } from '../utilities/initReq.js' export const handleServerFunctions = createServerFunctionHandler({ initReq: ({ configPromise, importMap }) => - initReq({ configPromise, importMap, key: 'RootLayout', serverAdapter: nextServerAdapter }), + initReq({ configPromise, importMap, key: 'RootLayout' }), }) diff --git a/packages/next/src/adapters/layout.tsx b/packages/next/src/adapters/layout.tsx index 490f177214f..de9573605fd 100644 --- a/packages/next/src/adapters/layout.tsx +++ b/packages/next/src/adapters/layout.tsx @@ -3,8 +3,8 @@ import { RootLayout as UIRootLayout } from '@payloadcms/ui/layouts' import { Inter, Roboto_Mono } from 'next/font/google' import React from 'react' +import { initReq } from '../utilities/initReq.js' import { NextRouterAdapter } from './router.js' -import { nextServerAdapter } from './server.js' // CSS import lives here (not in `@payloadcms/ui`) so the esbuild bundle that produces // `@payloadcms/next/css` (`dist/prod/styles.css`) can follow the CSS chain. esbuild // treats `@payloadcms/ui` as external; any CSS import inside that package is invisible @@ -34,7 +34,7 @@ export { metadata } from '@payloadcms/ui/layouts' type Props = Omit< React.ComponentProps, - 'additionalDependencyChecks' | 'fonts' | 'RouterAdapter' | 'serverAdapter' + 'additionalDependencyChecks' | 'fonts' | 'initReq' | 'RouterAdapter' > export const RootLayout = (props: Props) => ( @@ -45,7 +45,7 @@ export const RootLayout = (props: Props) => ( { className: inter.className, variable: inter.variable }, { className: robotoMono.className, variable: robotoMono.variable }, ]} + initReq={initReq} RouterAdapter={NextRouterAdapter} - serverAdapter={nextServerAdapter} /> ) diff --git a/packages/next/src/adapters/views.tsx b/packages/next/src/adapters/views.tsx index 0cb61477f84..da837309a20 100644 --- a/packages/next/src/adapters/views.tsx +++ b/packages/next/src/adapters/views.tsx @@ -6,16 +6,12 @@ import type { SanitizedConfig, } from 'payload' -import { initReq } from '@payloadcms/ui/utilities/initReq' import { renderNotFoundPage } from '@payloadcms/ui/views/NotFound/page' import { renderRoot } from '@payloadcms/ui/views/Root' import { defaultAdminViews } from '@payloadcms/ui/views/Root/adminViews' import { notFound, redirect } from 'next/navigation.js' -import { nextServerAdapter } from './server.js' - -const boundInitReq: Parameters[0]['initReq'] = (args) => - initReq({ ...args, serverAdapter: nextServerAdapter }) +import { initReq } from '../utilities/initReq.js' export const adminViews: AdminViewAdapter = defaultAdminViews @@ -31,7 +27,6 @@ type PageProps = { } export const RootPage = (props: PageProps) => - renderRoot({ ...props, adminViews, initReq: boundInitReq, notFound, redirect }) + renderRoot({ ...props, adminViews, initReq, notFound, redirect }) -export const NotFoundPage = (props: PageProps) => - renderNotFoundPage({ ...props, initReq: boundInitReq }) +export const NotFoundPage = (props: PageProps) => renderNotFoundPage({ ...props, initReq }) diff --git a/packages/next/src/utilities/initReq.spec.ts b/packages/next/src/utilities/initReq.spec.ts new file mode 100644 index 00000000000..2f4d294ee11 --- /dev/null +++ b/packages/next/src/utilities/initReq.spec.ts @@ -0,0 +1,48 @@ +import type { ImportMap, SanitizedConfig } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { initReq } from './initReq.js' + +const { nextServerAdapter, payloadInitReq } = vi.hoisted(() => ({ + nextServerAdapter: { + getHeaders: vi.fn(), + }, + payloadInitReq: vi.fn(), +})) + +vi.mock('payload', () => ({ + initReq: payloadInitReq, +})) + +vi.mock('../adapters/server.js', () => ({ + nextServerAdapter, +})) + +describe('Next initReq', () => { + beforeEach(() => { + payloadInitReq.mockReset().mockResolvedValue({}) + }) + + it('should bind the Next server adapter and React request cache', async () => { + const configPromise = Promise.resolve({} as SanitizedConfig) + const importMap = {} as ImportMap + + await initReq({ + configPromise, + importMap, + key: 'RootLayout', + }) + + expect(payloadInitReq).toHaveBeenCalledWith({ + cache: { + getPartial: expect.any(Function), + getRequest: expect.any(Function), + }, + configPromise, + importMap, + key: 'RootLayout', + serverAdapter: nextServerAdapter, + }) + }) +}) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts new file mode 100644 index 00000000000..2dc662043ee --- /dev/null +++ b/packages/next/src/utilities/initReq.ts @@ -0,0 +1,21 @@ +import type { InitReqArgs, InitReqCache, InitReqPartialResult, InitReqResult } from 'payload' + +import { initReq as payloadInitReq } from 'payload' + +import { nextServerAdapter } from '../adapters/server.js' +import { selectiveCache } from './selectiveCache.js' + +const partialReqCache = selectiveCache('partialReq') +const reqCache = selectiveCache('req') + +const cache: InitReqCache = { + getPartial: (factory) => partialReqCache.get(factory, 'global'), + getRequest: (factory, key) => reqCache.get(factory, key), +} + +export const initReq = (args: Omit) => + payloadInitReq({ + ...args, + cache, + serverAdapter: nextServerAdapter, + }) diff --git a/packages/ui/src/utilities/selectiveCache.ts b/packages/next/src/utilities/selectiveCache.ts similarity index 100% rename from packages/ui/src/utilities/selectiveCache.ts rename to packages/next/src/utilities/selectiveCache.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 460574aaf48..130baab3d83 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -266,11 +266,6 @@ "types": "./src/utilities/getPreferences.ts", "default": "./src/utilities/getPreferences.ts" }, - "./utilities/initReq": { - "import": "./src/utilities/initReq.ts", - "types": "./src/utilities/initReq.ts", - "default": "./src/utilities/initReq.ts" - }, "./utilities/buildFieldSchemaMap/traverseFields": { "import": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", "types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", diff --git a/packages/ui/src/layouts/Root/index.tsx b/packages/ui/src/layouts/Root/index.tsx index 7ba49358e86..387b78481bf 100644 --- a/packages/ui/src/layouts/Root/index.tsx +++ b/packages/ui/src/layouts/Root/index.tsx @@ -1,8 +1,9 @@ import type { ImportMap, + InitReqArgs, + InitReqResult, LanguageOptions, SanitizedConfig, - ServerAdapter, ServerFunctionClient, } from 'payload' @@ -18,7 +19,6 @@ import { getLanguageDir } from '../../utilities/getLanguageDir.js' import { getRequestEmbed } from '../../utilities/getRequestEmbed.js' import { getRequestHighContrast } from '../../utilities/getRequestHighContrast.js' import { getRequestTheme } from '../../utilities/getRequestTheme.js' -import { initReq } from '../../utilities/initReq.js' import { NestProviders } from './NestProviders.js' import { getViewportMeta } from './viewport.js' // eslint-disable-next-line payload/no-imports-from-self -- Self-import via package path ensures consumer's bundler resolves the full CSS chain (design tokens, preflight, etc.) in prod builds @@ -68,16 +68,12 @@ type RootLayoutProps = { readonly head?: React.ReactNode readonly htmlProps?: React.HtmlHTMLAttributes readonly importMap: ImportMap + readonly initReq: (args: Omit) => Promise /** * Client router adapter. Caller supplies a framework-specific provider * (for Next.js use the `NextRouterAdapter` exported from `@payloadcms/next`). */ readonly RouterAdapter: React.FC<{ children: React.ReactNode }> - /** - * Server adapter providing framework-specific access to headers, cookies, redirects, - * and other server APIs (for Next.js use `nextServerAdapter` from `@payloadcms/next`). - */ - readonly serverAdapter: ServerAdapter readonly serverFunction: ServerFunctionClient } @@ -100,8 +96,8 @@ const RootLayoutContent = async ({ head: headFromProps, htmlProps = {}, importMap, + initReq, RouterAdapter, - serverAdapter, serverFunction, }: RootLayoutProps) => { const { @@ -113,7 +109,7 @@ const RootLayoutContent = async ({ req: { payload: { config }, }, - } = await initReq({ configPromise, importMap, key: 'RootLayout', serverAdapter }) + } = await initReq({ configPromise, importMap, key: 'RootLayout' }) const theme = getRequestTheme({ config, diff --git a/packages/ui/src/utilities/getRequestLocale.ts b/packages/ui/src/utilities/getRequestLocale.ts deleted file mode 100644 index bcd8d77df81..00000000000 --- a/packages/ui/src/utilities/getRequestLocale.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Locale, PayloadRequest } from 'payload' - -import { findLocaleFromCode } from './findLocaleFromCode.js' -import { getPreferences } from './getPreferences.js' -import { upsertPreferences } from './upsertPreferences.js' - -type GetRequestLocalesArgs = { - req: PayloadRequest -} - -export async function getRequestLocale({ req }: GetRequestLocalesArgs): Promise { - if (req.payload.config.localization) { - const localeFromParams = req.query.locale as string | undefined - - if (req.user && localeFromParams) { - await upsertPreferences({ key: 'locale', req, value: localeFromParams }) - } - - return ( - (req.user && - findLocaleFromCode( - req.payload.config.localization, - localeFromParams || - ( - await getPreferences( - 'locale', - req.payload, - req.user.id, - req.user.collection, - ) - )?.value, - )) || - findLocaleFromCode( - req.payload.config.localization, - req.payload.config.localization.defaultLocale || 'en', - ) - ) - } - - return undefined -} diff --git a/packages/ui/src/utilities/initReq.ts b/packages/ui/src/utilities/initReq.ts deleted file mode 100644 index 661df86e98d..00000000000 --- a/packages/ui/src/utilities/initReq.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { I18n, I18nClient } from '@payloadcms/translations' -import type { - ImportMap, - InitReqResult, - PayloadRequest, - SanitizedConfig, - ServerAdapter, -} from 'payload' - -import { initI18n } from '@payloadcms/translations' -import { - createLocalReq, - executeAuthStrategies, - getAccessResults, - getPayload, - getRequestLanguage, - parseCookies, -} from 'payload' - -import { getRequestLocale } from './getRequestLocale.js' -import { selectiveCache } from './selectiveCache.js' - -type PartialResult = { - i18n: I18nClient -} & Pick & - Pick - -const partialReqCache = selectiveCache('partialReq') -const reqCache = selectiveCache('req') - -/** - * Initializes a full request object, including the `req` object and access control. - * Reads headers/cookies through the supplied `serverAdapter` so the function is - * framework-agnostic; the consuming framework wires its own adapter. - */ -export const initReq = async function ({ - canSetHeaders, - configPromise, - importMap, - key, - overrides, - serverAdapter, -}: { - canSetHeaders?: boolean - configPromise: Promise | SanitizedConfig - importMap: ImportMap - key: string - overrides?: Parameters[0] - serverAdapter: ServerAdapter -}): Promise { - const headers = await serverAdapter.getHeaders() - const cookies = parseCookies(headers) - - const partialResult = await partialReqCache.get(async () => { - const config = await configPromise - const payload = await getPayload({ config, cron: true, importMap }) - const languageCode = getRequestLanguage({ - config, - cookies, - headers, - }) - - const i18n: I18nClient = await initI18n({ - config: config.i18n, - context: 'client', - language: languageCode, - }) - - const { responseHeaders, user } = await executeAuthStrategies({ - canSetHeaders, - headers, - payload, - }) - - return { - i18n, - languageCode, - payload, - responseHeaders, - user, - } - }, 'global') - - return reqCache - .get(async () => { - const { i18n, languageCode, payload, responseHeaders, user } = partialResult - - const { req: reqOverrides, ...optionsOverrides } = overrides || {} - - const req = await createLocalReq( - { - req: { - headers, - host: headers.get('host'), - i18n: i18n as I18n, - responseHeaders, - server: serverAdapter, - user, - ...(reqOverrides || {}), - }, - ...(optionsOverrides || {}), - }, - payload, - ) - - const locale = await getRequestLocale({ - req, - }) - - req.locale = locale?.code - - const permissions = await getAccessResults({ - req, - }) - - return { - cookies, - headers, - languageCode, - locale, - permissions, - req, - } - }, key) - .then((result) => { - // Shallow-copy req before returning to prevent - // mutations from propagating to the cached req object. - // This ensures parallel operations using the same cache key don't affect each other. - return { - ...result, - req: { - ...result.req, - ...(result.req?.context - ? { - context: { ...result.req.context }, - } - : {}), - }, - } - }) -} From cf6b13afe2264de64c4de01342f0a0e7b7384589 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:33:12 -0400 Subject: [PATCH 04/21] refactor(tanstack-start): use payload initReq --- packages/tanstack-start/src/exports/server.ts | 2 - .../src/layouts/Root/getLayoutData.ts | 11 +- .../src/utilities/getRequestLocale.ts | 44 ------- .../src/utilities/handleServerFunctions.ts | 12 +- .../src/utilities/initReq.server.ts | 121 ------------------ .../src/utilities/loadAdminPage.tsx | 9 +- 6 files changed, 26 insertions(+), 173 deletions(-) delete mode 100644 packages/tanstack-start/src/utilities/getRequestLocale.ts delete mode 100644 packages/tanstack-start/src/utilities/initReq.server.ts diff --git a/packages/tanstack-start/src/exports/server.ts b/packages/tanstack-start/src/exports/server.ts index e8e00e0422a..07927879e12 100644 --- a/packages/tanstack-start/src/exports/server.ts +++ b/packages/tanstack-start/src/exports/server.ts @@ -3,11 +3,9 @@ export { logout } from '../auth/logout.js' export { refresh } from '../auth/refresh.js' export { payloadApiHandlers } from '../routes/apiRoute.js' export { getRequestI18n } from '../utilities/getRequestI18n.server.js' -export { getRequestLocale } from '../utilities/getRequestLocale.js' export { handleGraphQL } from '../utilities/graphqlHandler.server.js' export { handleAPIRoute } from '../utilities/handleAPIRoute.server.js' export { handleServerFunctions } from '../utilities/handleServerFunctions.js' -export { initReq } from '../utilities/initReq.server.js' export { loadAdminPage, type LoadAdminPageArgs, diff --git a/packages/tanstack-start/src/layouts/Root/getLayoutData.ts b/packages/tanstack-start/src/layouts/Root/getLayoutData.ts index 17616aa85bb..56ba0144dea 100644 --- a/packages/tanstack-start/src/layouts/Root/getLayoutData.ts +++ b/packages/tanstack-start/src/layouts/Root/getLayoutData.ts @@ -16,10 +16,12 @@ import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' import { getRequestEmbed } from '@payloadcms/ui/utilities/getRequestEmbed' import { getRequestTheme } from '@payloadcms/ui/utilities/getRequestTheme' import { Outlet } from '@tanstack/react-router' +import { getRequest } from '@tanstack/react-start/server' +import { initReq } from 'payload' import { applyLocaleFiltering } from 'payload/shared' import { createElement } from 'react' -import { initReq } from '../../utilities/initReq.server.js' +import { tanstackServerAdapter } from '../../utilities/serverAdapter.server.js' export type RootLayoutData = { clientConfig: ClientConfig @@ -65,7 +67,12 @@ export async function getLayoutData({ req: { payload: { config }, }, - } = await initReq({ configPromise, importMap }) + } = await initReq({ + configPromise, + importMap, + requestURL: getRequest().url, + serverAdapter: tanstackServerAdapter, + }) const theme = getRequestTheme({ config, cookies, headers }) const isEmbedded = getRequestEmbed({ config, cookies }) diff --git a/packages/tanstack-start/src/utilities/getRequestLocale.ts b/packages/tanstack-start/src/utilities/getRequestLocale.ts deleted file mode 100644 index cba0b164577..00000000000 --- a/packages/tanstack-start/src/utilities/getRequestLocale.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Locale, PayloadRequest } from 'payload' - -import { getPreferences, upsertPreferences } from '@payloadcms/ui/rsc' -import { findLocaleFromCode } from '@payloadcms/ui/shared' - -type GetRequestLocaleArgs = { - req: PayloadRequest -} - -export async function getRequestLocale({ req }: GetRequestLocaleArgs): Promise { - if (req.payload.config.localization) { - const localeFromParams = req.query.locale as string | undefined - - if (req.user && localeFromParams) { - await upsertPreferences({ key: 'locale', req, value: localeFromParams }) - } - - const userLocale = - req.user && - findLocaleFromCode( - req.payload.config.localization, - localeFromParams || - ( - await getPreferences( - 'locale', - req.payload, - req.user.id, - req.user.collection, - ) - )?.value, - ) - - return ( - userLocale ?? - findLocaleFromCode( - req.payload.config.localization, - req.payload.config.localization.defaultLocale || 'en', - ) ?? - undefined - ) - } - - return undefined -} diff --git a/packages/tanstack-start/src/utilities/handleServerFunctions.ts b/packages/tanstack-start/src/utilities/handleServerFunctions.ts index c49bf7cadac..9a98fd7f1a5 100644 --- a/packages/tanstack-start/src/utilities/handleServerFunctions.ts +++ b/packages/tanstack-start/src/utilities/handleServerFunctions.ts @@ -1,9 +1,17 @@ import { createServerFunctionHandler } from '@payloadcms/ui/utilities/handleServerFunctions' +import { getRequest } from '@tanstack/react-start/server' +import { initReq } from 'payload' -import { initReq } from './initReq.server.js' import { serializeForRsc } from './serializeForRsc.js' +import { tanstackServerAdapter } from './serverAdapter.server.js' export const handleServerFunctions = createServerFunctionHandler({ - initReq: ({ configPromise, importMap }) => initReq({ configPromise, importMap }), + initReq: ({ configPromise, importMap }) => + initReq({ + configPromise, + importMap, + requestURL: getRequest().url, + serverAdapter: tanstackServerAdapter, + }), transformResult: serializeForRsc, }) diff --git a/packages/tanstack-start/src/utilities/initReq.server.ts b/packages/tanstack-start/src/utilities/initReq.server.ts deleted file mode 100644 index 185b1b61b52..00000000000 --- a/packages/tanstack-start/src/utilities/initReq.server.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { I18n, I18nClient } from '@payloadcms/translations' -import type { ImportMap, InitReqResult, SanitizedConfig, ServerAdapter } from 'payload' - -import { initI18n } from '@payloadcms/translations' -import { getRequest } from '@tanstack/react-start/server' -import { - createLocalReq, - executeAuthStrategies, - getAccessResults, - getPayload, - getRequestLanguage, - parseCookies, -} from 'payload' -import * as qs from 'qs-esm' - -import { getRequestLocale } from './getRequestLocale.js' -import { tanstackServerAdapter } from './serverAdapter.server.js' - -/** - * Initializes a Payload request object from the current TanStack Start server context. - * Uses `getRequest()` from `@tanstack/react-start/server` to access the incoming request. - */ -export async function initReq({ - configPromise, - importMap, - overrides, - serverAdapter = tanstackServerAdapter, -}: { - configPromise: Promise | SanitizedConfig - importMap: ImportMap - overrides?: Parameters[0] - /** - * `ServerAdapter` attached to `req.server`. Defaults to the native - * `tanstackServerAdapter` (throws TanStack `redirect`/`notFound`). The admin - * page render passes an error-contract adapter so navigation thrown mid-render - * is caught at the loader boundary and re-thrown as native TanStack nav. - */ - serverAdapter?: ServerAdapter -}): Promise { - const webRequest = getRequest() - const headers = new Headers(webRequest.headers) - const cookies = parseCookies(headers) - - const config = await configPromise - const payload = await getPayload({ config, importMap }) - - const languageCode = getRequestLanguage({ - config, - cookies, - headers, - }) - - const i18n: I18nClient = await initI18n({ - config: config.i18n, - context: 'client', - language: languageCode, - }) - - const { responseHeaders, user } = await executeAuthStrategies({ - headers, - payload, - }) - - const { req: reqOverrides, ...optionsOverrides } = overrides || {} - - // Parse the active URL's query string so that `req.query` (and thus - // `getRequestLocale`, access checks, etc.) reflect things like `?locale=es` - // even when the caller (e.g. the root layout loader) hasn't explicitly - // forwarded `searchParams` via overrides. `createLocalReq` only defaults - // `req.query` to `{}` and never reads the request URL itself. - let queryFromUrl: Record | undefined - try { - const urlObject = new URL(webRequest.url) - if (urlObject.search) { - queryFromUrl = qs.parse(urlObject.search, { - depth: 10, - ignoreQueryPrefix: true, - }) as Record - } - } catch { - queryFromUrl = undefined - } - - const req = await createLocalReq( - { - req: { - headers, - host: headers.get('host') ?? undefined, - i18n: i18n as I18n, - query: queryFromUrl, - responseHeaders, - // Expose the `ServerAdapter` so framework-agnostic server functions can - // set cookies / redirect via `req.server` (mirrors the Next.js adapter, - // which passes `nextServerAdapter`). Required by handlers like - // `switch-language`. Defaults to the native adapter; the page render - // overrides it with the error-contract adapter. - server: serverAdapter, - url: webRequest.url, - user, - ...(reqOverrides || {}), - }, - ...(optionsOverrides || {}), - }, - payload, - ) - - const locale = await getRequestLocale({ req }) - - req.locale = locale?.code - - const permissions = await getAccessResults({ req }) - - return { - cookies, - headers, - languageCode, - locale, - permissions, - req, - } -} diff --git a/packages/tanstack-start/src/utilities/loadAdminPage.tsx b/packages/tanstack-start/src/utilities/loadAdminPage.tsx index a85a788a3f4..9a2838aef83 100644 --- a/packages/tanstack-start/src/utilities/loadAdminPage.tsx +++ b/packages/tanstack-start/src/utilities/loadAdminPage.tsx @@ -2,12 +2,13 @@ import type { ImportMap, MetaConfig, SanitizedConfig } from 'payload' import { getViewportContent } from '@payloadcms/ui/shared' import { renderServerComponent } from '@tanstack/react-start/rsc' +import { getRequest } from '@tanstack/react-start/server' +import { initReq } from 'payload' import type { AdminPageMetadata } from './meta.js' import { getRequestI18n } from './getRequestI18n.server.js' -import { initReq } from './initReq.server.js' -import { createPageRenderServerAdapter } from './serverAdapter.server.js' +import { createPageRenderServerAdapter, tanstackServerAdapter } from './serverAdapter.server.js' export type LoadAdminPageArgs = { config: SanitizedConfig @@ -165,6 +166,7 @@ export async function loadAdminPage({ // redirect lands on a clean `/admin/login`. const segments = splatSegments.length > 0 ? splatSegments : undefined const searchParams = search ?? {} + const requestURL = getRequest().url // Records navigation requested via `req.server.*` (including throws swallowed // by RSC streaming deep inside view components). Read after the render. @@ -181,6 +183,7 @@ export async function loadAdminPage({ configPromise: args.configPromise, importMap: args.importMap, overrides: args.overrides, + requestURL, serverAdapter: pageServerAdapter, }) userAgent = result.headers.get('user-agent') ?? undefined @@ -211,6 +214,8 @@ export async function loadAdminPage({ configPromise: args.configPromise, importMap: args.importMap, overrides: args.overrides, + requestURL, + serverAdapter: tanstackServerAdapter, }), params: Promise.resolve({ segments: splatSegments }), searchParams: Promise.resolve(searchParams), From c20f4362990451d01a1c5e5ab72ca24112bf7823 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:41:51 -0400 Subject: [PATCH 05/21] refactor: clarify initReq result factories --- packages/payload/src/admin/initReq.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index 2917ac4f277..ddd1fb84b52 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -54,7 +54,7 @@ export async function initReq({ const headers = await serverAdapter.getHeaders() const cookies = parseCookies(headers) - const partialFactory = async (): Promise => { + const createPartialResult = async (): Promise => { const config = await configPromise const payload = await getPayload({ config, cron: true, importMap }) const languageCode = getRequestLanguage({ @@ -81,8 +81,10 @@ export async function initReq({ user, } } - const partialResult = cache ? await cache.getPartial(partialFactory) : await partialFactory() - const requestFactory = async (): Promise => { + const partialResult = cache + ? await cache.getPartial(createPartialResult) + : await createPartialResult() + const createRequestResult = async (): Promise => { const { i18n, languageCode, payload, responseHeaders, user } = partialResult const { req: reqOverrides, ...optionsOverrides } = overrides || {} const requestDefaults = getRequestDefaults({ requestURL }) @@ -117,7 +119,9 @@ export async function initReq({ req, } } - const result = cache ? await cache.getRequest(requestFactory, key!) : await requestFactory() + const result = cache + ? await cache.getRequest(createRequestResult, key!) + : await createRequestResult() return { ...result, From 0b7d53de2edc703cfb863c4275e63fc4286e6e44 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 20:52:19 -0400 Subject: [PATCH 06/21] refactor(tanstack-start): bind initReq request context --- .../src/layouts/Root/getLayoutData.ts | 6 +- .../src/utilities/handleServerFunctions.ts | 6 +- .../src/utilities/initReq.server.spec.ts | 72 +++++++++++++++++++ .../src/utilities/initReq.server.ts | 17 +++++ .../src/utilities/loadAdminPage.tsx | 9 +-- 5 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 packages/tanstack-start/src/utilities/initReq.server.spec.ts create mode 100644 packages/tanstack-start/src/utilities/initReq.server.ts diff --git a/packages/tanstack-start/src/layouts/Root/getLayoutData.ts b/packages/tanstack-start/src/layouts/Root/getLayoutData.ts index 56ba0144dea..f993ab7b9df 100644 --- a/packages/tanstack-start/src/layouts/Root/getLayoutData.ts +++ b/packages/tanstack-start/src/layouts/Root/getLayoutData.ts @@ -16,12 +16,10 @@ import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' import { getRequestEmbed } from '@payloadcms/ui/utilities/getRequestEmbed' import { getRequestTheme } from '@payloadcms/ui/utilities/getRequestTheme' import { Outlet } from '@tanstack/react-router' -import { getRequest } from '@tanstack/react-start/server' -import { initReq } from 'payload' import { applyLocaleFiltering } from 'payload/shared' import { createElement } from 'react' -import { tanstackServerAdapter } from '../../utilities/serverAdapter.server.js' +import { initReq } from '../../utilities/initReq.server.js' export type RootLayoutData = { clientConfig: ClientConfig @@ -70,8 +68,6 @@ export async function getLayoutData({ } = await initReq({ configPromise, importMap, - requestURL: getRequest().url, - serverAdapter: tanstackServerAdapter, }) const theme = getRequestTheme({ config, cookies, headers }) diff --git a/packages/tanstack-start/src/utilities/handleServerFunctions.ts b/packages/tanstack-start/src/utilities/handleServerFunctions.ts index 9a98fd7f1a5..047ca411c93 100644 --- a/packages/tanstack-start/src/utilities/handleServerFunctions.ts +++ b/packages/tanstack-start/src/utilities/handleServerFunctions.ts @@ -1,17 +1,13 @@ import { createServerFunctionHandler } from '@payloadcms/ui/utilities/handleServerFunctions' -import { getRequest } from '@tanstack/react-start/server' -import { initReq } from 'payload' +import { initReq } from './initReq.server.js' import { serializeForRsc } from './serializeForRsc.js' -import { tanstackServerAdapter } from './serverAdapter.server.js' export const handleServerFunctions = createServerFunctionHandler({ initReq: ({ configPromise, importMap }) => initReq({ configPromise, importMap, - requestURL: getRequest().url, - serverAdapter: tanstackServerAdapter, }), transformResult: serializeForRsc, }) diff --git a/packages/tanstack-start/src/utilities/initReq.server.spec.ts b/packages/tanstack-start/src/utilities/initReq.server.spec.ts new file mode 100644 index 00000000000..6c0a2f8d151 --- /dev/null +++ b/packages/tanstack-start/src/utilities/initReq.server.spec.ts @@ -0,0 +1,72 @@ +import type { ImportMap, SanitizedConfig, ServerAdapter } from 'payload' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { initReq } from './initReq.server.js' + +const { getRequest, payloadInitReq, tanstackServerAdapter } = vi.hoisted(() => ({ + getRequest: vi.fn(), + payloadInitReq: vi.fn(), + tanstackServerAdapter: { + getHeaders: vi.fn(), + }, +})) + +vi.mock('@tanstack/react-start/server', () => ({ + getRequest, +})) + +vi.mock('payload', () => ({ + initReq: payloadInitReq, +})) + +vi.mock('./serverAdapter.server.js', () => ({ + tanstackServerAdapter, +})) + +describe('TanStack Start initReq', () => { + beforeEach(() => { + getRequest.mockReturnValue({ + url: 'https://example.com/admin?locale=es', + }) + payloadInitReq.mockReset().mockResolvedValue({}) + }) + + it('should bind the active request URL and default server adapter', async () => { + const configPromise = Promise.resolve({} as SanitizedConfig) + const importMap = {} as ImportMap + + await initReq({ + configPromise, + importMap, + }) + + expect(payloadInitReq).toHaveBeenCalledWith({ + configPromise, + importMap, + requestURL: 'https://example.com/admin?locale=es', + serverAdapter: tanstackServerAdapter, + }) + }) + + it('should allow the page render to override the server adapter', async () => { + const configPromise = Promise.resolve({} as SanitizedConfig) + const importMap = {} as ImportMap + const pageServerAdapter = { + getHeaders: vi.fn(), + } as unknown as ServerAdapter + + await initReq({ + configPromise, + importMap, + serverAdapter: pageServerAdapter, + }) + + expect(payloadInitReq).toHaveBeenCalledWith({ + configPromise, + importMap, + requestURL: 'https://example.com/admin?locale=es', + serverAdapter: pageServerAdapter, + }) + }) +}) diff --git a/packages/tanstack-start/src/utilities/initReq.server.ts b/packages/tanstack-start/src/utilities/initReq.server.ts new file mode 100644 index 00000000000..ec2cf0c9937 --- /dev/null +++ b/packages/tanstack-start/src/utilities/initReq.server.ts @@ -0,0 +1,17 @@ +import type { InitReqArgs, ServerAdapter } from 'payload' + +import { getRequest } from '@tanstack/react-start/server' +import { initReq as payloadInitReq } from 'payload' + +import { tanstackServerAdapter } from './serverAdapter.server.js' + +type TanStackInitReqArgs = { + serverAdapter?: ServerAdapter +} & Omit + +export const initReq = ({ serverAdapter = tanstackServerAdapter, ...args }: TanStackInitReqArgs) => + payloadInitReq({ + ...args, + requestURL: getRequest().url, + serverAdapter, + }) diff --git a/packages/tanstack-start/src/utilities/loadAdminPage.tsx b/packages/tanstack-start/src/utilities/loadAdminPage.tsx index 9a2838aef83..a85a788a3f4 100644 --- a/packages/tanstack-start/src/utilities/loadAdminPage.tsx +++ b/packages/tanstack-start/src/utilities/loadAdminPage.tsx @@ -2,13 +2,12 @@ import type { ImportMap, MetaConfig, SanitizedConfig } from 'payload' import { getViewportContent } from '@payloadcms/ui/shared' import { renderServerComponent } from '@tanstack/react-start/rsc' -import { getRequest } from '@tanstack/react-start/server' -import { initReq } from 'payload' import type { AdminPageMetadata } from './meta.js' import { getRequestI18n } from './getRequestI18n.server.js' -import { createPageRenderServerAdapter, tanstackServerAdapter } from './serverAdapter.server.js' +import { initReq } from './initReq.server.js' +import { createPageRenderServerAdapter } from './serverAdapter.server.js' export type LoadAdminPageArgs = { config: SanitizedConfig @@ -166,7 +165,6 @@ export async function loadAdminPage({ // redirect lands on a clean `/admin/login`. const segments = splatSegments.length > 0 ? splatSegments : undefined const searchParams = search ?? {} - const requestURL = getRequest().url // Records navigation requested via `req.server.*` (including throws swallowed // by RSC streaming deep inside view components). Read after the render. @@ -183,7 +181,6 @@ export async function loadAdminPage({ configPromise: args.configPromise, importMap: args.importMap, overrides: args.overrides, - requestURL, serverAdapter: pageServerAdapter, }) userAgent = result.headers.get('user-agent') ?? undefined @@ -214,8 +211,6 @@ export async function loadAdminPage({ configPromise: args.configPromise, importMap: args.importMap, overrides: args.overrides, - requestURL, - serverAdapter: tanstackServerAdapter, }), params: Promise.resolve({ segments: splatSegments }), searchParams: Promise.resolve(searchParams), From b5ce5c1cc5373319764e3213523fa4cf3233fe87 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 21:00:26 -0400 Subject: [PATCH 07/21] refactor(tanstack-start): pass initReq directly --- .../tanstack-start/src/utilities/handleServerFunctions.ts | 6 +----- packages/tanstack-start/src/utilities/loadAdminPage.tsx | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/tanstack-start/src/utilities/handleServerFunctions.ts b/packages/tanstack-start/src/utilities/handleServerFunctions.ts index 047ca411c93..c36c06d4e4a 100644 --- a/packages/tanstack-start/src/utilities/handleServerFunctions.ts +++ b/packages/tanstack-start/src/utilities/handleServerFunctions.ts @@ -4,10 +4,6 @@ import { initReq } from './initReq.server.js' import { serializeForRsc } from './serializeForRsc.js' export const handleServerFunctions = createServerFunctionHandler({ - initReq: ({ configPromise, importMap }) => - initReq({ - configPromise, - importMap, - }), + initReq, transformResult: serializeForRsc, }) diff --git a/packages/tanstack-start/src/utilities/loadAdminPage.tsx b/packages/tanstack-start/src/utilities/loadAdminPage.tsx index a85a788a3f4..afe25aad897 100644 --- a/packages/tanstack-start/src/utilities/loadAdminPage.tsx +++ b/packages/tanstack-start/src/utilities/loadAdminPage.tsx @@ -206,12 +206,7 @@ export async function loadAdminPage({ const notFoundNode = await renderNotFoundPage({ config: Promise.resolve(config), importMap, - initReq: (args) => - initReq({ - configPromise: args.configPromise, - importMap: args.importMap, - overrides: args.overrides, - }), + initReq, params: Promise.resolve({ segments: splatSegments }), searchParams: Promise.resolve(searchParams), }) From 9470e9424cc6d33fb2e0c673dd0abf1671aec7d3 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 22:13:32 -0400 Subject: [PATCH 08/21] test(next): protect initReq request caching --- package.json | 3 +- .../next/src/utilities/initReq.rsc.spec.ts | 93 +++++++++++++++++++ vitest.config.ts | 11 +++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/next/src/utilities/initReq.rsc.spec.ts diff --git a/package.json b/package.json index dd71e92f0ed..ddddca17897 100644 --- a/package.json +++ b/package.json @@ -133,8 +133,9 @@ "test:int:postgres": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true vitest --project int", "test:int:sqlite": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=sqlite DISABLE_LOGGING=true vitest --project int", "test:int:summary": "pnpm runts ./test/runTestsWithSummary.ts", + "test:rsc": "cross-env NODE_OPTIONS=\"--conditions=react-server\" vitest run --project rsc", "test:types": "tstyche", - "test:unit": "vitest run --project unit", + "test:unit": "vitest run --project unit && pnpm test:rsc", "translateNewKeys": "pnpm --filter @tools/scripts run generateTranslations:core" }, "devDependencies": { diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts new file mode 100644 index 00000000000..6efe793535a --- /dev/null +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -0,0 +1,93 @@ +import type { ImportMap, SanitizedConfig } from 'payload' + +import { createRequire } from 'node:module' + +import type React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { initReq } from './initReq.js' + +const { counters, payloadInitReq } = vi.hoisted(() => ({ + counters: { + partial: 0, + request: 0, + }, + payloadInitReq: vi.fn(), +})) + +vi.mock('react', () => createRequire(import.meta.url)('react')) + +vi.mock('payload', () => ({ + initReq: payloadInitReq, +})) + +vi.mock('../adapters/server.js', () => ({ + nextServerAdapter: { + getHeaders: vi.fn(), + }, +})) + +type RscRenderer = { + renderToReadableStream: ( + model: React.ReactNode, + moduleMap: Record, + ) => ReadableStream +} + +const require = createRequire(import.meta.url) +const React = require('react') as typeof import('react') +const { renderToReadableStream } = + require('next/dist/compiled/react-server-dom-webpack/server.node.js') as RscRenderer + +const configPromise = Promise.resolve({} as SanitizedConfig) +const importMap = {} as ImportMap + +async function renderNavigation(): Promise { + async function Navigation() { + await Promise.all([ + initReq({ configPromise, importMap, key: 'RootLayout' }), + initReq({ configPromise, importMap, key: 'RootLayout' }), + initReq({ configPromise, importMap, key: 'initPage' }), + ]) + + return null + } + + const stream = renderToReadableStream(React.createElement(Navigation), {}) + + await new Response(stream).arrayBuffer() +} + +describe('Next initReq RSC cache', () => { + beforeEach(() => { + counters.partial = 0 + counters.request = 0 + payloadInitReq.mockReset().mockImplementation(async ({ cache, key }) => { + await cache.getPartial(async () => { + counters.partial += 1 + return {} + }) + + return cache.getRequest(async () => { + counters.request += 1 + return {} + }, key) + }) + }) + + it('should deduplicate initialization per navigation and reset between navigations', async () => { + await renderNavigation() + + expect(counters).toEqual({ + partial: 1, + request: 2, + }) + + await renderNavigation() + + expect(counters).toEqual({ + partial: 2, + request: 4, + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 11c5e08fca4..66e8d8ccb17 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -50,11 +50,22 @@ export default defineConfig({ }, }, test: { + exclude: ['packages/next/**/*.rsc.spec.ts'], include: ['packages/**/*.spec.ts', 'tools/**/*.spec.ts'], name: 'unit', environment: 'node', }, }, + { + resolve: { + conditions: ['react-server'], + }, + test: { + include: ['packages/next/**/*.rsc.spec.ts'], + name: 'rsc', + environment: 'node', + }, + }, { resolve: { alias: [ From 381b1f709e7ebf4cafb7216238a55da617a39e9e Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 29 Jul 2026 22:24:40 -0400 Subject: [PATCH 09/21] fix(payload): ignore locale query for anonymous requests --- packages/payload/src/admin/getRequestLocale.spec.ts | 8 ++++++++ packages/payload/src/admin/getRequestLocale.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/admin/getRequestLocale.spec.ts b/packages/payload/src/admin/getRequestLocale.spec.ts index 64aa5945058..19d042526a1 100644 --- a/packages/payload/src/admin/getRequestLocale.spec.ts +++ b/packages/payload/src/admin/getRequestLocale.spec.ts @@ -86,6 +86,14 @@ describe('getRequestLocale', () => { await expect(getRequestLocale({ req: createRequest() })).resolves.toMatchObject({ code: 'en' }) }) + it('should ignore a query locale for an anonymous request', async () => { + await expect( + getRequestLocale({ req: createRequest({ locale: 'es', user: false }) }), + ).resolves.toMatchObject({ code: 'en' }) + expect(findPreference).not.toHaveBeenCalled() + expect(updatePreference).not.toHaveBeenCalled() + }) + it('should return undefined when localization is disabled', async () => { await expect( getRequestLocale({ req: createRequest({ isLocalized: false }) }), diff --git a/packages/payload/src/admin/getRequestLocale.ts b/packages/payload/src/admin/getRequestLocale.ts index f92fa887271..608bb651258 100644 --- a/packages/payload/src/admin/getRequestLocale.ts +++ b/packages/payload/src/admin/getRequestLocale.ts @@ -33,7 +33,9 @@ export async function getRequestLocale({ user: req.user, }) : undefined - const localeCode = localeFromParams || (preference?.value as string | undefined) + const localeCode = req.user + ? localeFromParams || (preference?.value as string | undefined) + : undefined return ( findLocaleFromCode({ localeCode, localization }) ?? From 4c4410e2e7c5de57b2e31e9cb7e077bd288ef93c Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Thu, 30 Jul 2026 10:28:55 -0400 Subject: [PATCH 10/21] cleanup --- packages/payload/src/admin/initReq.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index ddd1fb84b52..466ae836242 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -54,6 +54,7 @@ export async function initReq({ const headers = await serverAdapter.getHeaders() const cookies = parseCookies(headers) + const createPartialResult = async (): Promise => { const config = await configPromise const payload = await getPayload({ config, cron: true, importMap }) @@ -62,11 +63,13 @@ export async function initReq({ cookies, headers, }) + const i18n = await initI18n({ config: config.i18n, context: 'client', language: languageCode, }) + const { responseHeaders, user } = await executeAuthStrategies({ canSetHeaders, headers, @@ -81,13 +84,16 @@ export async function initReq({ user, } } + const partialResult = cache ? await cache.getPartial(createPartialResult) : await createPartialResult() + const createRequestResult = async (): Promise => { const { i18n, languageCode, payload, responseHeaders, user } = partialResult const { req: reqOverrides, ...optionsOverrides } = overrides || {} const requestDefaults = getRequestDefaults({ requestURL }) + const req = await createLocalReq( { req: { @@ -104,6 +110,7 @@ export async function initReq({ }, payload, ) + const locale = await getRequestLocale({ req }) req.locale = locale?.code @@ -119,6 +126,7 @@ export async function initReq({ req, } } + const result = cache ? await cache.getRequest(createRequestResult, key!) : await createRequestResult() From cdbc5c49d0ceda7eb1259a200d9729182307f5c4 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Thu, 6 Aug 2026 15:39:48 -0400 Subject: [PATCH 11/21] refactor: expose framework initReq entrypoints --- packages/next/package.json | 10 ++++++++++ packages/next/src/exports/server.ts | 1 + packages/next/src/utilities/initReq.spec.ts | 2 +- packages/tanstack-start/src/exports/server.ts | 1 + .../src/utilities/initReq.server.spec.ts | 2 +- 5 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/next/src/exports/server.ts diff --git a/packages/next/package.json b/packages/next/package.json index 782fb4262be..fb4f3c11511 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -45,6 +45,11 @@ "types": "./src/exports/routes.ts", "default": "./src/exports/routes.ts" }, + "./server": { + "import": "./src/exports/server.ts", + "types": "./src/exports/server.ts", + "default": "./src/exports/server.ts" + }, "./auth": { "import": "./src/exports/auth.ts", "types": "./src/exports/auth.ts", @@ -157,6 +162,11 @@ "types": "./dist/exports/routes.d.ts", "default": "./dist/exports/routes.js" }, + "./server": { + "import": "./dist/exports/server.js", + "types": "./dist/exports/server.d.ts", + "default": "./dist/exports/server.js" + }, "./auth": { "import": "./dist/exports/auth.js", "types": "./dist/exports/auth.d.ts", diff --git a/packages/next/src/exports/server.ts b/packages/next/src/exports/server.ts new file mode 100644 index 00000000000..a4f2f584415 --- /dev/null +++ b/packages/next/src/exports/server.ts @@ -0,0 +1 @@ +export { initReq } from '../utilities/initReq.js' diff --git a/packages/next/src/utilities/initReq.spec.ts b/packages/next/src/utilities/initReq.spec.ts index 2f4d294ee11..8b1e8c3359c 100644 --- a/packages/next/src/utilities/initReq.spec.ts +++ b/packages/next/src/utilities/initReq.spec.ts @@ -2,7 +2,7 @@ import type { ImportMap, SanitizedConfig } from 'payload' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { initReq } from './initReq.js' +import { initReq } from '@payloadcms/next/server' const { nextServerAdapter, payloadInitReq } = vi.hoisted(() => ({ nextServerAdapter: { diff --git a/packages/tanstack-start/src/exports/server.ts b/packages/tanstack-start/src/exports/server.ts index 07927879e12..d7fdc77bc50 100644 --- a/packages/tanstack-start/src/exports/server.ts +++ b/packages/tanstack-start/src/exports/server.ts @@ -6,6 +6,7 @@ export { getRequestI18n } from '../utilities/getRequestI18n.server.js' export { handleGraphQL } from '../utilities/graphqlHandler.server.js' export { handleAPIRoute } from '../utilities/handleAPIRoute.server.js' export { handleServerFunctions } from '../utilities/handleServerFunctions.js' +export { initReq } from '../utilities/initReq.server.js' export { loadAdminPage, type LoadAdminPageArgs, diff --git a/packages/tanstack-start/src/utilities/initReq.server.spec.ts b/packages/tanstack-start/src/utilities/initReq.server.spec.ts index 6c0a2f8d151..0337a3a1ac3 100644 --- a/packages/tanstack-start/src/utilities/initReq.server.spec.ts +++ b/packages/tanstack-start/src/utilities/initReq.server.spec.ts @@ -2,7 +2,7 @@ import type { ImportMap, SanitizedConfig, ServerAdapter } from 'payload' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { initReq } from './initReq.server.js' +import { initReq } from '@payloadcms/tanstack-start/server' const { getRequest, payloadInitReq, tanstackServerAdapter } = vi.hoisted(() => ({ getRequest: vi.fn(), From 61c2f19d5b2c06b8e4033fd4c76258af8c23497b Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Thu, 6 Aug 2026 16:37:59 -0400 Subject: [PATCH 12/21] test: consolidate initReq coverage --- .../next/src/utilities/initReq.rsc.spec.ts | 2 +- packages/next/src/utilities/initReq.spec.ts | 48 -------- .../src/admin/getRequestLocale.spec.ts | 104 ------------------ packages/payload/src/admin/initReq.spec.ts | 73 +++++++++++- .../src/utilities/initReq.server.spec.ts | 72 ------------ 5 files changed, 70 insertions(+), 229 deletions(-) delete mode 100644 packages/next/src/utilities/initReq.spec.ts delete mode 100644 packages/payload/src/admin/getRequestLocale.spec.ts delete mode 100644 packages/tanstack-start/src/utilities/initReq.server.spec.ts diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts index 6efe793535a..c4843a59857 100644 --- a/packages/next/src/utilities/initReq.rsc.spec.ts +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -5,7 +5,7 @@ import { createRequire } from 'node:module' import type React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { initReq } from './initReq.js' +import { initReq } from '@payloadcms/next/server' const { counters, payloadInitReq } = vi.hoisted(() => ({ counters: { diff --git a/packages/next/src/utilities/initReq.spec.ts b/packages/next/src/utilities/initReq.spec.ts deleted file mode 100644 index 8b1e8c3359c..00000000000 --- a/packages/next/src/utilities/initReq.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ImportMap, SanitizedConfig } from 'payload' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { initReq } from '@payloadcms/next/server' - -const { nextServerAdapter, payloadInitReq } = vi.hoisted(() => ({ - nextServerAdapter: { - getHeaders: vi.fn(), - }, - payloadInitReq: vi.fn(), -})) - -vi.mock('payload', () => ({ - initReq: payloadInitReq, -})) - -vi.mock('../adapters/server.js', () => ({ - nextServerAdapter, -})) - -describe('Next initReq', () => { - beforeEach(() => { - payloadInitReq.mockReset().mockResolvedValue({}) - }) - - it('should bind the Next server adapter and React request cache', async () => { - const configPromise = Promise.resolve({} as SanitizedConfig) - const importMap = {} as ImportMap - - await initReq({ - configPromise, - importMap, - key: 'RootLayout', - }) - - expect(payloadInitReq).toHaveBeenCalledWith({ - cache: { - getPartial: expect.any(Function), - getRequest: expect.any(Function), - }, - configPromise, - importMap, - key: 'RootLayout', - serverAdapter: nextServerAdapter, - }) - }) -}) diff --git a/packages/payload/src/admin/getRequestLocale.spec.ts b/packages/payload/src/admin/getRequestLocale.spec.ts deleted file mode 100644 index 19d042526a1..00000000000 --- a/packages/payload/src/admin/getRequestLocale.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { PayloadRequest, SanitizedLocalizationConfig } from '../index.js' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { getRequestLocale } from './getRequestLocale.js' - -const { findPreference, updatePreference } = vi.hoisted(() => ({ - findPreference: vi.fn(), - updatePreference: vi.fn(), -})) - -vi.mock('../preferences/operations/findOne.js', () => ({ - findOne: findPreference, -})) - -vi.mock('../preferences/operations/update.js', () => ({ - update: updatePreference, -})) - -const localization = { - defaultLocale: 'en', - locales: [ - { code: 'en', label: 'English' }, - { code: 'es', label: 'Spanish' }, - ], -} as SanitizedLocalizationConfig - -const createRequest = ({ - isLocalized = true, - locale, - user = true, -}: { - isLocalized?: boolean - locale?: string - user?: boolean -} = {}): PayloadRequest => - ({ - payload: { - config: { - localization: isLocalized ? localization : false, - }, - }, - query: locale ? { locale } : {}, - user: user - ? { - collection: 'users', - id: 'user-id', - } - : null, - }) as PayloadRequest - -describe('getRequestLocale', () => { - beforeEach(() => { - findPreference.mockReset() - updatePreference.mockReset() - }) - - it('should persist and return a valid locale from the request query', async () => { - const req = createRequest({ locale: 'es' }) - - await expect(getRequestLocale({ req })).resolves.toMatchObject({ code: 'es' }) - expect(updatePreference).toHaveBeenCalledWith({ - key: 'locale', - req, - user: req.user, - value: 'es', - }) - expect(findPreference).not.toHaveBeenCalled() - }) - - it('should return a valid stored locale when the request query omits locale', async () => { - const req = createRequest() - findPreference.mockResolvedValue({ value: 'es' }) - - await expect(getRequestLocale({ req })).resolves.toMatchObject({ code: 'es' }) - expect(findPreference).toHaveBeenCalledWith({ - key: 'locale', - req, - user: req.user, - }) - }) - - it('should fall back to the configured default locale', async () => { - findPreference.mockResolvedValue(null) - - await expect(getRequestLocale({ req: createRequest() })).resolves.toMatchObject({ code: 'en' }) - }) - - it('should ignore a query locale for an anonymous request', async () => { - await expect( - getRequestLocale({ req: createRequest({ locale: 'es', user: false }) }), - ).resolves.toMatchObject({ code: 'en' }) - expect(findPreference).not.toHaveBeenCalled() - expect(updatePreference).not.toHaveBeenCalled() - }) - - it('should return undefined when localization is disabled', async () => { - await expect( - getRequestLocale({ req: createRequest({ isLocalized: false }) }), - ).resolves.toBeUndefined() - expect(findPreference).not.toHaveBeenCalled() - expect(updatePreference).not.toHaveBeenCalled() - }) -}) diff --git a/packages/payload/src/admin/initReq.spec.ts b/packages/payload/src/admin/initReq.spec.ts index 76cd2a77446..4683b8bd484 100644 --- a/packages/payload/src/admin/initReq.spec.ts +++ b/packages/payload/src/admin/initReq.spec.ts @@ -10,10 +10,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { initReq } from './initReq.js' -const { getAccessResults, getPayload, initI18n } = vi.hoisted(() => ({ - getAccessResults: vi.fn(), - getPayload: vi.fn(), - initI18n: vi.fn(), +const { findPreference, getAccessResults, getPayload, initI18n, updatePreference } = vi.hoisted( + () => ({ + findPreference: vi.fn(), + getAccessResults: vi.fn(), + getPayload: vi.fn(), + initI18n: vi.fn(), + updatePreference: vi.fn(), + }), +) + +vi.mock('../preferences/operations/findOne.js', () => ({ + findOne: findPreference, +})) + +vi.mock('../preferences/operations/update.js', () => ({ + update: updatePreference, })) vi.mock('../index.js', () => ({ @@ -45,6 +57,17 @@ const config = { serverURL: 'https://configured.example.com', } as SanitizedConfig +const localizedConfig = { + ...config, + localization: { + defaultLocale: 'en', + locales: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Spanish' }, + ], + }, +} as SanitizedConfig + const importMap = {} as ImportMap const permissions = { canAccessAdmin: true, @@ -116,9 +139,11 @@ const createReusingCache = (): InitReqCache => { describe('initReq', () => { beforeEach(() => { authenticate.mockClear() + findPreference.mockReset().mockResolvedValue(null) getAccessResults.mockReset().mockResolvedValue(permissions) getPayload.mockReset().mockResolvedValue(payload) initI18n.mockReset().mockResolvedValue(i18n) + updatePreference.mockReset() }) it('should derive the URL and a nested query from requestURL', async () => { @@ -142,6 +167,46 @@ describe('initReq', () => { }) }) + it('should persist an authenticated request locale', async () => { + getPayload.mockResolvedValue({ ...payload, config: localizedConfig }) + + const result = await initReq({ + configPromise: localizedConfig, + importMap, + requestURL: 'https://example.com/admin?locale=es', + serverAdapter, + }) + + expect(result.locale).toMatchObject({ code: 'es' }) + expect(result.req.locale).toBe('es') + expect(updatePreference).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'locale', + value: 'es', + }), + ) + }) + + it('should ignore a request locale for an anonymous request', async () => { + authenticate.mockResolvedValueOnce({ + responseHeaders: new Headers(), + user: null, + }) + getPayload.mockResolvedValue({ ...payload, config: localizedConfig }) + + const result = await initReq({ + configPromise: localizedConfig, + importMap, + requestURL: 'https://example.com/admin?locale=es', + serverAdapter, + }) + + expect(result.locale).toMatchObject({ code: 'en' }) + expect(result.req.locale).toBe('en') + expect(findPreference).not.toHaveBeenCalled() + expect(updatePreference).not.toHaveBeenCalled() + }) + it('should prefer explicit request overrides over requestURL', async () => { const result = await initReq({ configPromise: config, diff --git a/packages/tanstack-start/src/utilities/initReq.server.spec.ts b/packages/tanstack-start/src/utilities/initReq.server.spec.ts deleted file mode 100644 index 0337a3a1ac3..00000000000 --- a/packages/tanstack-start/src/utilities/initReq.server.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ImportMap, SanitizedConfig, ServerAdapter } from 'payload' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { initReq } from '@payloadcms/tanstack-start/server' - -const { getRequest, payloadInitReq, tanstackServerAdapter } = vi.hoisted(() => ({ - getRequest: vi.fn(), - payloadInitReq: vi.fn(), - tanstackServerAdapter: { - getHeaders: vi.fn(), - }, -})) - -vi.mock('@tanstack/react-start/server', () => ({ - getRequest, -})) - -vi.mock('payload', () => ({ - initReq: payloadInitReq, -})) - -vi.mock('./serverAdapter.server.js', () => ({ - tanstackServerAdapter, -})) - -describe('TanStack Start initReq', () => { - beforeEach(() => { - getRequest.mockReturnValue({ - url: 'https://example.com/admin?locale=es', - }) - payloadInitReq.mockReset().mockResolvedValue({}) - }) - - it('should bind the active request URL and default server adapter', async () => { - const configPromise = Promise.resolve({} as SanitizedConfig) - const importMap = {} as ImportMap - - await initReq({ - configPromise, - importMap, - }) - - expect(payloadInitReq).toHaveBeenCalledWith({ - configPromise, - importMap, - requestURL: 'https://example.com/admin?locale=es', - serverAdapter: tanstackServerAdapter, - }) - }) - - it('should allow the page render to override the server adapter', async () => { - const configPromise = Promise.resolve({} as SanitizedConfig) - const importMap = {} as ImportMap - const pageServerAdapter = { - getHeaders: vi.fn(), - } as unknown as ServerAdapter - - await initReq({ - configPromise, - importMap, - serverAdapter: pageServerAdapter, - }) - - expect(payloadInitReq).toHaveBeenCalledWith({ - configPromise, - importMap, - requestURL: 'https://example.com/admin?locale=es', - serverAdapter: pageServerAdapter, - }) - }) -}) From 3e111b5f4c8e59f74b9ecf35d156170315b9a004 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Thu, 6 Aug 2026 21:49:58 -0400 Subject: [PATCH 13/21] chore: trigger CI From bd50008559823b39ce5f528c5ed5471d7b93bad4 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Fri, 7 Aug 2026 16:29:04 -0400 Subject: [PATCH 14/21] fix(next): harden initReq cache contract --- packages/next/src/utilities/initReq.ts | 6 +++++- vitest.config.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 2dc662043ee..83c96e71393 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -13,7 +13,11 @@ const cache: InitReqCache = { getRequest: (factory, key) => reqCache.get(factory, key), } -export const initReq = (args: Omit) => +type NextInitReqArgs = { + key: string +} & Omit + +export const initReq = (args: NextInitReqArgs) => payloadInitReq({ ...args, cache, diff --git a/vitest.config.ts b/vitest.config.ts index 8dda2bbb310..37654926242 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -52,7 +52,7 @@ export default defineConfig({ }, }, test: { - exclude: ['packages/next/**/*.rsc.spec.ts'], + exclude: ['**/*.rsc.spec.ts'], include: ['packages/**/*.spec.ts', 'tools/**/*.spec.ts'], name: 'unit', environment: 'node', From 37a6312daa2148c6c6b5abb1e2ac3ffa9c41615b Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Fri, 7 Aug 2026 16:43:06 -0400 Subject: [PATCH 15/21] refactor: internalize framework initReq wrappers --- packages/next/package.json | 10 ---------- packages/next/src/exports/server.ts | 1 - packages/next/src/utilities/initReq.rsc.spec.ts | 2 +- packages/payload/src/admin/initReq.ts | 6 ++++++ packages/tanstack-start/src/exports/server.ts | 1 - 5 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 packages/next/src/exports/server.ts diff --git a/packages/next/package.json b/packages/next/package.json index 033780edc4d..fd7ec52a24c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -45,11 +45,6 @@ "types": "./src/exports/routes.ts", "default": "./src/exports/routes.ts" }, - "./server": { - "import": "./src/exports/server.ts", - "types": "./src/exports/server.ts", - "default": "./src/exports/server.ts" - }, "./auth": { "import": "./src/exports/auth.ts", "types": "./src/exports/auth.ts", @@ -162,11 +157,6 @@ "types": "./dist/exports/routes.d.ts", "default": "./dist/exports/routes.js" }, - "./server": { - "import": "./dist/exports/server.js", - "types": "./dist/exports/server.d.ts", - "default": "./dist/exports/server.js" - }, "./auth": { "import": "./dist/exports/auth.js", "types": "./dist/exports/auth.d.ts", diff --git a/packages/next/src/exports/server.ts b/packages/next/src/exports/server.ts deleted file mode 100644 index a4f2f584415..00000000000 --- a/packages/next/src/exports/server.ts +++ /dev/null @@ -1 +0,0 @@ -export { initReq } from '../utilities/initReq.js' diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts index c4843a59857..6efe793535a 100644 --- a/packages/next/src/utilities/initReq.rsc.spec.ts +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -5,7 +5,7 @@ import { createRequire } from 'node:module' import type React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { initReq } from '@payloadcms/next/server' +import { initReq } from './initReq.js' const { counters, payloadInitReq } = vi.hoisted(() => ({ counters: { diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index 466ae836242..1a88dc26c44 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -38,6 +38,12 @@ export type InitReqArgs = { serverAdapter: ServerAdapter } +/** + * Initializes the request state used by framework adapters to render the admin panel. + * + * @internal This integration point is intended for official Payload framework adapters. + * It may change without notice. + */ export async function initReq({ cache, canSetHeaders, diff --git a/packages/tanstack-start/src/exports/server.ts b/packages/tanstack-start/src/exports/server.ts index d7fdc77bc50..07927879e12 100644 --- a/packages/tanstack-start/src/exports/server.ts +++ b/packages/tanstack-start/src/exports/server.ts @@ -6,7 +6,6 @@ export { getRequestI18n } from '../utilities/getRequestI18n.server.js' export { handleGraphQL } from '../utilities/graphqlHandler.server.js' export { handleAPIRoute } from '../utilities/handleAPIRoute.server.js' export { handleServerFunctions } from '../utilities/handleServerFunctions.js' -export { initReq } from '../utilities/initReq.server.js' export { loadAdminPage, type LoadAdminPageArgs, From 2bb566d5e24748e48ed305fb9dc19334eb15f81c Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Tue, 22 Sep 2026 14:46:09 -0400 Subject: [PATCH 16/21] fix(next): cache locale preference resolution --- .../next/src/utilities/initReq.rsc.spec.ts | 19 +++++++++ packages/next/src/utilities/initReq.ts | 2 + packages/payload/src/admin/initReq.spec.ts | 39 +++++++++++++++++++ packages/payload/src/admin/initReq.ts | 17 +++++++- 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts index a5a0dfe6abc..de90bbccfea 100644 --- a/packages/next/src/utilities/initReq.rsc.spec.ts +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -9,6 +9,7 @@ import { initReq } from './initReq.js' const { counters, payloadInitReq } = vi.hoisted(() => ({ counters: { + locale: 0, partial: 0, request: 0, }, @@ -85,6 +86,7 @@ async function renderNavigationWithOverrides(): Promise { describe('Next initReq RSC cache', () => { beforeEach(() => { + counters.locale = 0 counters.partial = 0 counters.request = 0 payloadInitReq.mockReset().mockImplementation(async ({ cache, key, overrides }) => { @@ -96,6 +98,20 @@ describe('Next initReq RSC cache', () => { return cache.getRequest( async () => { counters.request += 1 + + if (cache.getLocale) { + await cache.getLocale( + async () => { + counters.locale += 1 + return { locale: undefined } + }, + 'payload', + 'users', + 'user-id', + undefined, + ) + } + return {} }, key, @@ -108,6 +124,7 @@ describe('Next initReq RSC cache', () => { await renderNavigation() expect(counters).toEqual({ + locale: 1, partial: 1, request: 2, }) @@ -115,6 +132,7 @@ describe('Next initReq RSC cache', () => { await renderNavigation() expect(counters).toEqual({ + locale: 2, partial: 2, request: 4, }) @@ -124,6 +142,7 @@ describe('Next initReq RSC cache', () => { await renderNavigationWithOverrides() expect(counters).toEqual({ + locale: 1, partial: 1, request: 2, }) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index e876eca2bc7..684363980f1 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -6,9 +6,11 @@ import { nextServerAdapter } from '../adapters/server.js' import { selectiveCache } from './selectiveCache.js' const partialReqCache = selectiveCache('partialReq') +const localeCache = selectiveCache>('locale') const reqCache = selectiveCache('req') const cache: InitReqCache = { + getLocale: (factory, ...cacheArgs) => localeCache.get(factory, ...cacheArgs), getPartial: (factory) => partialReqCache.get(factory, 'global'), getRequest: (factory, key, ...cacheArgs) => reqCache.get(factory, key, ...cacheArgs), } diff --git a/packages/payload/src/admin/initReq.spec.ts b/packages/payload/src/admin/initReq.spec.ts index 44e122580df..db8ae6d2ad1 100644 --- a/packages/payload/src/admin/initReq.spec.ts +++ b/packages/payload/src/admin/initReq.spec.ts @@ -141,6 +141,10 @@ const createExecutingCache = (): InitReqCache => ({ }) const createReusingCache = (): InitReqCache => { + const localeResults: Array<{ + cacheArgs: unknown[] + result: Pick + }> = [] let partialResult: InitReqPartialResult | undefined const requestResults: Array<{ cacheArgs: unknown[] @@ -149,6 +153,22 @@ const createReusingCache = (): InitReqCache => { }> = [] return { + getLocale: vi.fn(async (factory, ...cacheArgs) => { + const cached = localeResults.find( + (entry) => + entry.cacheArgs.length === cacheArgs.length && + entry.cacheArgs.every((arg, index) => arg === cacheArgs[index]), + ) + + if (cached) { + return cached.result + } + + const result = await factory() + localeResults.push({ cacheArgs, result }) + + return result + }), getPartial: vi.fn(async (factory) => { partialResult ??= await factory() return partialResult @@ -413,6 +433,25 @@ describe('initReq', () => { expect(second.user).toMatchObject({ source: 'second' }) }) + it('should reuse locale preference resolution across request cache keys', async () => { + getPayload.mockResolvedValue({ ...payload, config: localizedConfig }) + findPreference.mockResolvedValue({ value: 'es' }) + const cache = createReusingCache() + const args = { + cache, + configPromise: localizedConfig, + importMap, + serverAdapter, + } + + const rootLayoutResult = await initReq({ ...args, key: 'RootLayout' }) + const pageResult = await initReq({ ...args, key: 'initPage' }) + + expect(rootLayoutResult.locale).toMatchObject({ code: 'es' }) + expect(pageResult.locale).toMatchObject({ code: 'es' }) + expect(findPreference).toHaveBeenCalledOnce() + }) + it('should use anonymous access when user read access fails', async () => { applyUserReadAccess.mockRejectedValueOnce(new Error('read access failed')) diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index 1b22375dd16..ab960a6d9b4 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -24,6 +24,10 @@ export type InitReqPartialResult = { Pick export type InitReqCache = { + getLocale?: ( + factory: () => Promise>, + ...cacheArgs: unknown[] + ) => Promise> getPartial: (factory: () => Promise) => Promise getRequest: ( factory: () => Promise, @@ -130,7 +134,18 @@ export async function initReq({ req.user = null } - const locale = await getRequestLocale({ req }) + const resolveLocale = async (): Promise> => ({ + locale: await getRequestLocale({ req }), + }) + const { locale } = cache?.getLocale + ? await cache.getLocale( + resolveLocale, + payload, + req.user?.collection, + req.user?.id, + req.query.locale, + ) + : await resolveLocale() req.locale = locale?.code From 31b5cad691dafd26586411616e971c68d1ce17a1 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Tue, 22 Sep 2026 15:45:33 -0400 Subject: [PATCH 17/21] refactor: clarify initReq adapter alias --- packages/next/src/utilities/initReq.rsc.spec.ts | 8 ++++---- packages/next/src/utilities/initReq.ts | 4 ++-- .../src/utilities/initReq.server.spec.ts | 12 ++++++------ .../tanstack-start/src/utilities/initReq.server.ts | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts index de90bbccfea..12f415f2658 100644 --- a/packages/next/src/utilities/initReq.rsc.spec.ts +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -7,19 +7,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { initReq } from './initReq.js' -const { counters, payloadInitReq } = vi.hoisted(() => ({ +const { counters, initPayloadReq } = vi.hoisted(() => ({ counters: { locale: 0, partial: 0, request: 0, }, - payloadInitReq: vi.fn(), + initPayloadReq: vi.fn(), })) vi.mock('react', () => createRequire(import.meta.url)('react')) vi.mock('payload', () => ({ - initReq: payloadInitReq, + initReq: initPayloadReq, })) vi.mock('../adapters/server.js', () => ({ @@ -89,7 +89,7 @@ describe('Next initReq RSC cache', () => { counters.locale = 0 counters.partial = 0 counters.request = 0 - payloadInitReq.mockReset().mockImplementation(async ({ cache, key, overrides }) => { + initPayloadReq.mockReset().mockImplementation(async ({ cache, key, overrides }) => { await cache.getPartial(async () => { counters.partial += 1 return {} diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 684363980f1..adc65eb3101 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -1,6 +1,6 @@ import type { InitReqArgs, InitReqCache, InitReqPartialResult, InitReqResult } from 'payload' -import { initReq as payloadInitReq } from 'payload' +import { initReq as initPayloadReq } from 'payload' import { nextServerAdapter } from '../adapters/server.js' import { selectiveCache } from './selectiveCache.js' @@ -20,7 +20,7 @@ type NextInitReqArgs = { } & Omit export const initReq = (args: NextInitReqArgs) => - payloadInitReq({ + initPayloadReq({ ...args, cache, serverAdapter: nextServerAdapter, diff --git a/packages/tanstack-start/src/utilities/initReq.server.spec.ts b/packages/tanstack-start/src/utilities/initReq.server.spec.ts index 5f95f3074df..e9541648101 100644 --- a/packages/tanstack-start/src/utilities/initReq.server.spec.ts +++ b/packages/tanstack-start/src/utilities/initReq.server.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getRequest, payloadInitReq, tanstackServerAdapter } = vi.hoisted(() => ({ +const { getRequest, initPayloadReq, tanstackServerAdapter } = vi.hoisted(() => ({ getRequest: vi.fn(), - payloadInitReq: vi.fn(), + initPayloadReq: vi.fn(), tanstackServerAdapter: { getHeaders: vi.fn(), }, @@ -13,7 +13,7 @@ vi.mock('@tanstack/react-start/server', () => ({ })) vi.mock('payload', () => ({ - initReq: payloadInitReq, + initReq: initPayloadReq, })) vi.mock('./devConfigReload.server.js', () => ({})) @@ -27,7 +27,7 @@ import { initReq } from './initReq.server.js' describe('initReq', () => { beforeEach(() => { getRequest.mockReset().mockReturnValue(new Request('http://localhost/admin?locale=es')) - payloadInitReq.mockReset().mockResolvedValue({}) + initPayloadReq.mockReset().mockResolvedValue({}) }) it('should provide the active request URL and default server adapter', async () => { @@ -38,7 +38,7 @@ describe('initReq', () => { await initReq(args) - expect(payloadInitReq).toHaveBeenCalledWith({ + expect(initPayloadReq).toHaveBeenCalledWith({ ...args, requestURL: 'http://localhost/admin?locale=es', serverAdapter: tanstackServerAdapter, @@ -56,7 +56,7 @@ describe('initReq', () => { serverAdapter: serverAdapter as never, }) - expect(payloadInitReq).toHaveBeenCalledWith( + expect(initPayloadReq).toHaveBeenCalledWith( expect.objectContaining({ serverAdapter, }), diff --git a/packages/tanstack-start/src/utilities/initReq.server.ts b/packages/tanstack-start/src/utilities/initReq.server.ts index 94e49cc8205..d2c8f08c4ab 100644 --- a/packages/tanstack-start/src/utilities/initReq.server.ts +++ b/packages/tanstack-start/src/utilities/initReq.server.ts @@ -1,9 +1,9 @@ import type { InitReqArgs, ServerAdapter } from 'payload' import { getRequest } from '@tanstack/react-start/server' -import { initReq as payloadInitReq } from 'payload' +import { initReq as initPayloadReq } from 'payload' -// Registers the dev reload strategy before `payloadInitReq` can build an instance. +// Registers the dev reload strategy before `initPayloadReq` can build an instance. // Side-effect only, and a no-op outside of dev serve. import './devConfigReload.server.js' import { tanstackServerAdapter } from './serverAdapter.server.js' @@ -13,7 +13,7 @@ type TanStackInitReqArgs = { } & Omit export const initReq = ({ serverAdapter = tanstackServerAdapter, ...args }: TanStackInitReqArgs) => - payloadInitReq({ + initPayloadReq({ ...args, requestURL: getRequest().url, serverAdapter, From 674c7664de44e0b4186eb5886645e1a6b2fc7694 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Tue, 22 Sep 2026 15:57:01 -0400 Subject: [PATCH 18/21] docs(payload): explain request locale resolution --- packages/payload/src/admin/getRequestLocale.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/payload/src/admin/getRequestLocale.ts b/packages/payload/src/admin/getRequestLocale.ts index 608bb651258..49ca8dccfc3 100644 --- a/packages/payload/src/admin/getRequestLocale.ts +++ b/packages/payload/src/admin/getRequestLocale.ts @@ -3,6 +3,7 @@ import type { Locale, PayloadRequest, SanitizedLocalizationConfig } from '../ind import { findOne as findPreference } from '../preferences/operations/findOne.js' import { update as updatePreference } from '../preferences/operations/update.js' +/** Resolves the request locale from query params, user preferences, or the configured default, persisting an explicit user selection. */ export async function getRequestLocale({ req, }: { From f78fbc4ffb9bf1e9652e047ca0735730a158f530 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Tue, 22 Sep 2026 15:58:47 -0400 Subject: [PATCH 19/21] docs(payload): explain initReq cache lifecycle --- packages/payload/src/admin/initReq.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index ab960a6d9b4..d4945c5c862 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -23,12 +23,16 @@ export type InitReqPartialResult = { } & Pick & Pick +/** Framework-provided request-scoped caching hooks used to deduplicate request initialization. */ export type InitReqCache = { + /** Reuses locale preference resolution across request results. */ getLocale?: ( factory: () => Promise>, ...cacheArgs: unknown[] ) => Promise> + /** Reuses Payload, i18n, and authentication state within the current request. */ getPartial: (factory: () => Promise) => Promise + /** Reuses a complete initialized request for the supplied key and cache arguments. */ getRequest: ( factory: () => Promise, key: string, @@ -37,10 +41,15 @@ export type InitReqCache = { } export type InitReqArgs = { + /** + * Optional framework-owned request-scoped cache. + * Framework adapters control its lifetime to prevent request state from leaking between requests. + */ cache?: InitReqCache canSetHeaders?: boolean configPromise: Promise | SanitizedConfig importMap: ImportMap + /** Identifies the complete request result within `cache`; required when a cache is supplied. */ key?: string overrides?: Parameters[0] requestURL?: string From 8e9e2404eac35f7d769bcfad511ae142a13ccef2 Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Tue, 22 Sep 2026 23:40:30 -0400 Subject: [PATCH 20/21] chore: move initReq to internal export --- packages/next/src/utilities/initReq.rsc.spec.ts | 2 +- packages/next/src/utilities/initReq.ts | 5 +++-- packages/payload/src/exports/internal.ts | 6 ++++++ packages/payload/src/index.ts | 6 ------ .../tanstack-start/src/utilities/initReq.server.spec.ts | 2 +- packages/tanstack-start/src/utilities/initReq.server.ts | 5 +++-- packages/ui/src/layouts/Root/index.tsx | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/next/src/utilities/initReq.rsc.spec.ts b/packages/next/src/utilities/initReq.rsc.spec.ts index 12f415f2658..9e71d6091ff 100644 --- a/packages/next/src/utilities/initReq.rsc.spec.ts +++ b/packages/next/src/utilities/initReq.rsc.spec.ts @@ -18,7 +18,7 @@ const { counters, initPayloadReq } = vi.hoisted(() => ({ vi.mock('react', () => createRequire(import.meta.url)('react')) -vi.mock('payload', () => ({ +vi.mock('payload/internal', () => ({ initReq: initPayloadReq, })) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index adc65eb3101..29f17ef26d3 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -1,6 +1,7 @@ -import type { InitReqArgs, InitReqCache, InitReqPartialResult, InitReqResult } from 'payload' +import type { InitReqResult } from 'payload' +import type { InitReqArgs, InitReqCache, InitReqPartialResult } from 'payload/internal' -import { initReq as initPayloadReq } from 'payload' +import { initReq as initPayloadReq } from 'payload/internal' import { nextServerAdapter } from '../adapters/server.js' import { selectiveCache } from './selectiveCache.js' diff --git a/packages/payload/src/exports/internal.ts b/packages/payload/src/exports/internal.ts index 2d45eb0d565..f859cef43b1 100644 --- a/packages/payload/src/exports/internal.ts +++ b/packages/payload/src/exports/internal.ts @@ -2,6 +2,12 @@ * Modules exported here are not part of the public API and are subject to change without notice and without a major version bump. */ +export { + initReq, + type InitReqArgs, + type InitReqCache, + type InitReqPartialResult, +} from '../admin/initReq.js' export { applyUserReadAccess } from '../auth/applyUserReadAccess.js' export { createCLI } from '../cli/index.js' export { getCommandInput } from '../cli/runtime/getCommandInput.js' diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 0b73fad5e05..f9356d8efd1 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -124,12 +124,6 @@ import { } from './globals/operations/local/update.js' export type * from './admin/adapters/index.js' export type { FieldState } from './admin/forms/Form.js' -export { - initReq, - type InitReqArgs, - type InitReqCache, - type InitReqPartialResult, -} from './admin/initReq.js' export type * from './admin/types.js' export { EntityType } from './admin/views/dashboard.js' import type { SupportedLanguages } from '@payloadcms/translations' diff --git a/packages/tanstack-start/src/utilities/initReq.server.spec.ts b/packages/tanstack-start/src/utilities/initReq.server.spec.ts index e9541648101..45f3d9ca1ef 100644 --- a/packages/tanstack-start/src/utilities/initReq.server.spec.ts +++ b/packages/tanstack-start/src/utilities/initReq.server.spec.ts @@ -12,7 +12,7 @@ vi.mock('@tanstack/react-start/server', () => ({ getRequest, })) -vi.mock('payload', () => ({ +vi.mock('payload/internal', () => ({ initReq: initPayloadReq, })) diff --git a/packages/tanstack-start/src/utilities/initReq.server.ts b/packages/tanstack-start/src/utilities/initReq.server.ts index d2c8f08c4ab..8a3b4675bba 100644 --- a/packages/tanstack-start/src/utilities/initReq.server.ts +++ b/packages/tanstack-start/src/utilities/initReq.server.ts @@ -1,7 +1,8 @@ -import type { InitReqArgs, ServerAdapter } from 'payload' +import type { ServerAdapter } from 'payload' +import type { InitReqArgs } from 'payload/internal' import { getRequest } from '@tanstack/react-start/server' -import { initReq as initPayloadReq } from 'payload' +import { initReq as initPayloadReq } from 'payload/internal' // Registers the dev reload strategy before `initPayloadReq` can build an instance. // Side-effect only, and a no-op outside of dev serve. diff --git a/packages/ui/src/layouts/Root/index.tsx b/packages/ui/src/layouts/Root/index.tsx index a6e90d7d30c..cca7717082d 100644 --- a/packages/ui/src/layouts/Root/index.tsx +++ b/packages/ui/src/layouts/Root/index.tsx @@ -1,11 +1,11 @@ import type { ImportMap, - InitReqArgs, InitReqResult, LanguageOptions, SanitizedConfig, ServerFunctionClient, } from 'payload' +import type { InitReqArgs } from 'payload/internal' import { applyLocaleFiltering } from 'payload/shared' import React, { Suspense } from 'react' From 4ff86ebcf342971211976bc54c8daa6519f55dba Mon Sep 17 00:00:00 2001 From: Jake Fletcher Date: Wed, 23 Sep 2026 00:14:36 -0400 Subject: [PATCH 21/21] chore: clarify initReq cache naming --- packages/next/src/utilities/initReq.ts | 15 ++++++++++----- packages/next/src/utilities/selectiveCache.ts | 19 +++++++++++-------- packages/payload/src/admin/initReq.spec.ts | 18 ++++++++++-------- packages/payload/src/admin/initReq.ts | 11 +++++------ 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 29f17ef26d3..13b5bf7d8ea 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -6,14 +6,19 @@ import { initReq as initPayloadReq } from 'payload/internal' import { nextServerAdapter } from '../adapters/server.js' import { selectiveCache } from './selectiveCache.js' -const partialReqCache = selectiveCache('partialReq') +const partialResultCache = selectiveCache('partialResult') const localeCache = selectiveCache>('locale') -const reqCache = selectiveCache('req') +const requestResultCache = selectiveCache('requestResult') const cache: InitReqCache = { - getLocale: (factory, ...cacheArgs) => localeCache.get(factory, ...cacheArgs), - getPartial: (factory) => partialReqCache.get(factory, 'global'), - getRequest: (factory, key, ...cacheArgs) => reqCache.get(factory, key, ...cacheArgs), + getLocale: (resolveLocale, ...key) => localeCache.get({ create: resolveLocale, key }), + getPartial: (createPartialResult) => + partialResultCache.get({ create: createPartialResult, key: ['global'] }), + getRequest: (createRequestResult, key, ...cacheArgs) => + requestResultCache.get({ + create: createRequestResult, + key: [key, ...cacheArgs], + }), } type NextInitReqArgs = { diff --git a/packages/next/src/utilities/selectiveCache.ts b/packages/next/src/utilities/selectiveCache.ts index 2f559801f11..208031998fb 100644 --- a/packages/next/src/utilities/selectiveCache.ts +++ b/packages/next/src/utilities/selectiveCache.ts @@ -2,6 +2,13 @@ import { cache } from 'react' type CachedValue = object +type GetCachedArgs = { + /** Creates the value when it is not cached. */ + create: () => Promise + /** Values that identify the cached value. */ + key: unknown[] +} + // Module-scoped cache container that holds all cached, stable containers // - these may hold the stable value, or a promise to the stable value const globalCacheContainer: Record< @@ -21,21 +28,17 @@ const globalCacheContainer: Record< */ export function selectiveCache(namespace: string) { if (!globalCacheContainer[namespace]) { - globalCacheContainer[namespace] = cache((...args) => ({ + globalCacheContainer[namespace] = cache((..._args) => ({ value: null, })) } /** * Gets or creates a cached value for a specific key within the namespace - * - * @param key - The key to identify the cached value - * @param factory - A function that produces the value if not cached - * @returns The cached or newly created value */ - const getCached = async (factory: () => Promise, ...cacheArgs): Promise => { + const getCached = async ({ create, key }: GetCachedArgs): Promise => { const stableObjectFn = globalCacheContainer[namespace] - const stableObject = stableObjectFn(...cacheArgs) + const stableObject = stableObjectFn(...key) if ( stableObject?.value && @@ -45,7 +48,7 @@ export function selectiveCache(namespace: s return await stableObject.value } - stableObject.value = factory() + stableObject.value = create() return await stableObject.value } diff --git a/packages/payload/src/admin/initReq.spec.ts b/packages/payload/src/admin/initReq.spec.ts index db8ae6d2ad1..e6b4fcf5f34 100644 --- a/packages/payload/src/admin/initReq.spec.ts +++ b/packages/payload/src/admin/initReq.spec.ts @@ -136,8 +136,10 @@ const serverAdapter = { } as unknown as ServerAdapter const createExecutingCache = (): InitReqCache => ({ - getPartial: vi.fn((factory: () => Promise) => factory()), - getRequest: vi.fn((factory: () => Promise) => factory()), + getPartial: vi.fn((createPartialResult: () => Promise) => + createPartialResult(), + ), + getRequest: vi.fn((createRequestResult: () => Promise) => createRequestResult()), }) const createReusingCache = (): InitReqCache => { @@ -153,7 +155,7 @@ const createReusingCache = (): InitReqCache => { }> = [] return { - getLocale: vi.fn(async (factory, ...cacheArgs) => { + getLocale: vi.fn(async (resolveLocale, ...cacheArgs) => { const cached = localeResults.find( (entry) => entry.cacheArgs.length === cacheArgs.length && @@ -164,16 +166,16 @@ const createReusingCache = (): InitReqCache => { return cached.result } - const result = await factory() + const result = await resolveLocale() localeResults.push({ cacheArgs, result }) return result }), - getPartial: vi.fn(async (factory) => { - partialResult ??= await factory() + getPartial: vi.fn(async (createPartialResult) => { + partialResult ??= await createPartialResult() return partialResult }), - getRequest: vi.fn(async (factory, key, ...cacheArgs) => { + getRequest: vi.fn(async (createRequestResult, key, ...cacheArgs) => { const cached = requestResults.find( (entry) => entry.key === key && @@ -185,7 +187,7 @@ const createReusingCache = (): InitReqCache => { return cached.result } - const result = await factory() + const result = await createRequestResult() requestResults.push({ cacheArgs, key, result }) return result diff --git a/packages/payload/src/admin/initReq.ts b/packages/payload/src/admin/initReq.ts index d4945c5c862..e56e05b7f9e 100644 --- a/packages/payload/src/admin/initReq.ts +++ b/packages/payload/src/admin/initReq.ts @@ -27,14 +27,16 @@ export type InitReqPartialResult = { export type InitReqCache = { /** Reuses locale preference resolution across request results. */ getLocale?: ( - factory: () => Promise>, + resolveLocale: () => Promise>, ...cacheArgs: unknown[] ) => Promise> /** Reuses Payload, i18n, and authentication state within the current request. */ - getPartial: (factory: () => Promise) => Promise + getPartial: ( + createPartialResult: () => Promise, + ) => Promise /** Reuses a complete initialized request for the supplied key and cache arguments. */ getRequest: ( - factory: () => Promise, + createRequestResult: () => Promise, key: string, ...cacheArgs: unknown[] ) => Promise @@ -58,9 +60,6 @@ export type InitReqArgs = { /** * Initializes the request state used by framework adapters to render the admin panel. - * - * @internal This integration point is intended for official Payload framework adapters. - * It may change without notice. */ export async function initReq({ cache,