Skip to content

fix: add SWR non-blocking guard tests for route handler ISR#975

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
Divkix:fix/959-route-handler-swr-block
Apr 30, 2026
Merged

fix: add SWR non-blocking guard tests for route handler ISR#975
james-elicx merged 2 commits intocloudflare:mainfrom
Divkix:fix/959-route-handler-swr-block

Conversation

@Divkix
Copy link
Copy Markdown
Contributor

@Divkix Divkix commented Apr 30, 2026

Fixes #959

Summary

  • Audited the App Router route handler runtime for the pendingWaitUntil / pipe-readable blocking pattern from the Next.js fix (vercel/next.js#93189)
  • Confirmed no bug: vinext correctly uses ctx.waitUntil() for all background cache writes and regenerations — no equivalent of the Next.js pendingWaitUntil / pipe-readable blocking exists
  • Added explicit timing assertions to the existing route handler STALE test to guard against regressions
  • Added a new slow-ISR route handler fixture and test that verifies stale responses complete quickly (< 500ms) even when the handler has a 1s delay, ported from Next.js test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts

Test plan

  • it("route handler ISR: STALE serves stale data and triggers background regen") — added staleDuration < 500ms assertion
  • it("route handler ISR: STALE completes quickly without blocking on background regen") — new test with slow handler fixture, verifies cold request ≥ 700ms, stale response < 500ms, background regen completes successfully

…re#959)

Audit confirms vinext correctly uses ctx.waitUntil() for all background
cache-write and regeneration work — no equivalent of the Next.js
pendingWaitUntil / pipe-readable blocking pattern exists.

## Changes
- Add slow ISR route handler fixture (revalidate=1, 1s handler delay)
- Add response-time assertion to existing STALE test (< 500ms)
- Add new test verifying stale response completes fast despite slow
  background regeneration

Ported from Next.js:
test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
@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@975

commit: 309688a

@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.

Review

Good audit work. The PR correctly identifies that vinext's architecture avoids the Next.js bug (#93189) by design — triggerBackgroundRegeneration() fires the regen promise without awaiting it, and ctx.waitUntil() keeps the isolate alive without blocking the response. The tests are a useful regression guard.

CI is all green. A few observations below, nothing blocking.

Comment thread tests/app-router.test.ts

// The stale response must arrive promptly; background regen runs
// out-of-band via ctx.waitUntil(). Allow 500ms for cold-start latency.
expect(staleDuration).toBeLessThan(500);
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 500ms threshold is reasonable for the existing static-data handler since its handler body is near-instant — the only latency is test infra overhead. But note that this assertion is sensitive to CI load spikes. If this starts flaking, the fix is to raise this to 1000ms (not remove it), since the important invariant is "much less than any hypothetical regen duration." Worth keeping in mind.

Comment thread tests/app-router.test.ts
expect(cold.status).toBe(200);
const coldBody = await cold.json();
const coldDuration = Date.now() - coldStart;
expect(coldDuration).toBeGreaterThanOrEqual(700); // roughly 1s handler delay
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.

700ms lower bound for a 1000ms setTimeout is generous, which is good for CI. One edge case: if the test process is under heavy load and the cold request takes significantly longer than 1s (e.g., 3s due to GC or Vite overhead), the 1500ms stale window below might not be enough for the entry to expire (since revalidate=1 is measured from when the cache was written, not from test start). In practice this probably won't happen because the prod server is lightweight, but if this test ever flakes, the symptom would be a HIT instead of STALE at line 2323. The fix would be to increase the sleep at line 2315 or to poll for STALE instead of sleeping a fixed duration.

Comment thread tests/app-router.test.ts
expect(stale.headers.get("x-vinext-cache")).toBe("STALE");
const staleBody = await stale.json();
expect(staleBody.timestamp).toBe(coldBody.timestamp); // Still the old data
expect(staleDuration).toBeLessThan(500);
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 is the key assertion — the whole point of the test. The 500ms threshold works well here because the handler has a deliberate 1000ms delay, so there's a clear 2x gap between "stale response served from cache" (~0ms) and "blocked on regen" (~1000ms). Good design.

Comment thread tests/app-router.test.ts
});

// Ported from Next.js:
// test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
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 Next.js use-cache-swr test tests the "use cache" directive with SWR semantics, which is a different mechanism from vinext's export const revalidate ISR. The porting attribution is accurate in the sense that the test pattern (slow handler + timing assertion) is borrowed from there, but a reader might think vinext implements "use cache". Consider adjusting the comment to clarify:

Suggested change
// test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
// Test pattern ported from Next.js:
// test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
// (adapted from "use cache" SWR to route handler ISR with export const revalidate)

* Slow GET route handler for SWR non-blocking test.
*
* Ported from Next.js test:
* test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts
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.

Same note as the test file — the Next.js delayed-route fixture uses "use cache" + cacheLife("seconds"), not export const revalidate. The comment could clarify that this is an adapted port, not a direct translation:

Suggested change
* test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts
* Test pattern adapted from Next.js fixture:
* test/e2e/app-dir/use-cache-swr/app/delayed-route/route.ts

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review posted. Here's a summary of the feedback:

Overall: The PR is sound. vinext's ISR architecture correctly avoids the Next.js blocking bug by design — triggerBackgroundRegeneration() fires the regen promise without awaiting it, and ctx.waitUntil() extends the isolate's lifetime without delaying the response. The new tests are a good regression guard. CI is all green.

Inline comments:

  1. Line 2284 — The 500ms threshold on the existing static-data STALE test is reasonable but CI-load-sensitive. Noted that raising to 1000ms (not removing) is the right fix if it flakes.

  2. Line 2312 — The 700ms lower bound for the cold request is good. Flagged a subtle edge case: if the cold request takes much longer than 1s under load, the fixed 1500ms sleep might not be enough for the cache entry to expire, causing a HIT instead of STALE. Suggested polling as an alternative if it flakes.

  3. Line 2326 — Called out as the key assertion, well-designed with a clear 2x gap between cache-served (~0ms) and hypothetical blocking (~1000ms).

  4. Lines 2302 and fixture line 5 — Suggested minor comment adjustments: the Next.js use-cache-swr test uses the "use cache" directive, not export const revalidate. The test pattern is ported but the mechanism differs, and the attribution comments could be clearer about that to avoid implying vinext implements "use cache".

github run

Adjust comments to note test pattern is adapted from Next.js
"use cache" SWR tests, not a direct translation. The mechanism
differs (export const revalidate vs "use cache").
@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.

Review

LGTM. The PR is well-scoped and the tests are correctly designed.

Architecture verification: I traced the full STALE response path through readAppRouteHandlerCacheResponsescheduleBackgroundRegenerationtriggerBackgroundRegeneration. The regen promise is created synchronously and registered with ctx.waitUntil() but never awaited — the stale response is returned immediately. The claim in the PR description is accurate: vinext has no equivalent of the Next.js pendingWaitUntil / pipe-readable blocking bug.

Test design: The slow-ISR test creates a clean 2x gap (500ms threshold vs 1000ms handler delay) that makes the assertion robust. The cold-request lower bound (>= 700ms) provides a sanity check that the delay is actually running. The polling loop for fresh data with a 5s deadline is the right pattern for CI.

Previous review feedback: The second commit (309688a) correctly addresses all the attribution nits from the prior review — both the test file and fixture comments now clarify that the pattern is adapted from "use cache" SWR, not a direct translation.

CI is all green across all 21 checks. No blocking issues.

Comment thread tests/app-router.test.ts
expect(coldDuration).toBeGreaterThanOrEqual(700); // roughly 1s handler delay

// Wait for the 1s revalidate window to expire.
await new Promise((resolve) => setTimeout(resolve, 1500));
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.

Noting for future reference: the 1500ms sleep assumes the cold request completes in roughly 1s, so the cache entry (written at cold-request completion) has been stale for ~500ms by this point. If the cold request ever takes >1500ms under extreme CI load (e.g., 3s due to GC), the entry would still be within its 1s TTL when the stale request fires, producing a HIT instead of STALE.

This was already flagged in the prior review and I agree it's unlikely in practice. If it does flake, switching to a poll-for-STALE pattern (similar to the poll-for-HIT below) would be the robust fix. Not blocking.

Comment thread tests/app-router.test.ts
// regeneration, this will take ≥ 1s and fail.
const staleStart = Date.now();
const stale = await fetch(`${baseUrl}/api/slow-isr`);
const staleDuration = Date.now() - staleStart;
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: staleDuration is measured across only the fetch() call, not the .json() body read. That's fine — response headers (including x-vinext-cache) are available before the body is consumed, and .json() on a small in-memory body is negligible. Just noting this is correct as-is, since some reviewers might wonder whether the body read should be inside the timing window.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review submitted. Here's the summary:

Approved — the PR is well-scoped and correctly designed.

I verified the ISR architecture by tracing the full STALE response path: readAppRouteHandlerCacheResponsescheduleBackgroundRegenerationtriggerBackgroundRegeneration. The regen promise is created synchronously and registered with ctx.waitUntil() but never awaited, so the stale response returns immediately. vinext genuinely has no equivalent of the Next.js blocking bug.

The test design is solid — the 500ms threshold vs 1000ms handler delay creates a clean 2x gap that's robust for CI. The second commit addressed all attribution nits from the prior review. CI is all green across 21 checks.

Two inline observations (non-blocking):

  1. The 1500ms fixed sleep could theoretically be insufficient under extreme CI load — noted poll-for-STALE as the robust fallback if it flakes
  2. Confirmed the timing measurement correctly covers only fetch(), not body consumption, which is the right approach

github run

@james-elicx james-elicx merged commit 246aa1d into cloudflare:main Apr 30, 2026
26 checks passed
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.

Don't block route handler response on background SWR regeneration (Node runtime parity)

2 participants