From 78b0fc78fb621755470753ea7f4a46e6895f77e2 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Fri, 7 Aug 2026 17:59:42 +0100 Subject: [PATCH] perf(tanstack-start): warm the admin panel on dev server start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin's server-side module graph is ~1,900 modules (mostly `@payloadcms/ui`) and can't be externalized or pre-bundled, because the RSC pipeline needs each module's `'use client'` directives and CSS imports. Vite has no persistent cache for that transform, so it reruns every dev boot. On demand, the whole cost lands on the first `/admin` request — however long the developer waited before navigating, since nothing starts until the request arrives. Warming at startup overlaps it with switching to the browser. Wait before the admin renders, blank template on an M4 Max: | Developer navigates | Before | After | | --- | --- | --- | | 6s after `pnpm dev` | 8.2s | 4.0s | | 12s after `pnpm dev` | 8.0s | 0.05s | Total CPU work is unchanged; this only moves it off the critical path. Dev only, best-effort, and opt-out via `warmAdmin: false` since it also initializes Payload and connects to the database at boot. `warmAdminPath` covers configs overriding `routes.admin`. Also repoints smoke-plugin.mjs at `dist/withPayload/index.js`; it imported `dist/exports/vite.js`, which no longer exists, so the test could not run. --- .../tanstack-start/src/withPayload/index.ts | 18 +++++ .../src/withPayload/warmAdmin.ts | 78 +++++++++++++++++++ packages/tanstack-start/test/smoke-plugin.mjs | 19 ++++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 packages/tanstack-start/src/withPayload/warmAdmin.ts 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')