Skip to content

feat(prerender): usability enhancements for pre-rendering (#565)#732

Open
raed04 wants to merge 1 commit intocloudflare:mainfrom
raed04:feat/prerender-usability-565
Open

feat(prerender): usability enhancements for pre-rendering (#565)#732
raed04 wants to merge 1 commit intocloudflare:mainfrom
raed04:feat/prerender-usability-565

Conversation

@raed04
Copy link
Copy Markdown
Contributor

@raed04 raed04 commented Apr 1, 2026

Summary

Closes #565 — Pre-rendering usability enhancements.

  • Improved progress bar: Shows phase labels (App Router / Pages Router), elapsed time, render rate (routes/s), and real-time status breakdown (rendered/skipped/errors)
  • Better route classification: Detects dynamic API imports (headers, cookies, draftMode from next/headers; connection from next/server; unstable_noStore/noStore from next/cache) via static analysis — routes importing these are classified as ssr instead of unknown
  • Prerender info in build report: Routes show [prerendered], [prerendered: N paths], [skipped], or [error] annotations; prerender summary section added
  • Public API for external control: runPrerender, PrerenderProgress, compileRouteGlob, matchRouteGlob and types exported from package entry point
  • Route filtering: includeRoutes/excludeRoutes glob patterns (* and **) with compiled regex caching
  • Dry-run mode: Scan and classify routes without rendering — shows what would be prerendered
  • External progress callback: onProgress option for programmatic tracking alongside built-in TTY progress bar

Files changed

File Changes
packages/vinext/src/build/report.ts Dynamic API detection, prerender annotations in report, SYMBOLS export
packages/vinext/src/build/run-prerender.ts Enhanced progress bar, route filtering, dry-run, public API
packages/vinext/src/index.ts New public exports
tests/build-report.test.ts 120 tests (44 new) covering all new features

Test plan

  • 120/120 unit tests pass (44 new tests added)
  • Full test suite: 3733 pass — same pre-existing failures as main, zero regressions
  • Lint (oxlint): 0 warnings, 0 errors
  • Formatting: all modified files clean
  • Build: successful
  • Backward compatible — all new type fields optional, no breaking API changes
  • classifyAppRoute behavioral change documented: routes importing dynamic APIs now return ssr instead of unknown (more accurate, errs toward restrictive)

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 1, 2026

Open in StackBlitz

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

commit: dda07d1

…#565)

- Improve progress bar: phase labels (App/Pages Router), elapsed time,
  render rate, real-time status breakdown (rendered/skipped/errors)
- Improve route classification: detect dynamic API imports (headers,
  cookies, draftMode, connection, unstable_noStore) via static analysis
  to classify routes as dynamic instead of unknown
- Add prerender annotations to build report: [prerendered], [skipped],
  [error] suffixes on routes, prerender summary section
- Expose public API: runPrerender, PrerenderProgress, compileRouteGlob,
  matchRouteGlob, and associated types exported from package entry
- Add route filtering: includeRoutes/excludeRoutes glob patterns with
  compiled regex caching via createRouteFilter
- Add dry-run mode: scan and classify routes without rendering
- Add external onProgress callback for programmatic progress tracking
@raed04 raed04 force-pushed the feat/prerender-usability-565 branch 2 times, most recently from 6d4ba1b to dda07d1 Compare April 1, 2026 02:57
@raed04
Copy link
Copy Markdown
Contributor Author

raed04 commented Apr 1, 2026

Added: KV cache seeding at deploy time (closes #562)

This commit adds automatic KV cache population during vinext deploy. After pre-rendering completes, pre-rendered HTML/RSC files are uploaded to Workers KV via the Cloudflare bulk REST API so pages are cache HITs from the first request.

How it works

  • Step 6c in deploy pipeline (after prerender, before wrangler deploy)
  • Automatic when CLOUDFLARE_API_TOKEN is set and VINEXT_CACHE KV namespace exists
  • Opt-out via --no-seed-cache
  • Non-fatal: failures log a warning but don't block deployment

Key details

  • Correct cache key format: cache:app:<buildId>:<pathname>:html (matches runtime KVCacheHandler.get())
  • RSC data is base64-encoded to match KVCacheHandler serialization
  • Static routes omit expiration_ttl (matching runtime behavior — no expiry)
  • ISR routes get 10x revalidate TTL clamped to [60s, 30d]
  • Uploads in batches of 10,000 via Cloudflare KV bulk REST API

Security

  • Path traversal guard on manifest-controlled file paths
  • TOCTOU-safe file reads (try/catch, no existsSync)
  • Error messages capped to prevent sensitive data leakage
  • Checks Cloudflare API success: false responses

Tests

27 new unit tests covering key format, binary RSC, TTL clamping, unicode paths, batch splitting, API errors, and mixed manifests. All 213 tests pass.

Files

  • New: packages/vinext/src/cloudflare/seed-kv-cache.ts (300 lines)
  • New: tests/seed-kv-cache.test.ts (27 tests)
  • Modified: deploy.ts (+40 lines), cli.ts (+1 line)

@raed04 raed04 force-pushed the feat/prerender-usability-565 branch from b6f5aee to dda07d1 Compare April 1, 2026 03:40
@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.

Solid PR — the feature set is coherent, the test coverage is thorough (44 new tests), and the implementation choices are sensible. A few things worth addressing before merge.

Summary of findings:

  • One false-positive edge case in the dynamic API regex (multi-line block comments)
  • Naming nit: detectsDynamicApiUsage reads as a description, not a function name
  • The PrerenderProgress class tracks internal counters that duplicate the caller's counters — minor but confusing
  • Dry-run mode doesn't classify unknown routes distinctly in its output (intentional? worth a note)
  • The public API exports look good and the types are well-chosen


/** Detects `import { headers | cookies | draftMode } from "next/headers"` */
const DYNAMIC_HEADERS_RE =
/^\s*import\s*\{[^}]*(?<!\btype\s)\b(?:headers|cookies|draftMode)\b[^}]*\}\s*from\s*['"]next\/headers['"]/m;
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 ^ anchor with /m correctly skips // import { headers } ... (single-line comments) because the // precedes import. However, it produces false positives for imports inside multi-line block comments that start on a prior line:

const x = 1; /*
import { headers } from "next/headers";
*/

Here import starts at the beginning of a line (matching ^ in multiline mode), but the code is dead.

This is an inherent limitation of regex-based analysis and the JSDoc already documents the heuristic nature, so it's fine as-is for a build report. But worth adding to the "Limitations" comment block so future readers know.

* - Does not detect `type` imports (which are erased at runtime — correct behavior)
* - Anchored to line start to avoid false positives from commented-out imports
*/
export function detectsDynamicApiUsage(code: 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.

Naming nit: detectsDynamicApiUsage reads like a property/descriptor ("this function detects...") rather than an imperative. The rest of the codebase uses imperative names (extractExportConstString, hasNamedExport, classifyAppRoute). Consider detectDynamicApiUsage or hasDynamicApiImport for consistency.

Not a blocker since this is exported and already referenced in tests, but worth considering if you're touching it again.

route: string,
status?: "rendered" | "skipped" | "error",
): void {
if (status === "rendered") this.rendered++;
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.

PrerenderProgress tracks rendered, skipped, and errors internally via update(), but finish() ignores these and takes its own rendered/skipped/errors parameters computed by the caller from allRoutes. The internal counters are only used for the live progress bar display.

This works correctly, but the duplication is a subtle maintenance trap — if someone later changes finish() to use the internal counters (since they exist), the counts could diverge (e.g., if update() is called without a status, the internal counters undercount).

Consider either:

  1. Making finish() use the internal counters and drop the parameters, or
  2. Removing the internal counters and passing status breakdown to update() externally

Not blocking, but worth a note.

for (const route of routes) {
const { type } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic);
const sym = SYMBOLS[type] ?? "?";
const action = type === "api" || type === "ssr" ? "skip" : "render";
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.

In dry-run mode, routes classified as unknown get action = "render", which matches the actual prerender behavior (speculative rendering). But the output → render might be confusing to users — the route might fail at render time since it couldn't be statically classified.

Consider distinguishing this in the dry-run output, e.g., → render (speculative) or using the ? symbol's meaning to hint that the outcome is uncertain.

export function compileRouteGlob(pattern: string): RegExp {
// Use a Unicode placeholder that won't appear in route patterns to
// distinguish ** from * during the replacement chain.
const DOUBLE_STAR = "\uFFFF";
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: \uFFFF is a valid (though rare) Unicode character. If a route pattern ever contained it, this would produce incorrect regex output. A two-character sequence like \uFFFE\uFFFF or a longer sentinel would be safer, though in practice route patterns are ASCII so this is purely theoretical.

The approach is clean and the comment explains the rationale well.

completedUrls += 1;
progress.update(completedUrls, totalUrls, route);
progress.update(completedUrls, totalUrls, route, status);
try {
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.

Good defensive error handling on the onProgress callback. One thought: the error is written to process.stderr which is fine, but in a non-TTY context (CI), this message could be interleaved with other output. Consider logging it once and then suppressing subsequent callback errors for the same route (or using a flag to log once total) to avoid flooding stderr if the callback is consistently broken.

export { stripServerExports as _stripServerExports };
export { asyncHooksStubPlugin as _asyncHooksStubPlugin };

// Public prerender API
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 existing exports above this block use underscore-prefixed names (_parseStaticObjectLiteral, _stripServerExports) to signal internal/unstable APIs. The new prerender exports are un-prefixed, signaling they're public API.

This is intentional per the PR description — just confirming: are these APIs considered stable? Once external consumers depend on runPrerender, compileRouteGlob, etc., changing the signatures is a breaking change. If there's any uncertainty, consider _runPrerender to give room for iteration.

});

// ── Edge cases ──────────────────────────────────────────────────────────────

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.

Good test. One subtle gap: the // comment test passes because // import doesn't start with import after ^. But this test doesn't cover the false positive case of multi-line block comments:

// This would be a false positive:
const x = 1; /*
import { headers } from "next/headers";
*/

Worth adding as a documented known-limitation test with a comment explaining the expected true result and why it's acceptable.

paths.push(r.path);
prerenderPathsMap.set(r.route, paths);
}
} else if (!prerenderStatusMap.has(r.route)) {
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 priority logic — "rendered" wins over "skipped"/"error" for the same route — is smart for dynamic routes with multiple paths where some succeed and some fail. But the else if (!prerenderStatusMap.has(r.route)) guard means the first non-rendered status wins among skipped/error. If a route has both skipped and error results, whichever appears first in the array determines the status.

Is that intentional? It seems unlikely in practice (a route is either skipped or errored, not both), but if it can happen, you might want error to take priority over skipped.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 1, 2026

Review submitted successfully. Here's a summary of the feedback posted on PR #732:

Key findings (9 inline comments):

  1. Dynamic API regex false positive (report.ts:113): Multi-line block comments that start on a prior line can produce false positives with the ^-anchored regex. Documented limitation, not blocking.

  2. Naming nit (report.ts:139): detectsDynamicApiUsage reads as a descriptor rather than an imperative — detectDynamicApiUsage or hasDynamicApiImport would be more consistent with the codebase.

  3. Duplicated counters in PrerenderProgress (run-prerender.ts:66): update() tracks internal rendered/skipped/errors counters, but finish() takes its own parameters from the caller. The duplication is a subtle maintenance trap.

  4. Dry-run unknown routes (run-prerender.ts:274): Routes classified as unknown show → render in dry-run output, which could confuse users since these may fail at render time.

  5. Unicode sentinel in compileRouteGlob (run-prerender.ts:117): Using \uFFFF as a placeholder is clean but theoretically fragile if a route contained that character. Acknowledged as academic.

  6. onProgress error handling (run-prerender.ts:389): Good defensive try/catch, but could flood stderr if the callback is consistently broken — consider a log-once pattern.

  7. Public API stability (index.ts:3955): New un-prefixed exports signal stable API. Confirmed this is intentional per the PR description.

  8. Missing test for block comment false positive (build-report.test.ts:388): The comment test covers // but not multi-line /* ... */ block comments spanning lines.

  9. Prerender status priority (report.ts:793): "Rendered" correctly wins over "skipped"/"error", but among non-rendered statuses, first-in-array wins. If a route could have both skipped and error results, error should arguably take priority.

github run

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.

Pre-rendering usability enhancements

2 participants