Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8bb575
refactor: move admin locale resolution to payload
jacobsfletch Jul 30, 2026
5eefd8f
refactor: centralize initReq in payload
jacobsfletch Jul 30, 2026
56c5435
refactor(next): own initReq request caching
jacobsfletch Jul 30, 2026
cf6b13a
refactor(tanstack-start): use payload initReq
jacobsfletch Jul 30, 2026
c20f436
refactor: clarify initReq result factories
jacobsfletch Jul 30, 2026
0b7d53d
refactor(tanstack-start): bind initReq request context
jacobsfletch Jul 30, 2026
b5ce5c1
refactor(tanstack-start): pass initReq directly
jacobsfletch Jul 30, 2026
9470e94
test(next): protect initReq request caching
jacobsfletch Jul 30, 2026
381b1f7
fix(payload): ignore locale query for anonymous requests
jacobsfletch Jul 30, 2026
4c4410e
cleanup
jacobsfletch Jul 30, 2026
cdbc5c4
refactor: expose framework initReq entrypoints
jacobsfletch Aug 6, 2026
ba15367
Merge origin/main into refactor/consolidate-initreq
jacobsfletch Aug 6, 2026
61c2f19
test: consolidate initReq coverage
jacobsfletch Aug 6, 2026
3e111b5
chore: trigger CI
jacobsfletch Aug 7, 2026
bd50008
fix(next): harden initReq cache contract
jacobsfletch Aug 7, 2026
37a6312
refactor: internalize framework initReq wrappers
jacobsfletch Aug 7, 2026
69ba189
Merge branch 'main' into refactor/consolidate-initreq
jacobsfletch Sep 22, 2026
2bb566d
fix(next): cache locale preference resolution
jacobsfletch Sep 22, 2026
31b5cad
refactor: clarify initReq adapter alias
jacobsfletch Sep 22, 2026
674c766
docs(payload): explain request locale resolution
jacobsfletch Sep 22, 2026
f78fbc4
docs(payload): explain initReq cache lifecycle
jacobsfletch Sep 22, 2026
8e9e240
chore: move initReq to internal export
jacobsfletch Sep 23, 2026
4ff86eb
chore: clarify initReq cache naming
jacobsfletch Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,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",
"test:visual": "bash ./test/scripts/run-visual-docker.sh",
"test:visual:preview": "bash ./test/scripts/preview-visual-diff.sh",
"test:visual:update": "pnpm test:visual -- --update-snapshots",
Expand Down
5 changes: 2 additions & 3 deletions packages/next/src/adapters/handleServerFunctions.ts
Original file line number Diff line number Diff line change
@@ -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' }),
})
6 changes: 3 additions & 3 deletions packages/next/src/adapters/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,7 +34,7 @@ export { metadata } from '@payloadcms/ui/layouts'

type Props = Omit<
React.ComponentProps<typeof UIRootLayout>,
'additionalDependencyChecks' | 'fonts' | 'RouterAdapter' | 'serverAdapter'
'additionalDependencyChecks' | 'fonts' | 'initReq' | 'RouterAdapter'
>

export const RootLayout = (props: Props) => (
Expand All @@ -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}
/>
)
11 changes: 3 additions & 8 deletions packages/next/src/adapters/views.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof renderRoot>[0]['initReq'] = (args) =>
initReq({ ...args, serverAdapter: nextServerAdapter })
import { initReq } from '../utilities/initReq.js'

export const adminViews: AdminViewAdapter<AdminViewServerProps, MetaConfig> = defaultAdminViews

Expand All @@ -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 })
150 changes: 150 additions & 0 deletions packages/next/src/utilities/initReq.rsc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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, initPayloadReq } = vi.hoisted(() => ({
counters: {
locale: 0,
partial: 0,
request: 0,
},
initPayloadReq: vi.fn(),
}))

vi.mock('react', () => createRequire(import.meta.url)('react'))

vi.mock('payload/internal', () => ({
initReq: initPayloadReq,
}))

vi.mock('../adapters/server.js', () => ({
nextServerAdapter: {
getHeaders: vi.fn(),
},
}))

type RscRenderer = {
renderToReadableStream: (
model: React.ReactNode,
moduleMap: Record<string, unknown>,
) => ReadableStream<Uint8Array>
}

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<void> {
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()
}

async function renderNavigationWithOverrides(): Promise<void> {
async function Navigation() {
await Promise.all([
initReq({
configPromise,
importMap,
key: 'initPage',
overrides: { context: { source: 'first' } },
}),
initReq({
configPromise,
importMap,
key: 'initPage',
overrides: { context: { source: 'second' } },
}),
])

return null
}

const stream = renderToReadableStream(React.createElement(Navigation), {})

await new Response(stream).arrayBuffer()
}

describe('Next initReq RSC cache', () => {
beforeEach(() => {
counters.locale = 0
counters.partial = 0
counters.request = 0
initPayloadReq.mockReset().mockImplementation(async ({ cache, key, overrides }) => {
await cache.getPartial(async () => {
counters.partial += 1
return {}
})

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,
overrides,
)
})
})

it('should deduplicate initialization per navigation and reset between navigations', async () => {
await renderNavigation()

expect(counters).toEqual({
locale: 1,
partial: 1,
request: 2,
})

await renderNavigation()

expect(counters).toEqual({
locale: 2,
partial: 2,
request: 4,
})
})

it('should isolate requests with the same key and different overrides', async () => {
await renderNavigationWithOverrides()

expect(counters).toEqual({
locale: 1,
partial: 1,
request: 2,
})
})
})
33 changes: 33 additions & 0 deletions packages/next/src/utilities/initReq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { InitReqResult } from 'payload'
import type { InitReqArgs, InitReqCache, InitReqPartialResult } from 'payload/internal'

import { initReq as initPayloadReq } from 'payload/internal'

import { nextServerAdapter } from '../adapters/server.js'
import { selectiveCache } from './selectiveCache.js'

const partialResultCache = selectiveCache<InitReqPartialResult>('partialResult')
const localeCache = selectiveCache<Pick<InitReqResult, 'locale'>>('locale')
const requestResultCache = selectiveCache<InitReqResult>('requestResult')

const cache: InitReqCache = {
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 = {
key: string
} & Omit<InitReqArgs, 'cache' | 'key' | 'serverAdapter'>

export const initReq = (args: NextInitReqArgs) =>
initPayloadReq({
...args,
cache,
serverAdapter: nextServerAdapter,
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { cache } from 'react'

type CachedValue = object

type GetCachedArgs<TValue extends object> = {
/** Creates the value when it is not cached. */
create: () => Promise<TValue>
/** 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<
Expand All @@ -21,21 +28,17 @@ const globalCacheContainer: Record<
*/
export function selectiveCache<TValue extends object = CachedValue>(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<TValue>, ...cacheArgs): Promise<TValue> => {
const getCached = async ({ create, key }: GetCachedArgs<TValue>): Promise<TValue> => {
const stableObjectFn = globalCacheContainer[namespace]
const stableObject = stableObjectFn<TValue>(...cacheArgs)
const stableObject = stableObjectFn<TValue>(...key)

if (
stableObject?.value &&
Expand All @@ -45,7 +48,7 @@ export function selectiveCache<TValue extends object = CachedValue>(namespace: s
return await stableObject.value
}

stableObject.value = factory()
stableObject.value = create()

return await stableObject.value
}
Expand Down
63 changes: 63 additions & 0 deletions packages/payload/src/admin/getRequestLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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'

/** Resolves the request locale from query params, user preferences, or the configured default, persisting an explicit user selection. */
export async function getRequestLocale({
req,
}: {
req: PayloadRequest
}): Promise<Locale | undefined> {
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 = req.user
? localeFromParams || (preference?.value as string | undefined)
: 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
}
Loading
Loading