Skip to content

fix: warm server action modules in dev#995

Draft
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/server-action-dev-warmup
Draft

fix: warm server action modules in dev#995
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/server-action-dev-warmup

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

Fixes #994.

In dev, @vitejs/plugin-rsc validates incoming server action references against its in-memory serverReferenceMetaMap. That map is populated when action modules are transformed, so a cold action POST can fail before the action file has been loaded. This PR has vinext discover module-level "use server" files under app/ and add them to the RSC environment dev.warmup, preserving any user-specified warmup entries.

The behavior is intentionally implemented in a normal config helper instead of expanding generated entries: codegen continues to describe the app shape, while runtime/config behavior lives in importable modules.

Next.js reference behavior

Next.js does not rely on first-request lazy module transforms to recognize server actions. The webpack client module loader identifies server action IDs from RSC module metadata and proxies them in production:

Next also builds action assets and emits the server action manifest from discovered action entries, rather than waiting for the first action request to discover the module:

For directive semantics, the warmup scanner follows Next.js's module directive model: only directive-prologue string literals count, so inline "use server" actions are not treated as module-level server action files. Reference:

Tests

  • vp test run tests/server-action-warmup.test.ts
  • vp check packages/vinext/src/config/server-action-warmup.ts packages/vinext/src/index.ts tests/server-action-warmup.test.ts
  • vp test run tests/app-server-action-execution.test.ts
  • vp test run tests/app-router.test.ts -t "RSC plugin auto-registration"
  • vp run vinext#build

vp run vinext#build still reports the existing virtual-module external warnings for private-next-instrumentation-client, virtual:vinext-rsc-entry, and virtual:vite-rsc/client-references, then exits successfully.

Copilot AI review requested due to automatic review settings April 30, 2026 16:21
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@995

commit: c50f5fd

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/server-action-dev-warmup branch from 04f0de8 to 00e3297 Compare April 30, 2026 16:25
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04f0de812c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +116 to +117
const extensions = normalizePageExtensions(options.pageExtensions);
const pattern = `**/*.${buildExtensionGlob(extensions)}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scan server action files beyond pageExtensions

The warmup collector currently limits its scan to nextConfig.pageExtensions, so projects that set pageExtensions to route-only values like ['tsx', 'jsx'] will skip valid server action modules such as app/actions.ts. Those modules are still legal imports in App Router code, so they can still be referenced by forms, but they won’t be pre-warmed and the original cold-POST failure this patch fixes can still occur for those apps.

Useful? React with 👍 / 👎.

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/server-action-dev-warmup branch from 00e3297 to 66363b0 Compare April 30, 2026 16:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean implementation — the directive parser is careful, the extension union handles the pageExtensions concern correctly (addresses the Codex P2; SERVER_ACTION_SOURCE_EXTENSIONS always provides the full base set), and the test coverage is thorough. Two observations worth considering:

Comment thread packages/vinext/src/index.ts Outdated
Comment on lines +1305 to +1309
const actionWarmupEntries = await collectServerActionWarmupEntries({
root,
appDir,
pageExtensions: nextConfig?.pageExtensions,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scan reads every file under app/ to check for the directive prologue, which is correct for dev but unnecessary during production builds — dev.warmup is a no-op when command === "build". For large apps this is wasted I/O at build time.

Consider guarding with the config env:

Suggested change
const actionWarmupEntries = await collectServerActionWarmupEntries({
root,
appDir,
pageExtensions: nextConfig?.pageExtensions,
});
const actionWarmupEntries =
env?.command !== "build"
? await collectServerActionWarmupEntries({
root,
appDir,
pageExtensions: nextConfig?.pageExtensions,
})
: [];

Comment on lines +84 to +89
if (char === "\\") {
const escaped = source[index + 1];
if (escaped === undefined) {
return null;
}
value += escaped;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The escape handling here treats \ss, \nn (literal), etc. That's fine for the warmup scanner's purposes (nobody writes "use \x73erver" in practice), but it does mean the parsed value diverges from what a JS engine would produce for escape sequences like \n, \t, \u0073, or \x73.

Not a correctness issue for the "use server" check today — just noting it so we don't accidentally reuse this parser for general string literal extraction later.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review posted. Here's a summary of my findings:

Overall: This is a well-structured PR. The directive parser correctly implements the ECMAScript directive prologue semantics (whitespace/comments between directives, BOM handling, semicolons optional), the extension handling correctly takes the union of base extensions and pageExtensions (so the Codex P2 about missing .ts files is a false positive), and the tests cover the key cases including the integration test that spins up a real Vite dev server.

Two comments posted:

  1. Skip the scan during production builds (index.ts:1305-1309): collectServerActionWarmupEntries reads every file under app/ to check for the directive, but dev.warmup is a no-op when command === "build". Suggested guarding with env?.command !== "build" to avoid wasted I/O at build time.

  2. Escape handling note (server-action-warmup.ts:84-89): The readDirectiveLiteral escape handler treats \XX for all characters, which diverges from actual JS semantics for \n, \t, \uXXXX, etc. Not a correctness issue for matching "use server" today — just flagged so this parser doesn't get reused for general string extraction without adjustment.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/server-action-dev-warmup branch from 66363b0 to 9655358 Compare April 30, 2026 18:52
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix. The directive parser correctly implements ECMAScript directive prologue semantics, the extension handling is right (the base SERVER_ACTION_SOURCE_EXTENSIONS always covers .ts/.mts/etc. regardless of pageExtensions), the build-time skip is already in place (env?.command === "build" guard at index.ts:1306), and the test suite covers unit, integration, and the build-skip path.

One minor observation and one small suggestion below — neither is blocking.

}

export function hasModuleUseServerDirective(source: string): boolean {
let index = source.charCodeAt(0) === 0xfeff ? 1 : 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The BOM skip is good, but the parser doesn't handle hashbangs (#!/usr/bin/env node). If a file starts with #!, readDirectiveLiteral sees # (not a quote) and returns null, so the function returns false — the file would not be warmed up.

In practice nobody writes #!/usr/bin/env node followed by "use server", so this is purely theoretical. Just flagging it for completeness since the Next.js reference parser (getDirectiveFromByteCode) also doesn't handle hashbangs.

const pattern = `**/*.${buildExtensionGlob(extensions)}`;
const entries: string[] = [];

for await (const relativeFile of glob(pattern, { cwd: options.appDir })) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The for await loop reads each file sequentially — await readFile inside the loop body means each file is read only after the previous one finishes. For apps with many files under app/, this serializes all the I/O.

Not blocking (the scan only runs once at dev startup and the files are small), but if it ever shows up in profiling, batching the reads with Promise.all over chunks would be a straightforward improvement:

// Collect paths first, then read in parallel
const candidates: string[] = [];
for await (const relativeFile of glob(pattern, { cwd: options.appDir })) {
  candidates.push(relativeFile);
}
await Promise.all(candidates.map(async (relativeFile) => {
  const filePath = path.join(options.appDir, relativeFile);
  const source = await readFile(filePath, "utf8");
  if (hasModuleUseServerDirective(source)) {
    entries.push(toViteEntry(options.root, filePath));
  }
}));

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review posted. Here's a summary:

Verdict: Approved

This is a well-structured fix. The implementation correctly addresses the cold server action POST failure in dev by warming up module-level "use server" files so @vitejs/plugin-rsc's serverReferenceMetaMap is populated before the first action request.

What I verified:

  • The directive parser correctly implements ECMAScript directive prologue semantics (whitespace/comment skipping, BOM handling, semicolons optional, multiple directives before "use server")
  • The extension union (SERVER_ACTION_SOURCE_EXTENSIONSpageExtensions) correctly covers all possible source file types — the Codex P2 about missing .ts files is a false positive
  • The build-time skip (env?.command === "build") is already implemented, addressing the previous bonk review's first suggestion
  • Vite's deep-merge of config() hook returns means other dev sub-options won't be clobbered
  • mergeServerActionWarmupEntries correctly handles the user's existing warmup entries (since Vite replaces arrays rather than concatenating them during config merge)
  • Test coverage is thorough: unit tests for the directive parser, collection, merge, integration test with a real Vite dev server, and build-skip test

Two minor, non-blocking comments:

  1. Hashbang handling — The parser doesn't handle #!/usr/bin/env node before "use server", though this is purely theoretical (Next.js's reference parser doesn't either)
  2. Sequential file reads — The for await loop serializes readFile calls. Could be parallelized with Promise.all if it ever shows up in profiling for large apps

github run

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9655358d0f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const pattern = `**/*.${buildExtensionGlob(extensions)}`;
const entries: string[] = [];

for await (const relativeFile of glob(pattern, { cwd: options.appDir })) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discover server-action files outside app directory

The warmup collector only globs under options.appDir, so module-level "use server" files placed elsewhere (for example src/lib/actions.ts and imported into App Router components) are never pre-transformed. In those setups, first action POSTs can still hit the same cold reference path this patch is trying to prevent, because only actions physically inside app/ get added to dev.warmup.

Useful? React with 👍 / 👎.

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/server-action-dev-warmup branch from 9655358 to 3b7addc Compare April 30, 2026 19:02
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b7addc0c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


for await (const relativeFile of glob(pattern, {
cwd: options.root,
exclude: SERVER_ACTION_SCAN_EXCLUDES,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Node 22.0-22.13 glob compatibility

collectServerActionWarmupEntries passes a string-array exclude to fs.promises.glob, but this option shape is only supported starting Node 22.14. Because vinext advertises "node": ">=22", users on 22.0–22.13 will hit a runtime error during config evaluation (when hasAppDir is true), preventing dev startup. The repo already documents this compatibility constraint in routing/file-matcher.ts and uses function-form excludes there, so this new call regresses supported Node versions.

Useful? React with 👍 / 👎.

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/server-action-dev-warmup branch from 3b7addc to a05c187 Compare May 1, 2026 03:35
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for a real dev-experience pain point. The directive parser is carefully implemented, the build-time skip is in place, the function-form exclude is Node < 22.14 compatible (the Codex P1 is a false positive), and the test suite covers unit, integration, and build-skip paths.

Two issues worth addressing — one correctness concern about scan scope, one minor about the PR description accuracy.

const entries: string[] = [];

for await (const relativeFile of glob(pattern, {
cwd: options.root,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scan runs from options.root (the project root), not from appDir. This means it scans the entire project treesrc/, lib/, scripts/, tools/, etc. — reading every matching source file to check for the directive prologue.

The test at line 121 confirms this is intentional (it expects src/lib/actions.ts to be collected), and it makes sense given the issue's workaround example ('./lib/actions/**/*.ts'). But the PR summary says "discover module-level "use server" files under app/" which is misleading — it actually discovers them under the entire project root.

Two things to consider:

  1. Accuracy: Update the PR description to say "under the project root" instead of "under app/".

  2. Performance: For monorepo layouts where root contains many non-app directories (e.g., packages/, docs/, scripts/), this reads every source file in the entire tree. The excluded-roots set covers common build outputs, but not arbitrary sibling directories. In practice this is probably fine (the files are small, the directive check is a fast prefix scan, and it only runs once at dev startup), but it's worth being aware of. If it ever needs scoping, the appDir + any user-configured action directories would be a tighter default.

Not blocking — the broader scan is arguably more correct since server actions can live anywhere — but the description should match the implementation.

function shouldExcludeServerActionScanPath(name: string): boolean {
const segments = name.split(/[\\/]+/).filter(Boolean);
return (
SERVER_ACTION_SCAN_EXCLUDED_ROOTS.has(segments[0] ?? "") || segments.includes("node_modules")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The segment-based exclusion works, but note that segments[0] checks the first path component relative to cwd (which is root). This means directories like .git, dist, build, coverage, out are excluded only when they're direct children of root.

A nested build/ or dist/ directory (e.g., packages/foo/dist/actions.ts in a monorepo) would NOT be excluded. The node_modules check covers the most important case since it checks segments.includes("node_modules"), but the other excluded names only match at the root level.

This is probably fine for the initial implementation — most projects have these directories at root level. Just flagging the asymmetry between the node_modules check (any depth) and the other exclusions (root only).

return null;
}

export function hasModuleUseServerDirective(source: string): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this function has a subtle correctness assumption worth documenting. The directive prologue loop correctly handles:

  • BOM prefix
  • Whitespace and comments between directives
  • Multiple directives before "use server" (e.g., "use strict"; "use server")
  • Semicolons between directives (optional)
  • Both single and double quote styles

It correctly rejects:

  • Inline "use server" inside function bodies (the export keyword hits the non-quote branch)
  • import statements before the directive (the i is not a quote character)
  • Bare semicolons before the directive (the ; is not a quote character, so it returns false — this matches the test at line 56)

The bare-semicolon case is interesting: ; "use server" is rejected because the semicolon at position 0 is not preceded by a directive literal. In the loop, after skipWhitespaceAndComments, we try readDirectiveLiteral which sees ; (not a quote) and returns null, terminating the function with false. This matches ECMAScript directive prologue semantics where a bare semicolon is an empty statement, not a directive — good.

No change needed, just confirming the behavior is correct.

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createServer, type InlineConfig, type Plugin, type ViteDevServer } from "vite";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: createServer and ViteDevServer are imported from "vite" but other test utilities come from "vite-plus/test". This works because vite resolves to the bundled vite-plus core, but for consistency with the rest of the repo's conventions, is there a reason not to import from "vite-plus" here? Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 2, 2026

Review posted. Here's a summary:

Overall: Clean, well-scoped fix. The directive parser correctly implements ECMAScript directive prologue semantics, the build-time skip is in place, the function-form exclude avoids the Node < 22.14 compatibility issue (Codex P1 is a false positive), and tests are thorough.

Four comments posted:

  1. Scan scope vs. PR description mismatch (server-action-warmup.ts:150): The scan runs from root (the entire project), not from appDir as the PR summary states. The broader scan is arguably more correct (server actions can live in src/lib/, etc.), and the tests confirm this is intentional, but the PR description should be updated to match. Also flagged the performance implication for monorepo layouts where root contains many non-app directories.

  2. Asymmetric exclusion depth (server-action-warmup.ts:43): node_modules is excluded at any depth (segments.includes), but other excluded names (dist, build, etc.) only match at the root level (segments[0]). Noted as non-blocking since most projects have these at root level.

  3. Directive parser correctness confirmation (server-action-warmup.ts:121): Verified the parser handles all the edge cases correctly — BOM, comments, multiple directives, optional semicolons, bare semicolons, and inline "use server" inside function bodies. No change needed.

  4. Import style nit (test:4): createServer/ViteDevServer imported from "vite" instead of "vite-plus" — minor inconsistency with repo conventions.

github run

Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scanning every file on the disk would be somewhat inefficient - we'd probably want to do this by walking through a module graph and only scanning modules depended on by app router page for directives. however, that could still be somewhat taxing in large projects... this probably needs some more thought... maybe some collaboration in the vite plugin as well - there may be something that could be improved there for this

i think this might not work during hmr, as in, new server actions wouldn't be caught as the scan is a one-time thing.

@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 3, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server actions fail with [vite-rsc] invalid server reference on cold-start in dev

3 participants