fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error#1289
Conversation
…sted-dynamic error Ports Next.js PR #93707 to vinext. When a nested "use cache" propagates a dynamic cache life (revalidate: 0 or expire < 5 minutes) to a parent without an explicit cacheLife(), the error now attaches the inner invocation as . Changes: - Add NestedDynamicUseCacheError class (captured eagerly at inner call site) - Track hasExplicitRevalidate / hasExplicitExpire on CacheContext - Propagate dynamicNestedCacheError via ??= (first child wins) - Throw with when outer lacks explicit cacheLife for the relevant field - Update error messages to consistently backtick "use cache" - Add unit tests covering revalidate:0, short expire, explicit outer, first-child-wins Refs: cloudflare#1194 Ported from: vercel/next.js#93707
commit: |
|
/bigbonk review |
There was a problem hiding this comment.
Good port of Next.js PR #93707. The core logic — eagerly capturing NestedDynamicUseCacheError at the inner call site, propagating it to the parent context via ??=, and throwing with cause when the outer lacks explicit cacheLife — is correct and matches the upstream semantics. Tests are solid and well-structured.
A few items worth discussing:
| let eagerError: Error | undefined; | ||
| if (parentCtx && parentCtx.variant !== "private") { | ||
| eagerError = new NestedDynamicUseCacheError(); | ||
| Error.captureStackTrace(eagerError, runCachedFunctionWithContext); |
There was a problem hiding this comment.
Error.captureStackTrace is V8-specific (Node.js, Cloudflare Workers). If vinext ever targets a non-V8 runtime (e.g. JavaScriptCore in Bun), this will throw. The upstream Next.js PR has the same limitation since they only target V8 runtimes.
A defensive guard would be cheap:
| Error.captureStackTrace(eagerError, runCachedFunctionWithContext); | |
| if (typeof Error.captureStackTrace === 'function') { | |
| Error.captureStackTrace(eagerError, runCachedFunctionWithContext); | |
| } |
Not blocking — just something to consider for robustness, since the super() call in the constructor already captures a stack (just with the constructor frame included).
| const ctx = _getCacheContextFn?.(); | ||
| if (ctx) { | ||
| ctx.lifeConfigs.push(resolvedConfig); | ||
| if (resolvedConfig.revalidate !== undefined) ctx.hasExplicitRevalidate = true; |
There was a problem hiding this comment.
Subtle: cacheLife({ expire: 60 }) reaches here with resolvedConfig = { ...cacheLifeProfiles.default, ...{ expire: 60 } }, which means resolvedConfig.revalidate is 900 (from the default profile). This sets hasExplicitRevalidate = true even though the user only specified expire.
In practice this works correctly — if you call cacheLife() at all, you've made a conscious choice, so suppressing the nested-dynamic error is appropriate. But the flag name hasExplicitRevalidate is slightly misleading since it really means "cacheLife() was called and the resolved config includes revalidate" rather than "the user explicitly passed a revalidate value".
This matches the upstream Next.js behavior (their hasExplicitRevalidate is set on the work unit store level, not per-field), so not a bug — just worth a clarifying comment for future maintainers.
|
|
||
| // Propagate the inner's resolved cache life into the parent's configs so | ||
| // the outer's minimum-wins computation includes the inner's values. | ||
| if (parentCtx) { |
There was a problem hiding this comment.
This propagation of effectiveLife into the parent's lifeConfigs is new behavior beyond what the upstream PR #93707 adds. In Next.js, the inner cache's life is propagated to the parent through a different mechanism (propagateCacheEntryMetadata), not by pushing into lifeConfigs directly.
The PR description mentions this: "Default cache life profiles are propagated upward via parentCtx.lifeConfigs.push(effectiveLife), which may slightly alter minimum-wins behavior for deeply nested caches."
Is this intentional? It means the outer's resolveCacheLife() will include the inner's effective life in its minimum-wins computation, which could change the outer's effective revalidate/expire even when the outer has its own explicit cacheLife(). For example:
- Outer calls
cacheLife({ revalidate: 300 }) - Inner resolves
{ revalidate: 60 } - The outer's
lifeConfigsnow has both; minimum-wins givesrevalidate: 60
This seems like it might be the intended minimum-wins behavior for nested caches, but it's a separate concern from the error-cause feature and could cause subtle behavioral changes. Consider splitting this into a separate PR if it's intentional new behavior, or removing it if it's just scaffolding for the error propagation.
| } | ||
|
|
||
| expect(thrown).toBeDefined(); | ||
| expect(thrown!.cause).toBeDefined(); |
There was a problem hiding this comment.
The comment says "We can't easily test exact stack content in unit tests" — but you could verify that the cause is specifically from innerA (not innerB) by checking the cause's identity or message against a known marker. For example, you could verify thrown.message contains "revalidate" (from innerA's revalidate: 0) rather than "expire" (from innerB's expire: 120), which would actually test the first-child-wins semantics more rigorously:
| expect(thrown!.cause).toBeDefined(); | |
| // The cause should be from the FIRST dynamic child (innerA), verified | |
| // by the outer error message being about revalidate (not expire). | |
| expect(thrown!.message).toContain('revalidate'); |
| // and this outer cache lacks an explicit cacheLife for the relevant field, | ||
| // throw the nested-dynamic error now. | ||
| if (ctx.dynamicNestedCacheError) { | ||
| if (effectiveLife.revalidate === 0 && !ctx.hasExplicitRevalidate) { |
There was a problem hiding this comment.
There's a subtle interaction here: both the revalidate === 0 and expire < DYNAMIC_EXPIRE checks run sequentially. If the effective life has both revalidate === 0 AND expire < DYNAMIC_EXPIRE, only the revalidate error is thrown (the expire check is unreachable). This matches Next.js behavior where revalidate: 0 takes precedence.
However, there's an edge case: if dynamicNestedCacheError is set but the effective life is neither revalidate === 0 nor expire < DYNAMIC_EXPIRE (because the outer's own cacheLife() pushed the values above the thresholds), the error is silently discarded. This is correct — the outer made an explicit choice that overrides the dynamic child — but it only works because lifeConfigs.push(effectiveLife) at line 621 ensures the outer's minimum-wins includes the inner's values. So lines 620-622 and 637-652 are tightly coupled — good to add a comment noting this dependency.
|
Review posted successfully. Here's a summary of the feedback: Overall: Good port of Next.js PR #93707. The core error propagation logic is correct and well-tested. 5 comments posted:
|
…ants Addresses bigbonk review feedback on cloudflare#1289: - Guard `Error.captureStackTrace` with a typeof check for non-V8 runtime robustness. The constructor already captures a stack; this call only trims the constructor frame. - Document that propagating the inner's resolved life into the parent's `lifeConfigs` is load-bearing: minimum-wins must reflect the inner's dynamic values for the outer's nested-dynamic detection to fire, and the outer's explicit `cacheLife()` correctly suppresses the throw by pushing the resolved values above the dynamic thresholds. Matches Next.js `propagateCacheLifeAndTagsToRevalidateStore`. - Clarify that `hasExplicitRevalidate` / `hasExplicitExpire` flags really mean "cacheLife() was called and the resolved config includes this field", not "the user explicitly passed this field" — matches upstream which tracks the flag at the work unit store level. - Strengthen the first-child-wins test to assert the outer error message contains "revalidate" (not "expire"), rigorously verifying that `dynamicNestedCacheError ??=` preserves the first dynamic child's eager error as the cause.
|
/bigbonk review |
There was a problem hiding this comment.
Second review pass on the nested-dynamic cache error feature. The previous review's comments were well-addressed in the updated code (the Error.captureStackTrace guard, the clarifying comment on hasExplicitRevalidate, and the comments documenting the coupling between lifeConfigs.push and the error detection). The implementation is solid.
I have two remaining concerns — one is a real behavioral issue, one is a test gap. Neither is blocking, but the first one should be considered before merge.
| // propagation, the outer's effectiveLife would not reflect the inner's | ||
| // dynamic values and the throw at lines below would never fire. | ||
| if (parentCtx) { | ||
| parentCtx.lifeConfigs.push(effectiveLife); |
There was a problem hiding this comment.
Behavioral concern with lifeConfigs.push and executeWithContext callers.
The private variant (line 441) and dev mode (line 449) both call executeWithContext, which calls runCachedFunctionWithContext. This means:
-
A
"use cache: private"inner will eagerly allocate aNestedDynamicUseCacheErrorwhen nested inside a public parent, and will push itseffectiveLifeinto the parent'slifeConfigs. This is correct — private caches are excluded from being parents that throw (line 605 checksparentCtx.variant !== "private"), but they can still propagate dynamic life up to a public parent. -
However,
executeWithContextalso callsrecordRequestScopedCacheLife(effectiveLife)(line 564), which writes the inner's resolved life to the request-scoped store. This happens before the outerrunCachedFunctionWithContextresolves its own effective life and stores it. The request-scoped cache life uses minimum-wins accumulation (_setRequestScopedCacheLife), so order doesn't matter for correctness — but it's worth noting this is a side effect of the refactor.
Not blocking, but good to verify that the request-scoped cache life accumulation order hasn't changed relative to main for the private and dev-mode paths.
| // only the revalidate error is thrown (the expire branch is unreachable), | ||
| // matching Next.js which surfaces `revalidate: 0` first. | ||
| if (ctx.dynamicNestedCacheError) { | ||
| if (effectiveLife.revalidate === 0 && !ctx.hasExplicitRevalidate) { |
There was a problem hiding this comment.
Prerendering guard is missing.
Both error messages say "which is not allowed during prerendering", but the throw is unconditional — it fires during normal dynamic SSR too, not just prerendering. In Next.js, the equivalent check is gated by the work unit type being a prerender (workUnitStore.type === 'prerender'). This means a normal dynamic request with nested "use cache" where the inner has revalidate: 0 will throw an error claiming it's "not allowed during prerendering" even though no prerendering is happening.
This is the most significant issue in the PR. Options:
- Gate the throw on being inside a prerender work unit (check
workUnitAsyncStorage) - Remove "during prerendering" from the error message to make it accurate for all contexts
- Accept the divergence from Next.js and document it
I'd suggest option 1 for parity, but if the work unit store isn't reliably available in all vinext contexts, option 2 is a reasonable alternative.
| // captureStackTrace call just trims the constructor frame. | ||
| let eagerError: Error | undefined; | ||
| if (parentCtx && parentCtx.variant !== "private") { | ||
| eagerError = new NestedDynamicUseCacheError(); |
There was a problem hiding this comment.
Minor: the eager Error allocation happens on every nested public cache call, including cache hits in the shared cache path. Looking at the call flow:
registerCachedFunction→ shared cache HIT → returns early (line 465), never callsrunCachedFunctionWithContextregisterCachedFunction→ shared cache MISS → callsrunCachedFunctionWithContext→ allocates errorexecuteWithContext→ always callsrunCachedFunctionWithContext→ allocates error
So for the shared cache path, the allocation only happens on miss, which is fine. For private and dev-mode paths via executeWithContext, it happens on every invocation (since those skip cache lookup). In practice, the overhead is negligible — Error construction is cheap and the object is short-lived if not dynamic. Just noting for completeness.
| expect((thrown!.cause as Error).message).toContain("dynamic cache life"); | ||
| }); | ||
|
|
||
| it("does not throw when outer cache has explicit cacheLife", async () => { |
There was a problem hiding this comment.
Test gap: verify the return value semantics when outer has explicit cacheLife.
This test verifies the error is suppressed, but it would be more thorough to also verify the effective cache life that the outer resolves to. With cacheLife({ revalidate: 60 }) on the outer and cacheLife({ revalidate: 0 }) on the inner, the outer's effectiveLife resolves to revalidate: 0 via minimum-wins (because the inner's resolved life is pushed into parentCtx.lifeConfigs). This means the outer's result won't actually be cached by MemoryCacheHandler (it returns early for revalidate: 0).
This is arguably correct (minimum-wins semantics), but it might surprise users who expect cacheLife({ revalidate: 60 }) on the outer to override the inner's dynamic value. A test that calls outerCached() twice and verifies the function executes both times (no caching) would document this behavior explicitly.
|
Review posted successfully. Here's a summary of the feedback: Overall: The implementation is solid. The previous review's comments were well-addressed (captureStackTrace guard, clarifying comment on flag names, coupling documentation). The core error propagation logic is correct. 4 comments posted:
|
…ch Next.js Addresses bigbonk review feedback on cloudflare#1289 (round 2): - Previously the nested-dynamic-cache error threw unconditionally, even during normal production dynamic SSR. The error messages say "not allowed during prerendering", which is misleading outside the prerender phase. Next.js gates the equivalent check on the work unit type being `prerender` (or `request` in development) — see use-cache-wrapper.ts cases 'prerender'/'request' at the read site. - Gate the throw on `process.env.VINEXT_PRERENDER === "1"` (set by build/prerender.ts during the prerender phase) or NODE_ENV === "development". Production runtime SSR no longer throws; the outer function still executes and its result simply isn't cached (minimum-wins resolves its effective revalidate to 0). - Add a regression test verifying no-throw behavior under production runtime conditions (no VINEXT_PRERENDER, NODE_ENV=production). - Document caller paths (shared MISS, private, dev) on `runCachedFunctionWithContext` — all three reach this helper and propagate lifeConfigs / dynamicNestedCacheError up to the parent, matching Next.js's `propagateCacheEntryMetadata`. Request-scoped recording uses minimum-wins so inner-vs-outer order doesn't matter. - Add a test documenting the potentially surprising minimum-wins semantics: with `cacheLife({ revalidate: 60 })` on the outer and `cacheLife({ revalidate: 0 })` on the inner, the throw is suppressed but the outer's effective revalidate resolves to 0, so the outer's result isn't cached and the function executes on every call. Matches Next.js `propagateCacheLifeAndTagsToRevalidateStore`. - Wrap prerender-gated tests in a `withPrerenderFlag` helper that sets/restores VINEXT_PRERENDER around each test.
|
/bigbonk review |
There was a problem hiding this comment.
Good port of Next.js PR #93707. The three prior review rounds covered the major points well and the code has been iterated thoughtfully. The core logic — eager error capture, ??= first-child-wins propagation, prerender/dev gating, and hasExplicit* suppression — is correct and well-documented.
Two remaining items I'd want addressed before merge, one nit, and one observation.
| // throw would be misleading and would diverge from Next.js. | ||
| const shouldThrow = | ||
| typeof process !== "undefined" && | ||
| (process.env.VINEXT_PRERENDER === "1" || process.env.NODE_ENV === "development"); |
There was a problem hiding this comment.
Dev-mode error message is misleading.
The shouldThrow gate correctly fires in both prerender (VINEXT_PRERENDER=1) and development (NODE_ENV=development). But both error messages (lines 68-73, 75-81) say "which is not allowed during prerendering". During dev SSR, no prerendering is happening — the message is confusing.
Next.js gets away with this because their error messages are generic and the throw location is in shared render code. Here, it's worth either:
- Making the message context-aware:
| (process.env.VINEXT_PRERENDER === "1" || process.env.NODE_ENV === "development"); | |
| const shouldThrow = | |
| typeof process !== "undefined" && | |
| (process.env.VINEXT_PRERENDER === "1" || process.env.NODE_ENV === "development"); | |
| const duringPhrase = process.env.VINEXT_PRERENDER === "1" ? "during prerendering" : "in development"; |
...and interpolating duringPhrase into the error messages, or
- Using a neutral phrase like
"which is not allowed"(drop"during prerendering") in both messages.
Option 2 is simpler and always accurate. The linked docs page already explains the full context.
| const result = await cacheContextStorage.run(ctx, () => fn(...args)); | ||
| recordRequestScopedCacheLife(resolveCacheLife(ctx.lifeConfigs)); | ||
| return result; | ||
|
|
||
| // Resolve effective cache life from collected configs | ||
| const effectiveLife = resolveCacheLife(ctx.lifeConfigs); |
There was a problem hiding this comment.
Timing of resolveCacheLife vs the throw checks is correct but fragile.
The flow is: inner completes → pushes into parentCtx.lifeConfigs (line 655) → outer's fn body finishes → outer calls resolveCacheLife(ctx.lifeConfigs) (line 642) which now contains both the outer's own cacheLife() calls and the inner's pushed effectiveLife → throw checks at lines 703-716 use this merged result.
This works because cacheContextStorage.run(ctx, () => fn(...args)) creates a new context for the inner, but the inner's post-execution code pushes into the parent context's lifeConfigs. The parent's resolveCacheLife call happens after fn(...args) returns, so sequencing is guaranteed.
However, the correctness depends on the inner's await runCachedFunctionWithContext(...) completing (including its parentCtx.lifeConfigs.push) before the outer's resolveCacheLife runs. This is guaranteed by await semantics today, but if anyone ever refactored the inner to use fire-and-forget or Promise.all with short-circuit, the ordering could break silently. Consider adding a brief comment at line 642 noting this dependency:
// Note: must run after fn(...args) returns — the inner's
// runCachedFunctionWithContext has already pushed its effectiveLife
// into ctx.lifeConfigs by this point (via the parentCtx propagation).
| }); | ||
| }); | ||
|
|
||
| it("does not throw outside prerender/dev even with a nested dynamic inner cache", async () => { |
There was a problem hiding this comment.
Nit: this test may not actually test the production gate.
Vitest defaults NODE_ENV to "test", not "development". The test sets NODE_ENV = "production" and deletes VINEXT_PRERENDER to verify the throw doesn't fire. But "test" would also not fire (since the gate checks === "development"), so this test would pass even if the gate were broken to check !== "production" instead of === "development".
Consider adding a sibling assertion that explicitly sets NODE_ENV = "development" (without VINEXT_PRERENDER) and confirms the throw does fire. That would fully verify both sides of the gate.
| // captureStackTrace call just trims the constructor frame. | ||
| let eagerError: Error | undefined; | ||
| if (parentCtx && parentCtx.variant !== "private") { | ||
| eagerError = new NestedDynamicUseCacheError(); |
There was a problem hiding this comment.
Minor observation: the eager NestedDynamicUseCacheError allocation happens for every nested public cache call, including those where the inner resolves a non-dynamic cache life (e.g., cacheLife('days')). The error is then silently discarded at lines 662-668 when the condition doesn't match.
For a hot path where most nested caches are not dynamic, this is a wasted allocation + stack capture per call. In Next.js this is acceptable because the same pattern exists. But if profiling ever shows this as a bottleneck, a lazy approach (capture the error after resolving the effective life, only if dynamic) would eliminate the waste — at the cost of the stack pointing to the post-execution site rather than the original call site.
Not blocking — just noting for future optimization if cache-heavy workloads show up in profiles.
|
Review posted successfully. Here's a summary of the feedback: Overall: Good port of Next.js PR #93707. The core logic is correct and well-documented after three prior review rounds. 4 comments posted:
|
…pin dev/prod gate Addresses bigbonk review feedback on cloudflare#1289 (round 3): - Make the nested-dynamic error messages context-aware: emit "in development" when NODE_ENV=development, "during prerendering" otherwise. The previous unconditional "during prerendering" wording was misleading when the gate fired in dev SSR. Done via two small message-builder functions instead of constants. - Add a dev-mode test ("does throw in development mode even without VINEXT_PRERENDER set") that pins down the other side of the gate. Without this, Vitest's default NODE_ENV=test would mask a regression that broke the gate to e.g. `!== "production"` instead of `=== "development"`. Together with the existing production test, both branches of the gate are now covered. - Document the sequencing invariant between the inner's `parentCtx.lifeConfigs.push(effectiveLife)` and the outer's `resolveCacheLife(ctx.lifeConfigs)`. The correctness depends on the inner's await completing (including its post-execution propagation) before the outer's resolveCacheLife runs. A future refactor away from `await` (e.g. Promise.all with short-circuit) would silently break this and produce stale minimum-wins results. - Document the eager-Error allocation overhead trade-off: matches Next.js (captured eagerly so `cause` points at the original call site), with the lazy-capture optimization noted for future profiling. Verified: tests/shims.test.ts (918 tests), cache + ISR + fetch-cache suites, and full `vp check` all clean.
|
/bigbonk review |
There was a problem hiding this comment.
Good port of Next.js PR #93707. The iteration across 4 commits shows thoughtful responses to prior review feedback — context-aware error messages, the prerender/dev gate, captureStackTrace guard, and extensive commenting are all welcome improvements over a naive 1:1 port.
The core logic is correct: eager error capture at the inner call site, ??= first-child-wins propagation, hasExplicit* flag suppression, and the prerender/dev-only throw gate all match the upstream semantics. Tests are comprehensive and well-structured, including the dev-mode companion test that pins both sides of the gate.
One real issue (medium severity), one structural divergence worth documenting, and a couple of nits follow.
| typeof process !== "undefined" && | ||
| (process.env.VINEXT_PRERENDER === "1" || process.env.NODE_ENV === "development"); | ||
| if (shouldThrow && ctx.dynamicNestedCacheError) { | ||
| if (effectiveLife.revalidate === 0 && !ctx.hasExplicitRevalidate) { |
There was a problem hiding this comment.
The effectiveLife.revalidate === 0 check is subtly different from the upstream gate.
In Next.js, the throw is gated on the rdcResult.entry.revalidate (the inner's cache entry metadata) being 0, which represents the stored revalidate value. Here, effectiveLife is the outer's resolved cache life after minimum-wins merges all lifeConfigs (including the inner's pushed effectiveLife). In practice these produce the same result for the revalidate === 0 case (since Math.min(anything, 0) === 0), but they diverge semantically:
- If the outer calls
cacheLife({ revalidate: 60 })and the inner hasrevalidate: 0, the outer'seffectiveLife.revalidateis0via minimum-wins. The!ctx.hasExplicitRevalidateguard isfalse(outer calledcacheLife), so the throw is correctly suppressed. - If the outer calls
cacheLife({ revalidate: 0 })(explicitly dynamic) and the inner also hasrevalidate: 0, the outer'seffectiveLife.revalidateis0,hasExplicitRevalidateistrue, throw is suppressed. Correct.
So the behavior is correct in all cases I can construct. The semantic difference (checking the merged outer effective life vs the inner's entry metadata) doesn't produce observable differences because the throw guards (hasExplicit*) cover the cases where the merge could mask the inner's contribution. Just noting this for future maintainers — the comment at line 719 does a good job explaining the coupling, but mentioning this semantic difference would help.
| // effective revalidate/expire above the dynamic thresholds. Without this | ||
| // propagation, the outer's effectiveLife would not reflect the inner's | ||
| // dynamic values and the throw at lines below would never fire. | ||
| if (parentCtx) { |
There was a problem hiding this comment.
The parentCtx.lifeConfigs.push(effectiveLife) propagation is the most significant behavioral change beyond the error-cause feature.
The upstream PR #93707 does not add this propagation — Next.js already had propagateCacheLifeAndTagsToRevalidateStore which propagates the inner's resolved metadata into the outer's revalidate store. The vinext equivalent didn't exist before this PR, so this push is filling that gap.
This is load-bearing for correctness (the error detection depends on it, as the comment explains), and the minimum-wins documenting test at line 4397 explicitly verifies the consequence (outer re-executes because minimum-wins resolves to revalidate: 0 despite the outer's explicit cacheLife({ revalidate: 60 })). That's well done.
However, this is a standalone behavioral change separate from the error-cause feature. Pre-PR, a nested cacheLife({ revalidate: 0 }) inner would not affect the outer's effective revalidate at all — the outer would use its own lifeConfigs only. Post-PR, the outer's effective revalidate is 0 because the inner's life is pushed into its lifeConfigs. This changes caching behavior for any existing nested "use cache" setup where the inner is dynamic and the outer is not, even in production where the throw doesn't fire.
The PR description acknowledges this ("may slightly alter minimum-wins behavior for deeply nested caches without explicit cacheLife()"), and it matches Next.js semantics. But it would be worth calling this out in the commit message as a behavioral change, not just an implementation detail of the error feature.
| * Defaults to "during prerendering" to match Next.js wording when called from | ||
| * a context we don't recognize (the throw also wouldn't fire in that case). | ||
| */ | ||
| function nestedCacheContextPhrase(): string { |
There was a problem hiding this comment.
Nit: the priority order here means that when VINEXT_PRERENDER=1 and NODE_ENV=development are both set (e.g., running a local prerender with dev config), the message says "in development" rather than "during prerendering". This is unlikely in practice (prerender usually implies production NODE_ENV), but if it's intentional, a comment would help. If not:
| function nestedCacheContextPhrase(): string { | |
| function nestedCacheContextPhrase(): string { | |
| if (typeof process === "undefined") return "during prerendering"; | |
| if (process.env.VINEXT_PRERENDER === "1") return "during prerendering"; | |
| if (process.env.NODE_ENV === "development") return "in development"; | |
| return "during prerendering"; | |
| } |
This gives VINEXT_PRERENDER priority, which seems more accurate — if the prerender flag is set, the user is actually prerendering regardless of NODE_ENV.
| const previousPrerender = env.VINEXT_PRERENDER; | ||
| const previousNodeEnv = env.NODE_ENV; | ||
| delete env.VINEXT_PRERENDER; | ||
| env.NODE_ENV = "production"; |
There was a problem hiding this comment.
Good addition of both the production gate test and the dev-mode companion test (line 4319). The dev test at line 4319 was the right call from prior review — it pins both branches of the gate.
One subtle issue remains: Vitest sets NODE_ENV=test by default. If the shouldThrow gate were accidentally written as NODE_ENV !== 'production' instead of NODE_ENV === 'development', this test would still pass (because you set it to "production" to test the negative case). But the dev-mode test at line 4331 catches that regression by explicitly testing "development". So the pair together is solid.
The only remaining gap: there's no test that verifies NODE_ENV=test (the Vitest default) does not trigger the throw. Currently that's implicitly covered by any test that calls registerCachedFunction without setting env vars (since "test" !== "development" and VINEXT_PRERENDER is unset). But if someone adds a test that accidentally sets VINEXT_PRERENDER=1 globally, it could mask a regression. Not blocking — just noting.
| // suppression semantics are correct: calling `cacheLife()` is itself | ||
| // the explicit choice that opts the outer out of the nested-dynamic | ||
| // throw, regardless of which fields the user specified. | ||
| if (resolvedConfig.revalidate !== undefined) ctx.hasExplicitRevalidate = true; |
There was a problem hiding this comment.
The comment explaining the flag semantics is excellent and directly addresses the prior review concern. Well done.
One observation: since resolvedConfig is always { ...cacheLifeProfiles.default, ...profile }, both revalidate and expire will always be defined (the default profile has both). So these two lines are effectively ctx.hasExplicitRevalidate = true; ctx.hasExplicitExpire = true; — the !== undefined checks never fail. This means calling cacheLife() with any argument suppresses both the revalidate and expire nested-dynamic errors, not just the one for the field the user specified.
As the comment notes, this matches Next.js (which tracks the flag at the work unit store level, not per-field). But it means cacheLife({ expire: 60 }) suppresses the revalidate throw even though the user only expressed intent about expire. If per-field suppression is ever desired, these checks would need to inspect the raw user input, not resolvedConfig.
|
Review posted successfully. Here's a summary of the feedback: Overall: Good port of Next.js PR #93707. The core logic is correct and the 4-commit iteration shows thoughtful responses to prior review feedback. The implementation matches upstream semantics well. 5 comments posted:
|
…and per-field flag note Addresses bigbonk review feedback on cloudflare#1289 (round 4): - Prioritize VINEXT_PRERENDER over NODE_ENV=development in nestedCacheContextPhrase. If the prerender flag is set, the user is prerendering regardless of NODE_ENV (e.g. a dev-config prerender), so "during prerendering" is the accurate phrase. - Document the semantic difference between checking the outer's merged effectiveLife.revalidate vs the inner's entry metadata (as Next.js does). Behavior is observably equivalent in all cases due to the hasExplicit* guards; we use effectiveLife because vinext doesn't model a CacheResultMetadata type. - Extend the hasExplicit* comment in cache.ts to clarify that the `!== undefined` checks are effectively unconditional (default profile always provides both fields). They remain as defensive guards; per- field suppression would require inspecting the raw user input and would diverge from Next.js semantics, so it's noted as a deliberate design change rather than an incidental one. Bonk's remaining comments are informational: - Comment 2 ("lifeConfigs.push is a behavioral change"): the PR description already calls this out; no code change. - Comment 4 ("NODE_ENV=test gate coverage gap"): explicitly flagged as not blocking; the dev + prod test pair already pins both branches. Verified: tests/shims.test.ts (918 tests) and full `vp check` clean.
|
/bigbonk review |
There was a problem hiding this comment.
This has been through 4 prior review rounds and the iteration quality is evident. The core logic is correct, tests are comprehensive, and the extensive inline documentation is appreciated.
I have one real issue (misleading comment that could cause a future maintainer to remove a necessary guard), one behavioral concern worth confirming, and one minor nit. The rest is LGTM.
| // This block is tightly coupled with the `lifeConfigs.push(effectiveLife)` | ||
| // above: it relies on the inner's dynamic values being merged into this | ||
| // outer's `effectiveLife` via minimum-wins. If the outer's own explicit | ||
| // cacheLife() pushed revalidate/expire above the thresholds, the inner's | ||
| // values still appear in `lifeConfigs` but minimum-wins drops them, the | ||
| // checks below evaluate false, and the captured error is silently dropped. | ||
| // That is the desired behavior — the outer made an explicit choice that | ||
| // overrides the dynamic child. |
There was a problem hiding this comment.
This comment is incorrect and could mislead a future maintainer into removing the hasExplicit* guard.
The comment says: "If the outer's own explicit cacheLife() pushed revalidate/expire above the thresholds, the inner's values still appear in lifeConfigs but minimum-wins drops them, the checks below evaluate false, and the captured error is silently dropped."
Minimum-wins doesn't "drop" the inner's values — it takes the minimum. If the outer has revalidate: 60 and the inner has revalidate: 0, then Math.min(60, 0) === 0. The effectiveLife.revalidate === 0 check at line 772 is still true. What actually suppresses the throw is !ctx.hasExplicitRevalidate being false (because the outer called cacheLife()).
The comment should describe what actually happens:
| // This block is tightly coupled with the `lifeConfigs.push(effectiveLife)` | |
| // above: it relies on the inner's dynamic values being merged into this | |
| // outer's `effectiveLife` via minimum-wins. If the outer's own explicit | |
| // cacheLife() pushed revalidate/expire above the thresholds, the inner's | |
| // values still appear in `lifeConfigs` but minimum-wins drops them, the | |
| // checks below evaluate false, and the captured error is silently dropped. | |
| // That is the desired behavior — the outer made an explicit choice that | |
| // overrides the dynamic child. | |
| // This block is tightly coupled with the `lifeConfigs.push(effectiveLife)` | |
| // above: it relies on the inner's dynamic values being merged into this | |
| // outer's `effectiveLife` via minimum-wins. When the outer has its own | |
| // explicit `cacheLife()`, the effective life may still be dynamic (e.g., | |
| // `Math.min(60, 0) === 0`), but the `hasExplicitRevalidate`/ | |
| // `hasExplicitExpire` guards below suppress the throw — the outer made | |
| // an explicit choice that opts it out of the error, even though the | |
| // effective values remain dynamic via minimum-wins. |
| // propagation, the outer's effectiveLife would not reflect the inner's | ||
| // dynamic values and the throw at lines below would never fire. | ||
| if (parentCtx) { | ||
| parentCtx.lifeConfigs.push(effectiveLife); |
There was a problem hiding this comment.
Previous reviews have flagged this as a standalone behavioral change beyond the error-cause feature, and the PR description acknowledges it. One thing I want to confirm:
Pre-PR, if a nested "use cache" inner resolved revalidate: 0, the outer's effectiveLife was computed only from its own lifeConfigs (i.e., only from cacheLife() calls within the outer's own body). Post-PR, the outer's effectiveLife includes the inner's resolved life via this push, so minimum-wins gives revalidate: 0 even if the outer called cacheLife({ revalidate: 60 }). This changes caching behavior in production (where the throw doesn't fire) — the outer silently stops being cached.
The minimum-wins documenting test at shims.test.ts:4397 explicitly verifies this, which is great. But the commit message for the initial commit (ef0e0e8) frames this as purely an error-cause feature. Consider amending the PR description (or the squash-merge commit message) to call out: "Also propagates nested cache life configs to parent via lifeConfigs.push, matching Next.js's propagateCacheLifeAndTagsToRevalidateStore semantics. This changes minimum-wins behavior for nested caches — the outer's effective cache life now reflects the inner's contribution."
| // result. Tests in tests/shims.test.ts under "use cache runtime" cover | ||
| // this; the first-child-wins and minimum-wins documenting tests will fail | ||
| // if this invariant is broken. | ||
| const effectiveLife = resolveCacheLife(ctx.lifeConfigs); |
There was a problem hiding this comment.
Nit: the sequencing invariant comment is thorough and correct, but it references fn(...args) returning as the guarantee. Worth noting the subtle case: if fn calls multiple nested cached functions in sequence (await innerA(); await innerB()), both inner calls' lifeConfigs.push complete before fn returns (because each await ensures the inner's post-execution runs). But if fn calls them in parallel (await Promise.all([innerA(), innerB()])), the pushes still complete before the outer's resolveCacheLife because await cacheContextStorage.run(ctx, () => fn(...args)) waits for fn's returned promise, which is the Promise.all. So the invariant holds for both sequential and parallel inner calls.
This is already implicitly safe, but a single sentence like "This holds for both sequential and Promise.all-parallel inner calls, because fn's returned promise settles only after all nested awaits complete." would make it explicit for future readers scanning the comment.
|
Review posted successfully. Here's a summary of the feedback: Overall: Good port of Next.js PR #93707. The core logic is correct, tests are comprehensive, and the code has been well-iterated across 5 commits based on prior review feedback. LGTM with minor items. 3 comments posted:
|
…llel-call invariant Addresses bigbonk review feedback on cloudflare#1289 (round 5): - Fix incorrect minimum-wins explanation in the throw block comment. The previous wording claimed minimum-wins "drops" the inner's values when the outer has an explicit cacheLife() — that's wrong. Minimum- wins takes Math.min(60, 0) = 0, so the threshold check still evaluates true. What actually suppresses the throw is the `!ctx.hasExplicitRevalidate` / `!ctx.hasExplicitExpire` guard. Future maintainers reading the old comment might have concluded the hasExplicit* guards were redundant; the corrected wording makes clear they are load-bearing. - Also corrected the same misconception in the comment above the `parentCtx.lifeConfigs.push` block ("the outer only suppresses the throw when its own explicit cacheLife() pushed the effective values above the dynamic thresholds"). Replaced with the accurate two-step description: lifeConfigs.push makes the threshold checks possible; hasExplicit* guards independently decide suppression. - Add a sentence to the sequencing-invariant comment noting it holds for both sequential (`await innerA(); await innerB()`) and parallel (`await Promise.all([innerA(), innerB()])`) inner call patterns, since the outer's `await cacheContextStorage.run(...)` only resolves after fn's returned promise settles. - Updated PR description to call out `parentCtx.lifeConfigs.push` as a standalone behavioral change in production (not just during prerender/dev), separate from the error-cause feature. Linked to the minimum-wins documenting test that pins this behavior. Verified: tests/shims.test.ts (918 tests) and `vp check` clean.
|
/bigbonk review |
There was a problem hiding this comment.
Solid port of Next.js PR #93707. The implementation is correct, the PR description clearly documents the behavioral change (lifeConfigs propagation), the tests are comprehensive with both sides of the prerender/dev gate pinned, and the inline documentation is unusually thorough for the complexity involved. The 5 prior review rounds have been addressed well — context-aware error messages, captureStackTrace guard, corrected coupling comment, and dev-mode companion test are all present.
Two minor items below, neither blocking.
| // fire. (The `hasExplicit*` guards then independently decide whether to | ||
| // suppress the throw — see the longer comment below.) | ||
| if (parentCtx) { | ||
| parentCtx.lifeConfigs.push(effectiveLife); |
There was a problem hiding this comment.
Nit: this pushes effectiveLife unconditionally into the parent's lifeConfigs — including when this cache is a top-level call with parentCtx being a non-cache cacheContextStorage.run() scope from a test or from throwIfInsideCacheScope(). In practice this is harmless (the parent contexts created by tests have no downstream consumer of lifeConfigs), but it would be slightly cleaner and more defensive to also check parentCtx.variant !== undefined or some sentinel, to avoid pushing into a context that isn't actually a "use cache" parent.
Not blocking — just noting for future robustness.
| // innerB()])`), because `await cacheContextStorage.run(ctx, () => | ||
| // fn(...args))` only resolves after `fn`'s returned promise settles — | ||
| // and that promise itself awaits all nested inner calls. | ||
| const effectiveLife = resolveCacheLife(ctx.lifeConfigs); |
There was a problem hiding this comment.
The refactoring from two inline resolveCacheLife call sites (one in registerCachedFunction, one in executeWithContext) into a single one in runCachedFunctionWithContext is a net improvement — it eliminates the duplication and ensures both paths get the nested-dynamic error detection.
One thing worth noting: the shared-cache path in registerCachedFunction (line 504) now receives the effectiveLife from runCachedFunctionWithContext and uses it for both recordRequestScopedCacheLife AND the handler.set() call (lines 510, 549-550). Pre-PR, the effectiveLife in the shared-cache path only reflected the outer's own cacheLife() calls. Post-PR, it also includes the inner's propagated life via parentCtx.lifeConfigs.push. This means the revalidateSeconds passed to handler.set() is now the minimum-wins value across both outer and inner.
This is correct behavior (matches Next.js), but it's a second consequence of the lifeConfigs.push change beyond the throw detection — the outer's stored cache entry now respects the inner's dynamic life. The PR description covers the minimum-wins behavior but doesn't explicitly call out that the stored revalidateSeconds in the cache handler also changes. Worth a sentence in the commit message for completeness.
|
Review posted successfully. Here's a summary: Verdict: APPROVE The implementation is correct and well-iterated across 6 commits. The core logic — eager 2 minor comments posted (neither blocking):
|
Description
Ports Next.js PR #93707 to vinext.
When a nested
"use cache"propagates a dynamic cache life (revalidate: 0orexpire < 5minutes) to a parent without an explicitcacheLife(), the error now attaches the inner invocation ascause. This gives developers both stacks: the outer that threw, and the inner that propagated the dynamic life.Changes
NestedDynamicUseCacheErrorclass (captured eagerly at the inner call site while still on the synchronous stack). TrackhasExplicitRevalidate/hasExplicitExpireonCacheContext. PropagatedynamicNestedCacheErrorvia??=(first dynamic child wins). Throw withcausewhen the outer cache lacks explicitcacheLifefor the relevant field. Gate the throw onVINEXT_PRERENDER === "1"orNODE_ENV === "development", matching Next.js (which only throws when the work unit type isprerenderorrequestin dev).cacheLife()now setshasExplicitRevalidate/hasExplicitExpireflags on the currentCacheContext. UpdateCacheContextLiketype to match.revalidate: 0, shortexpire, explicit outercacheLife, first-child-wins semantics, production no-throw gate, dev throw gate, and a minimum-wins documenting test.Behavioral change:
lifeConfigspropagation to parentBeyond the error-cause feature, this PR adds
parentCtx.lifeConfigs.push(effectiveLife)to propagate the inner cache's resolved life into the outer'slifeConfigs. This fills a gap that Next.js handles viapropagateCacheLifeAndTagsToRevalidateStoreand is load-bearing for the new error detection (without it, the threshold checks could never fire).This changes caching behavior in production, not just during prerender/dev. Pre-PR, a nested
cacheLife({ revalidate: 0 })inner did not affect the outer's effective revalidate at all — the outer used its ownlifeConfigsonly. Post-PR, the outer's effective revalidate is0via minimum-wins (Math.min(60, 0) === 0) even if the outer calledcacheLife({ revalidate: 60 }). The outer silently stops being cached in this scenario.This matches Next.js's
propagateCacheLifeAndTagsToRevalidateStoresemantics. The minimum-wins documenting test inshims.test.ts("explicit cacheLife on outer suppresses throw but minimum-wins still applies to effective cache life") explicitly verifies and pins this behavior.Error message cleanup
Both nested-dynamic error messages now consistently backtick
"use cache"(matching Next.js). Messages are also context-aware: they say "in development" whenNODE_ENV=development(withoutVINEXT_PRERENDER) so dev-mode users aren't told they're prerendering.VINEXT_PRERENDERtakes priority overNODE_ENVwhen both are set.Related Issue
Closes #1194
Potential Risk & Impact
Errorallocation on every nested public cache call is required for correct stack capture but adds minor overhead. Private cache parents are intentionally excluded. Matches Next.js; lazy capture is a possible future optimization if profiling ever shows this is hot.lifeConfigspropagation described above changes minimum-wins behavior for nested caches in production. This is intentional, matches Next.js, and is explicitly tested.How Has This Been Tested?
pnpm test tests/shims.test.ts— 918/918 pass (including new nested-dynamic, dev/prod gate, and minimum-wins tests)pnpm run check— format, lint, and type checks clean across all 587 filesisr-cache,cache-control,cache-for-request,fetch-cache,app-page-cache) all pass