Skip to content

fix(vtex): key the legacy PDP cache by pageHref and by the props that change it - #11

Merged
marcoferreiradev merged 8 commits into
main-altfrom
fix/pdp-cache-key-props
Sep 2, 2026
Merged

marcoferreiradev merged 8 commits into
main-altfrom
fix/pdp-cache-key-props

Conversation

@marcoferreiradev

@marcoferreiradev marcoferreiradev commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

The cache key of vtex/loaders/legacy/productDetailsPage.ts was built from req.url and carried only slug, segment and skuId. Two things follow from that.

1. Every caller got its own entry

A page render and a /live/invoke call for the same product have different request URLs — /<slug>/p versus /live/invoke/.... The mobile app paid the full VTEX round trip for a payload the site had just computed.

intelligentSearch/productListingPage already solves this by preferring props.pageHref. This does the same, and adds pageHref to Props.

Measured: with the site having warmed the PDP, the app's call drops to 0.019–0.023s (hit).

2. similars decided for everyone

The prop toggles withIsSimilarTo inside the loader but was absent from the key. Whoever asked first decided whether isSimilarTo existed for everyone else until the entry expired.

Reproduced before the fix:

client asks similars=false (cold)  → isSimilarTo MISSING   (seeds the entry)
app asks similars=true             → isSimilarTo MISSING   ← gets the other one's

After the fix the entries separate: false returns it missing, true returns isSimilarTo=4.

indexingSkus, advancedConfigs.preferDescription and advancedConfigs.includeOriginalAttributes have exactly the same problem and are keyed alongside.

Context

Companion to oficina-dev/deco-runtime#2, which makes resolver= in the cache key identify the module rather than the path walked to it. Without that one, this fix alone is not enough for sharing; without this one, the PDP stays out even with that one.

🤖 Generated with Claude Code

… change it

The cache key was built from `req.url` and carried only slug/segment/skuId. Two
things followed from that.

A page render and a `/live/invoke` call for the same product have different
request URLs, so each caller got its own entry for identical data — the mobile
app paid the full VTEX round trip for a payload the site had just computed.
`intelligentSearch/productListingPage` already solves this by preferring
`props.pageHref`; this does the same, and adds `pageHref` to Props.

And `similars` toggles `withIsSimilarTo` in the loader but was absent from the
key, so whoever asked first decided whether `isSimilarTo` existed for everyone
else until the entry expired. Verified live: a call with `similars: false`
seeded the entry and a later call with `similars: true` got the product back
with `isSimilarTo` missing. `indexingSkus` and the two `advancedConfigs` fields
have the same problem and are keyed too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@marcoferreiradev marcoferreiradev changed the title fix(vtex): chavear a PDP legacy por pageHref e pelas props que mudam o payload fix(vtex): key the legacy PDP cache by pageHref and by the props that change it Aug 20, 2026
marcoferreiradev and others added 7 commits August 20, 2026 10:52
…n range

Two ways an attacker-controlled value reaches code that throws, both in the app
middleware or in a cacheKey — neither of which the runtime wraps in a try, so a
throw in either is a 500.

`?sc=` went straight into `segment.channel` and from there into the VTEXSC
cookie, where Deno's setCookie rejects anything outside US-ASCII by throwing.
`/any-page?sc=<emoji>` returned 500 for the whole page, from a plain URL, no
cookie needed. A sales channel is a VTEX integer id, so it is now only taken in
that shape — fenced at the source rather than sanitized downstream, because the
same value feeds both the cookie and the cache key.

And `btoa`, which only accepts latin1, was reached with the segment unescaped in
both `serialize` and `getSegmentCacheKeyWithoutUTM`. A `\uXXXX` escape inside the
vtex_segment cookie survives atob + JSON.parse as a real code point above 0xFF —
only the six utm fields went through removeNonLatin1Chars. Escaping to ASCII
before btoa keeps the value intact: the round-trip yields the same string, and a
segment that was already ASCII serializes byte-for-byte as before, so no cache
entry and no cookie is invalidated.

Verified live on a storefront: `?sc=<emoji>`, `?sc=abc` and a forged
vtex_segment cookie all return 200 across page render, /deco/render and
/live/invoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…che keys

Three ways the same result was landing in different entries, or different
results in the same one.

`?page=1` and no page at all are the same state. The PLP cacheKey computes
`page` from props and then re-appends every allowed param already in the URL,
so one yields `page=1&page=1` and the other `page=1` — a measured 1907ms miss
where a 12ms hit belonged. Dropping exact duplicate pairs after the sort is
neutral by construction; `filter.colors=azul&filter.colors=verde` survives,
which is why this is a dedup and not a `set`. Case is left alone on purpose:
the loader reads `PS` and `O` uppercase and `q` lowercase, none of them echoed
by the props block, so folding those would hand one page size the other's
entry.

`productList` keyed by the caller's URL, which for the PDP backfill is the PDP
path on our own render, `/deco/render` on the partial and `/live/invoke` from
the mobile app. It now honours `pageHref`, the same escape hatch the sibling
productListingPage has always had, so a caller that is not the page itself can
land on the entry the storefront already produced. A facets list is decided
entirely by its props, so its key stops reading the URL — otherwise the key is
finer than the fetch and mints one entry per `?q=`. With that closed, the
blanket `ctx.isInvoke` veto only survives where the URL still decides the
search, which is what kept the backfill paying full price on every PDP.

Now that the loader cache is keyed by the module, this key is the only thing
separating two callers of the same loader — so `priceFacets`, `similars` and
the facets list's own `query` had to go in it.
The website sells on trade policy 1 and the mobile app on policy 5, and the
two mirror each other — same catalogue, same prices. The app reaches these
loaders over /live/invoke carrying its own vtex_segment, so `channel` in the
cache key split every shared path into two entries with byte-identical
payloads, and whichever side arrived second paid the VTEX fetch again.

Measured on a virgin key, same server, same sequence: the website warms the
PLP (miss 2736ms) and the app reads it at channel 5 — miss 5007ms before,
hit 3ms after. priceTables and regionId still separate, so Prime and a
regionalized shopper keep their own entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFJdpNZtWBobnhj3CPPuMj
DEFAULT_SEGMENT carries no campaigns, priceTables, regionId or channelPrivacy,
so the anonymous SSR bag omits those fields entirely, while a shopper carrying
a VTEX-minted vtex_segment sends the first three as null and channelPrivacy
spelled out as "public". Same truth, two JSON strings, two cache entries — and
the mobile app always carries that cookie, so every entry it was meant to share
with the website's anonymous render depended on a shape it never has.

Absent, null and "" are now one state, the same predicate the storefront
applies on its own half of the key, and "public" folds into absent so only
"private" earns an entry of its own.

Measured on virgin keys, same server, same sequence: the website warms the PLP
with no cookie and the app reads it carrying a full-shaped segment on channel 5
— miss 1543ms before, hit 3ms after. The negatives still hold: priceTables
PRIME misses (956ms), regionId misses (789ms) and channelPrivacy private misses
(1112ms), each minting its own entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFJdpNZtWBobnhj3CPPuMj
Three defects the module-keyed loader cache makes reachable.

The PLP key took the raw `?page` while the loader subtracts `pageOffset` from
it, so with `pageOffset=0` the URL's page 1 and its absence — page 1 and page 0
of the catalogue — normalised onto one key, and the dedup that removed the
echoed duplicate took away the accident that had been keeping them apart. Both
sides now read the same `pageOf`, and that dedup drops to two lines.

`includeOriginalAttributes` was joined with a comma, so `["a,b"]` and
`["a","b"]` produced one key for payloads carrying different attributes.

The whitelist on a facets list's own `query` was gated on `ctx.isInvoke`, which
is true only for a loader calling a loader: a top-level POST /live/invoke
arrives with it false, so any phrase minted an entry and an IS search from the
public endpoint. The gate is gone, and the comment that claimed `isInvoke`
cannot tell the two apart went with it — it contradicted the measurement
recorded four lines below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFJdpNZtWBobnhj3CPPuMj
`pageHref` is a prop, so it arrives from a public POST /live/invoke like any
other, and it reaches `new URL` in two places: the key, which the runtime
evaluates outside its try, and the loader body, where it is the base of every
product URL in the payload. `pageHref: "x"` threw in both — measured HTTP 500,
now 200 on the IS list and on the legacy PDP, with the payload's product URLs
unchanged.

`keyUrlOf` is where that fallback lives, next to the other cache-key helpers, so
the three loaders stop each spelling it out. The legacy PDP's `pageHref` also
picks up the `@hide true` its sibling already had — it is a caller's contract,
not a knob for the admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFJdpNZtWBobnhj3CPPuMj
`useCollectionName` rewrites the last pageType from the product's collection, so
it changes the payload — and it was the one such prop missing from the key. This
storefront has it on for one PLP and off for the others, so a call naming that
page with the flag flipped wrote its breadcrumb into the entry the page reads.

Measured: false warms (miss 1197ms), true now mints its own (miss 1061ms), and
false still hits (10ms). Before, true was served false's entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFJdpNZtWBobnhj3CPPuMj
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