diff --git a/packages/tanstack-start/src/withPayload/index.ts b/packages/tanstack-start/src/withPayload/index.ts index 6d6e6c20b4e..a3f112330e3 100644 --- a/packages/tanstack-start/src/withPayload/index.ts +++ b/packages/tanstack-start/src/withPayload/index.ts @@ -21,6 +21,7 @@ import { onImportProtectionViolation, serverOnlyClientSpecifiers, } from './importProtection.js' +import { payloadWarmAdmin } from './warmAdmin.js' import { clientModuleResolution } from './workarounds/clientModuleResolution.js' import { payloadDevTransforms } from './workarounds/devTransforms.js' import { reactDomServerInRsc } from './workarounds/reactDomServerInRsc.js' @@ -94,6 +95,20 @@ export type WithPayloadOptions = { srcDirectory?: string /** Extra Vite config deep-merged over the Payload defaults. Ignored in `build` mode. */ vite?: UserConfig + /** + * Compile the admin panel when the dev server starts rather than on the first + * request to it, so the work overlaps switching to the browser. Defaults to + * `true`. Dev only — the production build is unaffected. + * + * Set to `false` to keep it on-demand: warming spends the CPU (and opens a + * database connection) on every dev boot even if you never open the admin. + */ + warmAdmin?: boolean + /** + * Admin path warmed when {@link WithPayloadOptions.warmAdmin} is enabled. Set + * this when the Payload config overrides `routes.admin`. Defaults to `'/admin'`. + */ + warmAdminPath?: string } /** The `nitro()` options Payload's server build requires. Typed structurally — `nitro` is the host's dependency, not Payload's. */ @@ -164,6 +179,8 @@ export function withPayload( silenceDependencyWarnings = true, srcDirectory = 'src', vite, + warmAdmin = true, + warmAdminPath = '/admin', } = options return (env) => { @@ -226,6 +243,7 @@ export function withPayload( stubPrettierInClient(), payloadDevTransforms(), payloadDevConfigReload({ payloadConfigPath }), + warmAdmin && payloadWarmAdmin({ adminPath: warmAdminPath }), ], resolve: { alias: [{ find: '@payload-config', replacement: path.resolve(payloadConfigPath) }], diff --git a/packages/tanstack-start/src/withPayload/warmAdmin.ts b/packages/tanstack-start/src/withPayload/warmAdmin.ts new file mode 100644 index 00000000000..96776e6093f --- /dev/null +++ b/packages/tanstack-start/src/withPayload/warmAdmin.ts @@ -0,0 +1,78 @@ +import type { PluginOption, ViteDevServer } from 'vite' + +/** + * Compiles the admin panel as soon as the dev server is listening, instead of + * waiting for the first navigation to trigger it. + * + * The admin's server-side module graph is ~1,900 modules deep (most of + * `@payloadcms/ui`), and it can't be externalized or pre-bundled — the RSC + * pipeline has to see each module's `'use client'` directives and CSS imports + * (see `config/external.ts`). Vite has no persistent cache for that transform, so + * it runs on every dev boot and takes several seconds. Left on-demand, the whole + * cost lands on the developer's first `/admin` request, and it lands there no + * matter how long they waited before navigating. + * + * Issuing one request at startup overlaps that work with the developer switching + * to their browser. Measured on a blank template (Apple M4 Max), navigating 6s + * after `pnpm dev` cut the wait from ~8.2s to ~4.0s; navigating 12s after cut it + * from ~8.0s to ~0.05s. Total CPU work is unchanged — this only moves it off the + * critical path. + * + * Note this also initializes Payload and connects to the database at boot rather + * than on first request. Disable with `warmAdmin: false` when that is unwanted — + * for example if you rarely open the admin and would rather not spend the CPU. + */ +export function payloadWarmAdmin({ adminPath }: { adminPath: string }): PluginOption { + return { + name: 'payload:warm-admin', + apply: 'serve', + configureServer(server) { + // No `httpServer` in middleware mode — the host owns listening, so there is + // no point at which we could know the URL to warm. + server.httpServer?.once('listening', () => { + // `resolvedUrls` is assigned by `server.listen()` just after the http + // server emits `listening`, so defer a tick to read the final value. + setImmediate(() => { + void warmAdminPanel({ adminPath, server }) + }) + }) + }, + } +} + +/** + * Requests the admin panel once and drains the response, so the full render — + * and every transform it pulls in — completes. Never rejects: a failed warm-up + * must not take the dev server down with it. + */ +async function warmAdminPanel({ + adminPath, + server, +}: { + adminPath: string + server: ViteDevServer +}): Promise { + const localUrl = server.resolvedUrls?.local?.[0] + + if (!localUrl) { + return + } + + // `localUrl` already carries Vite's `base`, so resolve the admin path against + // it rather than concatenating onto the origin. + const base = localUrl.endsWith('/') ? localUrl : `${localUrl}/` + const url = new URL(adminPath.replace(/^\/+/, ''), base) + const startedAt = performance.now() + + try { + const response = await fetch(url, { redirect: 'follow' }) + await response.arrayBuffer() + + const elapsedSeconds = ((performance.now() - startedAt) / 1000).toFixed(1) + server.config.logger.info(`warmed admin panel in ${elapsedSeconds}s`, { timestamp: true }) + } catch { + // Warming is best-effort. The route still compiles on demand, so a failure + // here (server closed mid-warm, custom `routes.admin`, unreachable database) + // costs nothing but the optimization. + } +} diff --git a/packages/tanstack-start/test/smoke-plugin.mjs b/packages/tanstack-start/test/smoke-plugin.mjs index 3c63b652cdc..7aa91ee72f8 100644 --- a/packages/tanstack-start/test/smoke-plugin.mjs +++ b/packages/tanstack-start/test/smoke-plugin.mjs @@ -14,7 +14,7 @@ import { payloadRscOptions, payloadTanstackStartOptions, withPayload, -} from '../dist/exports/vite.js' +} from '../dist/withPayload/index.js' const factory = withPayload(undefined, { payloadConfigPath: '/tmp/fake-payload.config.ts', @@ -50,10 +50,27 @@ for (const expected of [ 'payload:react-dom-server-in-rsc', 'payload:stub-prettier-in-client', 'payload:dev-transforms', + 'payload:warm-admin', ]) { if (!pluginNames.includes(expected)) errors.push(`missing plugin: ${expected}`) } +// Admin warming is dev-only and opt-out. +const noWarmPluginNames = withPayload(undefined, { + payloadConfigPath: '/tmp/fake-payload.config.ts', + warmAdmin: false, +})({ command: 'serve', mode: 'development' }) + .plugins.filter(Boolean) + .flat() + .map((p) => p?.name) + .filter(Boolean) +if (noWarmPluginNames.includes('payload:warm-admin')) { + errors.push('warmAdmin: false must omit payload:warm-admin') +} +if (!noWarmPluginNames.includes('payload:dev-transforms')) { + errors.push('warmAdmin: false must not drop the other Payload plugins') +} + // The `~@payloadcms/...` scss tilde importer must be wired for every consumer. if (typeof config.css?.preprocessorOptions?.scss?.importers?.[0]?.findFileUrl !== 'function') { errors.push('scss tilde importer not wired')