Skip to content

fix(loader): don't let a cached loader's inner invokes veto page caching - #1244

Draft
pedrofrxncx wants to merge 1 commit into
mainfrom
fix/loader-invoke-vary-isolation
Draft

pedrofrxncx wants to merge 1 commit into
mainfrom
fix/loader-invoke-vary-isolation

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A loader with cache = "stale-while-revalidate" and a non-null cacheKey runs handler(props, req, ctx) with the request's shared context on a cache miss (callHandlerAndCache). Loaders it calls through ctx.invoke build their FnContext from the same state.global, so they share the page's vary. If one of them is no-store, or returns a null key, it sets vary.shouldCache = false and the whole page goes no-store.

This only happens on the outer loader's miss. On a hit the inner loaders never run, so page cacheability flips with loader-cache state.

Real case (a VTEX storefront): a site loader relatedProductList (SWR, key = product slug) invokes vtex/loaders/legacy/productDetailsPage, intelligentSearch/productList (key is null when ctx.isInvoke) and legacy/productList (no cache export, so it defaults to no-store). Bots render deferred sections inline, so every bot PDP whose related-products entry is cold comes back no-store: about 19k requests/day at ~1.3s each of origin time on that one site. For human visitors, the same veto hits that section's /deco/render.

Change

  • RequestContext gets an optional vary.
  • callHandlerAndCache runs the handler inside RequestContext.bind({ ...active, vary: scoped }), and also passes { ...ctx, vary: scoped } to the handler itself.
  • fnContextFromHttpContext uses RequestContext.active()?.vary when present. Every loader resolved inside that async scope, invokes included, pushes keys and vetoes into the scoped vary. Debug entries still forward to the page's vary.debug, so ?__d still shows them.
  • Siblings running concurrently on the same request are outside the scope and keep the request's vary. This is not a save/restore of a shared boolean: a sibling vetoing while the cached loader is mid-flight still vetoes. There's a test for exactly that.
  • no-store, no-cache, null-key and cache-disabled paths are untouched.

Is it correct to cache the outer result when an inner loader said no-store?

I think so. The outer loader's output is written to the loader cache and served to every request on a hit, and no veto runs on a hit. The inner veto therefore never protected the data; it only made the single miss request no-store. The outer cacheKey already owns correctness (it's what other requests are served by). This change makes a miss behave like a hit and adds no new exposure: anything an outer loader leaks is already leaked on hits today.

I checked deco-cx/apps for cacheable loaders that invoke others (vtex/loaders/options/productIdByTerm, wake/loaders/productDetailsPage, wake/loaders/productListingPage). The wake PLP returns a null key when a partner cookie is present, so it stays on the uncached path, and its partnerAssociate call is an action, which never sets vary. Nothing in apps writes ctx.vary directly.

Inner vary keys are also scoped now. They were already only present on misses, so dropping them makes the page vary stable across hit and miss; the outer key is pushed as before.

Tests

blocks/loader.test.ts, which goes through loaderBlock.adapt with fake HttpContexts that share request state, the way the resolver does for invokes:

  • A cacheable loader invoking a no-store loader leaves page shouldCache true. This fails on main.
  • A sibling no-store loader that vetoes while the cacheable loader's handler is in flight still vetoes the page.
  • A no-store or no-cache outer loader still propagates the inner veto.

Full suite: 87 passed, 0 failed. deno check live.ts is clean, and deno fmt --check/deno lint are clean on the touched files. The 12 unformatted files in the repo were already unformatted on main.

Draft until it has been checked end to end on a site preview; results to follow in a comment.


Summary by cubic

Fixes page caching being vetoed by loaders invoked inside a cacheable loader's handler, so a miss now behaves like a hit.

Previously, a cacheable loader (stale-while-revalidate with a cacheKey) that invoked inner loaders via ctx.invoke ran them with the request's shared vary on a cache miss. If any inner loader was no-store or returned a null key, it set the page's vary to no-store—but only on that miss, because hits skip inner loaders. This made page cacheability depend on loader-cache state.

This change scopes each cacheable loader's handler run to its own Vary via RequestContext, so inner loaders push keys and vetoes into that scoped vary instead of the page's. Siblings running concurrently on the same request are outside the scope and still veto the page. Non-cacheable loaders (no-store/no-cache) propagate vetoes as before, and debug entries still surface in the page's vary. Adds tests covering the scoped behavior, the sibling mid-flight veto, and the unchanged non-cacheable paths.

Written for commit 1d6059f. Summary will update on new commits.

View guided diff Turn on auto-fix

A loader with stale-while-revalidate and a non-null cacheKey runs its
handler with the request's shared ctx on a cache miss. Loaders it calls
through ctx.invoke build their fn context from the same request state,
so a no-store or null-key inner loader sets vary.shouldCache = false and
the whole page goes no-store, but only on the outer loader's miss: on a
hit the inner loaders never run. The veto never protected the cached data
(it is served to every request on a hit regardless); it only made page
caching depend on loader-cache state.

Run callHandlerAndCache inside an AsyncLocalStorage scope carrying its own
Vary (RequestContext.vary). fnContextFromHttpContext uses it for any loader
resolved inside that scope, so inner keys and vetoes land in the scoped
vary, while debug entries still go to the page. Loaders running outside the
scope, including siblings that run concurrently, keep the request's vary, so
their vetoes still apply. Non-cacheable paths are unchanged.
@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.213.1 update
  • 🎉 for Minor 1.214.0 update
  • 🚀 for Major 2.0.0 update

@pedrofrxncx

Copy link
Copy Markdown
Contributor Author

End-to-end validation on a site preview

I pinned miess-01 to cd7396b, which is the site's deco version (1.211.1) plus this fix, cherry-picked. deno info confirms a single deco copy is loaded. Preview: deco-sites/miess-01#334, now closed. I compared it against prod (1.211.1).

Bots: Googlebot UA, cache-busted PDPs (deferred sections render inline)

prod preview
no-store 12/12 1/12
public 0/12 11/12
Unique productIDs on the page (shelf rendered) identical on all 12

The one PDP that stays no-store is no-store on both sides, so it's a separate veto not reached by this change.

Control set, browser UA: home, 10 PLPs, 5 PDPs, 3 searches and 3 legacy ?map= URLs (22 in total). Status, redirects, cache-control and the first 12 productIDs are identical on all 22 (16 public, 6 no-store on both).

Deferred sections, human path: /deco/render on 3 PDPs. Each has 6 render URLs on both sides, all 200 public on both. The related-products section returns the same number of products (26/26, 28/28, 8/8).

No regressions found. Still open: a real deploy, to watch the bot PDP BYPASS rate drop.

This branch has not been deployed

No deployments
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.

1 participant