Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions packages/tanstack-start/src/withPayload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -164,6 +179,8 @@ export function withPayload(
silenceDependencyWarnings = true,
srcDirectory = 'src',
vite,
warmAdmin = true,
warmAdminPath = '/admin',
} = options

return (env) => {
Expand Down Expand Up @@ -226,6 +243,7 @@ export function withPayload(
stubPrettierInClient(),
payloadDevTransforms(),
payloadDevConfigReload({ payloadConfigPath }),
warmAdmin && payloadWarmAdmin({ adminPath: warmAdminPath }),
],
resolve: {
alias: [{ find: '@payload-config', replacement: path.resolve(payloadConfigPath) }],
Expand Down
78 changes: 78 additions & 0 deletions packages/tanstack-start/src/withPayload/warmAdmin.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.
}
}
19 changes: 18 additions & 1 deletion packages/tanstack-start/test/smoke-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
Expand Down
Loading