feat(compat-v7): /commerce, /tanstack and /nextjs
Stacked on #704 (feat/compat-v7-sections). Adds the last pieces of @decocms/compat-v7 that the product owner approved on 2026-10-14: v7's commerce types and helpers, and the framework-bound parts for each binding. Since 2026-10-15 it also has /tanstack/worker, v7's legacy-origin A/B split (PO decision 6).
/commerce/*: v7's @decocms/apps-commerce
These are v7's pure files, kept under v7's own subpath layout: /commerce and /commerce/types, /commerce/types/cart, /commerce/sdk/{analytics,formatPrice,url,useOffer,useVariantPossibilities} and /commerce/utils/{canonical,constants,filters,productToAnalyticsItem,stateByZip}. A site only has to replace @decocms/apps-commerce/ with @decocms/compat-v7/commerce/ in its imports. None of it uses React.
v7's app runtime is not ported: app-types, manifest-utils, registry and resolve.
How it's pinned to v7. v7's files are kept verbatim with pinned hashes (7.x 9131e9d9; they are the same at the 7.x head, d586d6f0).
- Each of our files prints the same TypeScript AST as v7's (comments and formatting aside), so a changed type or expression fails the test.
- Each helper runs next to v7's own file over 5,000 generated inputs: prices, currencies and locales, URLs, offers and installments, variants, products and breadcrumbs, ranges and ZIP codes.
- I made 7 mutations to the code; the tests caught all 7.
/tanstack and /tanstack/server
The site's code for each piece:
// block map
"website/matchers/random.ts": (props) => random(props, matcherContext()),
// page server function
const page = await openPage(data.href); commitSegmentCookie(); return { ...page, device: requestDevice() };
// page component
<DeviceProvider value={page.device}>ā¦
// worker
segment: (request) => ({ ...cohortSegment(request) }),
matcherContext(pageUrl?): oneflagsarray per request. On a client-side navigation the request is the server-function call, sopageUrlmust be given there; without it, the call throws a message that says what to pass.commitSegmentCookie(): setsdeco_segmentexactly as v7'spersistFlagsdid:deco_segment=<value>; Max-Age=31536000; Path=/; SameSite=Lax. It sets nothing when the visitor's cookie already says the same.createDecoWorkernever stores a response that sets this cookie, as long as the site keepsdeco_segmentout ofsafeCookies.cohortSegment(request): v7's__abfsplit of the edge cache. It uses v7's workerreadRequestCookie, ported as is.requestDevice(): v7'sdetectDeviceon the request's user agent.DeviceProvider,useDeviceandDeviceContext: v7's context, in a single"use client"module shared with/nextjs.
No change to @decocms/tanstack was needed: TanStack Start's own response headers carry the cookie.
Works across TanStack Start versions. The response is written with getResponseHeaders() and setResponseHeader(name, list), which exist in every TanStack Start the binding supports. getResponse() was removed in 1.169; a consumer install found that and it is fixed here.
/tanstack/worker: v7's A/B split with the legacy origin (PO decision 6, 2026-10-15)
abTestingRoutes({ worker, ...v7's ABTestConfig }) is v7's withABTesting (7.x packages/blocks/src/sdk/abTesting.ts) as a createDecoWorker routes handler:
const abTesting = abTestingRoutes({ worker: () => worker, kvBinding: "SITES_KV", shouldBypassAB });
const worker = createDecoWorker({ /* ⦠*/ routes: async (request, env, ctx) => (await abTesting(request, env, ctx)) ?? siteRoutes(request) });
- It has the same KV entry, buckets,
_deco_bucket=<bucket>:<ratio %>cookie,?_deco_bucket=override,x-deco-bucketheader, circuit breaker,shouldBypassAB,preHandlerand streamed fallback proxy. v7'sgetStableBucket,tagBucket,proxyToFallbackandcreateReplaceStreamare exported too. It uses Web APIs only. - The "worker" bucket. v7 wrapped the whole worker, so it could tag the worker's response and fall back to the legacy origin when the worker threw. A
routeshandler only answers before the worker serves. So, for that bucket, the handler calls the worker back (worker). A header carrying a random token for each handler marks that inner call, so a client can't forge it, and the inner call skips the handler and goes on to the site's other routes, the edge cache and the page. Its response keeps the worker'spreloadLink hints, the only ones the page gets: the worker adds none to aroutesresponse (P468167723). The token is drawn on the handler's first request, not when it is created: a site builds its routes at module scope, where Workers refuse random values, so drawing it there made the worker fail to start (found on a storefront's preview build, fixed in0b91f011). No change to@decocms/tanstackwas needed:_deco_bucketwas already one of the worker's defaultsafeCookies, so cached pages are tagged too. - Pinned to v7. v7's file is a hash-pinned fixture (the same at
9131e9d9and at the 7.x head). Its tests:- 3,000 seeded requests through v7's
withABTesting(worker)and through the handler inside a worker give the same responses (status, headers, body), the same legacy-origin calls, the same worker calls and the same errors. The cases vary KV configs, ratios, cookies (legacy formats included), overrides, bypass, the pre-handler, POST bodies, chunked multi-byte bodies, redirects, failing origins and the circuit breaker. - v7's helpers get the same inputs as ours.
- Inside the real
createDecoWorker, a MISS and a HIT are both tagged, the page renders once, the preloads appear once, other routes still answer, and bypassed or unconfigured requests stay untagged. - 12 mutations: the tests catch all 12.
- 3,000 seeded requests through v7's
- A storefront that vendored v7's copy now uses this handler on its TanStack binding branch; that commit is local to the site's worktree.
PO decision 3 against the compat random
@decocms/blocks' multivariate now stops at the first matching rule, as v7 did. A new test resolves 2,000 seeded pages two ways:
- with the real
multivariateand the block map's compatrandom(saved randoms carry their name ascohort); - with v7's resolver loop over
evaluateVariantRule, thenpersistFlags.
Both give the same winner and the same deco_segment Set-Cookie. On more than 300 of those pages, rules after the winner are never drawn. If every rule is evaluated, the test fails. The 5,000-page sticky fuzz still passes.
/nextjs, /nextjs/server and /nextjs/proxy
- The URL the matchers read. A server component sees the request's headers but not its URL, and a proxy may rewrite the path a page renders under. So the site's proxy forwards the visitor's URL on
x-deco-page-urlwithpageUrlRequestHeaders(), which replaces any copy that came from outside./nextjs/proxyimports no React and nonext. matcherContext()is async: it readsnext/headersand keeps the flags per request through React'scache.- The cookie. A server component can't set a cookie, and the proxy runs before the page has made its decisions. So
SegmentCookierenders a one-line script that writes the same cookie bytes as v7, and, after a client-side navigation (where React runs no script it renders), an effect that writes the same value. The page renders it after its content has resolved. A page that reads the request is dynamic, so Next never shares it between visitors. requestDevice()and the sameDeviceProvider.
Boundaries
tests/compat-v7.guardrail.test.ts now checks:
/nextjs*never reaches@decocms/tanstackor@tanstack/*, and/tanstack*never reaches@decocms/nextjsornext;- the client entries import nothing server-only;
- the framework subpaths import only declared peers;
- no other package (blocks, tanstack, nextjs, apps-*) depends on compat-v7 or imports it. The import check now also catches side-effect imports, which it missed before.
I verified each check by planting a violation.
The bindings and their frameworks (@tanstack/react-start, next, react) are optional peers. Nothing new in @decocms/blocks.
Tests
- Sticky random on each framework: 5,000 seeded pages per framework through
matcherContext()and the cookie writer. The decisions, the response's Set-Cookie list (on TanStack) and the browser cookie (on Next) equal what v7'sevaluateVariantRuleandpersistFlagsproduced. This includes returning visitors, who get no new cookie. - Cache split:
cohortSegmentequals v7's worker token over 20,000 cookie headers. - Device: the device context matches v7's
useDeviceContext.tsxwith a value, in a browser, and on the server. - Mutations: I made 6 mutations in the framework code and the tests caught 5. The sixth, dropping
.trim()on the cookie value, can't change a result, becauseatobignores whitespace.
Gates
At ddd58ce2, after merging the lower branches up through P7 aedc30b7. That merge brings in PO decisions 1ā5: NODE_ENV dev mode, deco schema --check, the first-match multivariate, the "." shortcut on by default, and the leading host strip.
| Gate | Result |
|---|---|
bun run check | pass (GOMAXPROCS=4) |
bun run build | pass |
| Plain-Node import of every built export | 64/64 across all packages, compat-v7 27/27; deco --help lists --check |
skills:check and skills:readme:check | pass |
bun run test (2 GB cap) | 2,640 passed, 4 failed, 95 skipped. One failure is DS-2, which fails the same way on main since #689 (a fix is open against main). The other three are schema.test.ts timeouts under full-suite load. Run alone, those two files pass 68/69, and the one failure is DS-2. |
| compat-v7 + guardrail + schema tests | 148 at the merge, 155 with this commit |
At 0b91f011 (the token fix): bun run check and bun run build pass; plain-Node import 64/64; skills checks pass; bun run test 2,642 passed, 3 failed (DS-2 and two schema.test.ts timeouts; those two files alone 68/69, only DS-2), 95 skipped. The new test (creating the handler draws no random value and does no I/O) fails on the previous code.
Release dry run (throwaway clone)
semantic-release --dry-rungives 8.3.0.- After the version sync and the build, all 11 packages are at 8.3.0 with no
workspace:range, andnpm publish --dry-runpasses for each. The compat-v7 tarball has 172 files and no tests or fixtures. - Consumers: one with blocks, tanstack, compat-v7 and apps-vtex (it resolved
@tanstack/react-start1.169.2) and one with blocks, nextjs, compat-v7, apps-vtex andnext16.4.1. Each has one copy of@decocms/blocks, and plain Node imports each consumer's subpaths. The Next consumer gets no TanStack package. - Name:
@decocms/compat-v7returns 404 on npm, and 7.x has nopackages/compat-v7, so 7.x never publishes this name. The first publish needs the Trusted Publishing bootstrap (noted in #702).
Differences from v7
- Next.js sites: v7's Next binding never wrote
deco_segment, so random was drawn again on every request. Here random is sticky on Next too, as approved ("latest 7.x sticky for every site"), and the cookie is written by a page script instead of aSet-Cookieheader. useDevice()outside a provider on the server returns"desktop". v7 read its request scope; mountDeviceProviderwithrequestDevice()to get the request's device.- The A/B split runs inside the worker (
routes), where v7 wrapped it around everything. The worker answers/deco/_liveness, the cache purge and the CMS redirects before the split runs. A legacy-origin response then leaves through the worker's single exit like anyroutesresponse, so it getsCDN-Cache-Control: no-storewhen it sets none and the site's security headers when it sets none. It gets none of the worker'spreloadLink hints (they name the new site's assets), as in v7 (P468167723, compat7fad06ae). v7 passed the legacy response through untouched. A site's middleware around the worker (for example its VTEX cookie middleware) now also sees legacy responses.
For the step that wires compat-v7 into the migrator and the sites
- Replace
@decocms/apps-commerce/imports with@decocms/compat-v7/commerce/. The skill's import map still says to copy these files into the site. - Multivariate: no longer a difference. Since PO decision 3,
@decocms/blocksstops at the first match as v7 did (tested above with the compat random).
Review round (2026-10-15)
7aecbca3: the fuzz against v7'swithABTestingalso draws ratios that round to 1% and 99% (0.004, 0.006, 0.01, 0.99). An off-by-one at the always-fallback or always-worker edge used to pass; both now fail it.7fad06ae(after P468167723): the legacy page carries nopreloadhints; the inner call's hints are no longer stripped, so the "worker" bucket keeps them once. New test: the fallback bucket's HTML response has noLinkheader. Reverting the P4 check fails it and the worker-bucket test (hints twice).
Main refresh (2026-10-17, 18e9a8bb)
- Merged C3
3c1d051cinto this branch (normal merge, no conflicts), then merged up the stack to M2 #707. - Gates at the top of the stack (M2
5131a3c1):bun run check,bun run build,skills:check,skills:readme:checkpass.bun run test(2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run. - CI on this PR's new head: all checks pass.
š¤ Generated with Claude Code