@decocms/tanstack 7.71.0 → 7.71.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.71.0",
3
+ "version": "7.71.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -27,9 +27,9 @@
27
27
  "lint:unused": "knip"
28
28
  },
29
29
  "dependencies": {
30
- "@decocms/blocks": "7.71.0",
31
- "@decocms/blocks-admin": "7.71.0",
32
- "@decocms/blocks-cli": "7.71.0",
30
+ "@decocms/blocks": "7.71.2",
31
+ "@decocms/blocks-admin": "7.71.2",
32
+ "@decocms/blocks-cli": "7.71.2",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -0,0 +1,328 @@
1
+ // @vitest-environment node
2
+
3
+ import { setBlocks } from "@decocms/blocks/cms";
4
+ import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
5
+ import { type CacheKVNamespace, createKVCacheStorage } from "@decocms/blocks/sdk/cacheStorage";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createDecoWorkerEntry } from "./workerEntry";
8
+
9
+ // Regression suite for cache poisoning via tracking params, measured on a
10
+ // production storefront: a MISS on `/produtos/vestido?gclid=POISON42` stored the
11
+ // page under the clean key, and the next visitor on `/produtos/vestido` got a
12
+ // HIT with `POISON42` embedded 14 times (page URL, router match id, analytics
13
+ // `category_url`, pagination base URL).
14
+
15
+ function makeKV(): CacheKVNamespace {
16
+ const values = new Map<string, string>();
17
+ return {
18
+ get: async (key) => values.get(key) ?? null,
19
+ put: async (key, value) => {
20
+ values.set(key, value);
21
+ },
22
+ delete: async (key) => {
23
+ values.delete(key);
24
+ },
25
+ };
26
+ }
27
+
28
+ const jobs: Promise<unknown>[] = [];
29
+ const ctx = { waitUntil: (w: Promise<unknown>) => jobs.push(w), passThroughOnException() {} };
30
+ async function flush() {
31
+ while (jobs.length) await Promise.all(jobs.splice(0));
32
+ }
33
+
34
+ const options = {
35
+ observability: false as const,
36
+ outboundUserAgent: false as const,
37
+ geoCacheKey: "off" as const,
38
+ buildSegment: () => ({ device: "desktop" as const }),
39
+ cacheStorage: (bindings: Record<string, unknown>) =>
40
+ createKVCacheStorage(bindings.CACHE as CacheKVNamespace),
41
+ };
42
+ const env = () => ({ CACHE: makeKV(), BUILD_HASH: "build-A" });
43
+
44
+ /** Origin that embeds the URL it rendered — like a real page does. */
45
+ const echoOrigin = () => ({
46
+ fetch: vi.fn(
47
+ async (req: Request) =>
48
+ new Response(`<html>rendered ${req.url}</html>`, {
49
+ headers: { "content-type": "text/html" },
50
+ }),
51
+ ),
52
+ });
53
+
54
+ const page = (path: string) =>
55
+ new Request(`https://shop.test${path}`, { headers: { accept: "text/html" } });
56
+
57
+ /**
58
+ * Move the clock forward without fake timers (the cache storage and tracing
59
+ * helpers await real promises). The `listing` profile is fresh=120s, swr=900s,
60
+ * so +200s lands squarely in the stale-while-revalidate window.
61
+ */
62
+ let clockOffsetMs = 0;
63
+ const INSIDE_SWR_MS = 200_000;
64
+
65
+ beforeEach(() => {
66
+ clearLoaderCache();
67
+ setBlocks({});
68
+ clockOffsetMs = 0;
69
+ const realNow = Date.now.bind(Date);
70
+ vi.spyOn(Date, "now").mockImplementation(() => realNow() + clockOffsetMs);
71
+ });
72
+
73
+ afterEach(() => vi.restoreAllMocks());
74
+
75
+ describe("tracking params never poison the edge cache", () => {
76
+ it("does not store a page rendered from a tracked URL", async () => {
77
+ const origin = echoOrigin();
78
+ const bindings = env();
79
+ const worker = createDecoWorkerEntry(origin, options);
80
+
81
+ const tracked = await worker.fetch(page("/produtos?gclid=POISON42"), bindings, ctx);
82
+ expect(tracked.headers.get("X-Cache")).toBe("MISS");
83
+ expect(tracked.headers.get("X-Cache-Store")).toBe("skipped-tracking");
84
+ await flush();
85
+
86
+ const clean = await worker.fetch(page("/produtos"), bindings, ctx);
87
+ expect(clean.headers.get("X-Cache")).toBe("MISS");
88
+ expect(await clean.text()).not.toContain("POISON42");
89
+ });
90
+
91
+ it("still serves tracked requests from the clean entry", async () => {
92
+ const origin = echoOrigin();
93
+ const bindings = env();
94
+ const worker = createDecoWorkerEntry(origin, options);
95
+
96
+ await worker.fetch(page("/produtos"), bindings, ctx);
97
+ await flush();
98
+
99
+ const tracked = await worker.fetch(page("/produtos?utm_source=ads&gclid=X1"), bindings, ctx);
100
+ expect(tracked.headers.get("X-Cache")).toBe("HIT");
101
+ const body = await tracked.text();
102
+ expect(body).not.toContain("X1");
103
+ expect(body).not.toContain("utm_source");
104
+ expect(origin.fetch).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("revalidates a stale entry from the clean URL, not the tracked one", async () => {
108
+ const origin = echoOrigin();
109
+ const bindings = env();
110
+ const worker = createDecoWorkerEntry(origin, options);
111
+
112
+ await worker.fetch(page("/produtos"), bindings, ctx);
113
+ await flush();
114
+
115
+ clockOffsetMs = INSIDE_SWR_MS;
116
+ const stale = await worker.fetch(page("/produtos?gclid=POISON42"), bindings, ctx);
117
+ expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
118
+ await flush();
119
+
120
+ // The background render must see the clean URL. Rendering the tracked one
121
+ // would be discarded by the store guard — a full SSR for nothing, on every
122
+ // ad visitor, for the rest of the SWR window.
123
+ const rendered = origin.fetch.mock.calls.map((c) => (c[0] as Request).url);
124
+ expect(rendered).toHaveLength(2);
125
+ expect(rendered[1]).not.toContain("POISON42");
126
+
127
+ // And it refreshed the entry, so paid traffic keeps the cache warm.
128
+ clockOffsetMs = INSIDE_SWR_MS + 1_000;
129
+ const next = await worker.fetch(page("/produtos"), bindings, ctx);
130
+ expect(next.headers.get("X-Cache")).toBe("HIT");
131
+ expect(await next.text()).not.toContain("POISON42");
132
+ expect(origin.fetch).toHaveBeenCalledTimes(2);
133
+ });
134
+
135
+ it("revalidates a stale GET server fn from the clean payload", async () => {
136
+ const origin = echoOrigin();
137
+ const bindings = env();
138
+ const worker = createDecoWorkerEntry(origin, options);
139
+ const sfn = (pageUrl: string) =>
140
+ new Request(
141
+ `https://shop.test/_serverFn/loadCmsPage?payload=${encodeURIComponent(
142
+ JSON.stringify({ data: { pageUrl } }),
143
+ )}`,
144
+ );
145
+
146
+ await worker.fetch(sfn("/produtos"), bindings, ctx);
147
+ await flush();
148
+
149
+ clockOffsetMs = INSIDE_SWR_MS;
150
+ // The tracking lives inside `payload`, not in the URL's own query — so
151
+ // cleaning the URL alone would render (and store) the tracked page.
152
+ const stale = await worker.fetch(sfn("/produtos?gclid=POISON42"), bindings, ctx);
153
+ expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
154
+ await flush();
155
+
156
+ clockOffsetMs = INSIDE_SWR_MS + 1_000;
157
+ const next = await worker.fetch(sfn("/produtos"), bindings, ctx);
158
+ expect(next.headers.get("X-Cache")).toBe("HIT");
159
+ expect(await next.text()).not.toContain("POISON42");
160
+ });
161
+
162
+ it("stores clean pages as before", async () => {
163
+ const origin = echoOrigin();
164
+ const bindings = env();
165
+ const worker = createDecoWorkerEntry(origin, options);
166
+
167
+ const first = await worker.fetch(page("/produtos?sort=price:asc"), bindings, ctx);
168
+ expect(first.headers.get("X-Cache-Store")).toBeNull();
169
+ await flush();
170
+ const second = await worker.fetch(page("/produtos?sort=price:asc"), bindings, ctx);
171
+ expect(second.headers.get("X-Cache")).toBe("HIT");
172
+ });
173
+ });
174
+
175
+ describe("POST server fn: tracked pageUrl converges on one key and is never stored", () => {
176
+ const sfnOrigin = () => ({
177
+ fetch: vi.fn(async (req: Request) => {
178
+ const body = await req.text();
179
+ return new Response(`section for ${body}`, {
180
+ headers: { "content-type": "application/json", "X-Deco-Cacheable": "true" },
181
+ });
182
+ }),
183
+ });
184
+ const post = (pageUrl: string) =>
185
+ new Request("https://shop.test/_serverFn/abc123", {
186
+ method: "POST",
187
+ headers: { "content-type": "application/json" },
188
+ body: JSON.stringify({ data: { component: "Shelf", pageUrl } }),
189
+ });
190
+
191
+ it("serves a tracked body from the clean body's entry", async () => {
192
+ const origin = sfnOrigin();
193
+ const bindings = env();
194
+ const worker = createDecoWorkerEntry(origin, options);
195
+
196
+ const clean = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
197
+ expect(clean.headers.get("X-Cache")).toBe("MISS");
198
+ await flush();
199
+
200
+ const tracked = await worker.fetch(
201
+ post("https://shop.test/produtos?gclid=POISON42"),
202
+ bindings,
203
+ ctx,
204
+ );
205
+ expect(tracked.headers.get("X-Cache")).toBe("HIT");
206
+ expect(await tracked.text()).not.toContain("POISON42");
207
+ });
208
+
209
+ it("does not store a response rendered from a tracked body", async () => {
210
+ const origin = sfnOrigin();
211
+ const bindings = env();
212
+ const worker = createDecoWorkerEntry(origin, options);
213
+
214
+ await worker.fetch(post("https://shop.test/produtos?gclid=POISON42"), bindings, ctx);
215
+ await flush();
216
+
217
+ const clean = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
218
+ expect(clean.headers.get("X-Cache")).toBe("MISS");
219
+ expect(await clean.text()).not.toContain("POISON42");
220
+ });
221
+
222
+ it("labels the skipped store, like the HTML path does", async () => {
223
+ const worker = createDecoWorkerEntry(sfnOrigin(), options);
224
+
225
+ const tracked = await worker.fetch(
226
+ post("https://shop.test/produtos?gclid=POISON42"),
227
+ env(),
228
+ ctx,
229
+ );
230
+ expect(tracked.headers.get("X-Cache")).toBe("MISS");
231
+ expect(tracked.headers.get("X-Cache-Store")).toBe("skipped-tracking");
232
+ });
233
+
234
+ it("revalidates a stale entry from the canonical body", async () => {
235
+ const origin = sfnOrigin();
236
+ const bindings = env();
237
+ const worker = createDecoWorkerEntry(origin, options);
238
+
239
+ await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
240
+ await flush();
241
+
242
+ clockOffsetMs = INSIDE_SWR_MS;
243
+ const stale = await worker.fetch(
244
+ post("https://shop.test/produtos?gclid=POISON42"),
245
+ bindings,
246
+ ctx,
247
+ );
248
+ expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
249
+ await flush();
250
+
251
+ // Refreshed rather than discarded: a third request HITs without a third
252
+ // origin render, and the stored section never saw the landing URL.
253
+ clockOffsetMs = INSIDE_SWR_MS + 1_000;
254
+ const next = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
255
+ expect(next.headers.get("X-Cache")).toBe("HIT");
256
+ expect(await next.text()).not.toContain("POISON42");
257
+ expect(origin.fetch).toHaveBeenCalledTimes(2);
258
+ });
259
+ });
260
+
261
+ describe("buildSegment geo split warning", () => {
262
+ const geo = (path: string) =>
263
+ new Request(`https://shop.test${path}`, {
264
+ headers: { accept: "text/html", "cf-region-code": "RJ" },
265
+ });
266
+ const cfRegionSegment = (req: Request) => ({
267
+ device: "desktop" as const,
268
+ regionId: req.headers.get("cf-region-code") ?? undefined,
269
+ });
270
+ const warned = (warn: { mock: { calls: unknown[][] } }) =>
271
+ warn.mock.calls.filter((c) => String(c[0]).includes("buildSegment sets regionId"));
272
+
273
+ it("warns once when regionId is the CF region and no location matcher exists", async () => {
274
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
275
+ // A loaded decofile with no location matcher — that's what makes
276
+ // `geoCacheKey: "auto"` resolving to "off" an answer and not a default.
277
+ setBlocks({ "/": { __resolveType: "website/pages/Page.tsx" } });
278
+ const worker = createDecoWorkerEntry(echoOrigin(), {
279
+ ...options,
280
+ geoCacheKey: "auto",
281
+ buildSegment: cfRegionSegment,
282
+ });
283
+ await worker.fetch(geo("/a"), env(), ctx);
284
+ await worker.fetch(geo("/b"), env(), ctx);
285
+ expect(warned(warn)).toHaveLength(1);
286
+ });
287
+
288
+ it("stays quiet before the decofile loads", async () => {
289
+ // `_autoGeoKey` defaults to "off", so warning here would tell a site that
290
+ // DOES have a location matcher to delete the fallback it needs — and the
291
+ // flag latches, so the bogus advice is all they ever see.
292
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
293
+ const worker = createDecoWorkerEntry(echoOrigin(), {
294
+ ...options,
295
+ geoCacheKey: "auto",
296
+ buildSegment: cfRegionSegment,
297
+ });
298
+ await worker.fetch(geo("/a"), env(), ctx);
299
+ expect(warned(warn)).toHaveLength(0);
300
+
301
+ // Once the decofile lands and really has no matcher, it does warn.
302
+ setBlocks({ "/": { __resolveType: "website/pages/Page.tsx" } });
303
+ await worker.fetch(geo("/b"), env(), ctx);
304
+ expect(warned(warn)).toHaveLength(1);
305
+ });
306
+
307
+ it("stays quiet for a VTEX regionId", async () => {
308
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
309
+ const worker = createDecoWorkerEntry(echoOrigin(), {
310
+ ...options,
311
+ geoCacheKey: "auto",
312
+ buildSegment: () => ({
313
+ device: "desktop" as const,
314
+ regionId: "v2.1BB18CE648B5111D0933734ED83EC783",
315
+ }),
316
+ });
317
+ await worker.fetch(
318
+ new Request("https://shop.test/a", {
319
+ headers: { accept: "text/html", "cf-region-code": "RJ" },
320
+ }),
321
+ env(),
322
+ ctx,
323
+ );
324
+ expect(warn.mock.calls.some((c) => String(c[0]).includes("buildSegment sets regionId"))).toBe(
325
+ false,
326
+ );
327
+ });
328
+ });
@@ -48,6 +48,7 @@ import {
48
48
  detectCacheProfile,
49
49
  edgeCacheConfig,
50
50
  getCacheProfile,
51
+ payloadHasTrackingParams,
51
52
  serverFnPagePath,
52
53
  } from "@decocms/blocks/sdk/cacheHeaders";
53
54
  import {
@@ -85,7 +86,7 @@ import {
85
86
  } from "@decocms/blocks/sdk/redirects";
86
87
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
87
88
  import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
88
- import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
89
+ import { cleanPathForCacheKey, hasTrackingParams } from "@decocms/blocks/sdk/urlUtils";
89
90
  import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
90
91
  import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
91
92
  import { reconfigureAppsOnce } from "@decocms/blocks-admin/apps/autoconfig";
@@ -966,6 +967,15 @@ const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+
966
967
  // Sites that need city precision should set geoCacheKey: "city" explicitly.
967
968
 
968
969
  let _autoGeoKey: "off" | "region" = "off";
970
+ /**
971
+ * Whether `_autoGeoKey` is an answer or still the pre-hydration default. It
972
+ * starts `"off"` and only becomes `"region"` once a decofile carrying a
973
+ * location matcher loads, so anything that *reports* on `"off"` (rather than
974
+ * just keying on it) has to wait for a non-empty decofile first. Recomputed on
975
+ * every change rather than latched, so a reset back to an empty decofile
976
+ * un-settles it.
977
+ */
978
+ let _autoGeoSettled = false;
969
979
  let _autoGeoListenerInstalled = false;
970
980
 
971
981
  function installAutoGeoListener(): void {
@@ -973,6 +983,7 @@ function installAutoGeoListener(): void {
973
983
  _autoGeoListenerInstalled = true;
974
984
  const update = (blocks: Record<string, unknown>) => {
975
985
  _autoGeoKey = detectLocationMatcher(blocks) ? "region" : "off";
986
+ _autoGeoSettled = Object.keys(blocks).length > 0;
976
987
  };
977
988
  onChange(update);
978
989
  update(loadBlocks());
@@ -1138,9 +1149,40 @@ export function createDecoWorkerEntry(
1138
1149
  return fromHeader || undefined;
1139
1150
  }
1140
1151
 
1152
+ // Sites generated by older migration templates set `regionId` from the
1153
+ // Cloudflare state on every request, which splits every page's cache ~27x
1154
+ // (one copy per BR state) even when no content varies by location. The
1155
+ // framework already does this backfill below, and only when a location
1156
+ // matcher exists — so flag the redundant, unconditional version once.
1157
+ let warnedGeoSegment = false;
1158
+ /**
1159
+ * Whether `effectiveGeoKey() === "off"` is a real answer or just the
1160
+ * pre-hydration default. Under `geoCacheKey: "auto"` a request that lands
1161
+ * before the decofile loads would otherwise latch the warning below and tell
1162
+ * the team to delete a geo fallback its location matcher actually needs. An
1163
+ * explicit `geoCacheKey` has no such race.
1164
+ */
1165
+ function geoKeySettled(): boolean {
1166
+ return geoCacheKeyOpt !== "auto" || _autoGeoSettled;
1167
+ }
1141
1168
  const buildSegment = rawBuildSegment
1142
1169
  ? (request: Request): SegmentKey => {
1143
1170
  const seg = rawBuildSegment(request);
1171
+ if (
1172
+ !warnedGeoSegment &&
1173
+ seg.regionId &&
1174
+ effectiveGeoKey() === "off" &&
1175
+ geoKeySettled() &&
1176
+ seg.regionId === readRegionFromRequest(request)
1177
+ ) {
1178
+ warnedGeoSegment = true;
1179
+ console.warn(
1180
+ `[deco] buildSegment sets regionId to the Cloudflare region ("${seg.regionId}") ` +
1181
+ "but no website/matchers/location.ts block exists, so every page's edge cache " +
1182
+ "is split per region for nothing. Return only the VTEX regionId from buildSegment " +
1183
+ 'and let geoCacheKey: "auto" backfill the region when a location matcher is added.',
1184
+ );
1185
+ }
1144
1186
  if (seg.regionId) return seg;
1145
1187
  if (effectiveGeoKey() === "off") return seg;
1146
1188
  const region = readRegionFromRequest(request);
@@ -1412,6 +1454,15 @@ export function createDecoWorkerEntry(
1412
1454
  return parts.join("|");
1413
1455
  }
1414
1456
 
1457
+ function requestCarriesTracking(url: URL): boolean {
1458
+ if (hasTrackingParams(url.toString())) return true;
1459
+ if (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/")) {
1460
+ const payload = url.searchParams.get("payload");
1461
+ return !!payload && payloadHasTrackingParams(payload);
1462
+ }
1463
+ return false;
1464
+ }
1465
+
1415
1466
  function buildCacheKey(
1416
1467
  request: Request,
1417
1468
  env: Record<string, unknown>,
@@ -2543,7 +2594,14 @@ export function createDecoWorkerEntry(
2543
2594
  // work correctly. We only read the body for the cache key hash.
2544
2595
  const originClone = request.clone();
2545
2596
  const body = await request.text();
2546
- const bodyHash = await hashText(body);
2597
+ // Key on the body with tracking params stripped (a deferred section's
2598
+ // `pageUrl` carries the landing URL, so `?gclid=…` otherwise mints one
2599
+ // entry per ad click), but never *store* a response rendered from a
2600
+ // tracked body: it embeds that URL, and every later visitor would get it.
2601
+ const sfnCarriesTracking = shouldStripTracking && payloadHasTrackingParams(body);
2602
+ const bodyHash = await hashText(
2603
+ shouldStripTracking ? canonicalizeServerFnPayloadForCacheKey(body, []) : body,
2604
+ );
2547
2605
 
2548
2606
  // Build a synthetic GET cache key from the URL + body hash + segment
2549
2607
  // Includes device, salesChannel, regionId, flags — so users in
@@ -2606,7 +2664,15 @@ export function createDecoWorkerEntry(
2606
2664
  ctx.waitUntil(
2607
2665
  (async () => {
2608
2666
  try {
2609
- const bgReq = new Request(request, { body, method: "POST" });
2667
+ // Revalidate from the canonical body, for the same reason the
2668
+ // HTML path revalidates from the clean URL: the entry is keyed
2669
+ // on it, and a render from the tracked body embeds that landing
2670
+ // URL. Rendering the tracked body would only be discarded,
2671
+ // leaving the entry stale for the rest of the SWR window.
2672
+ const bgBody = sfnCarriesTracking
2673
+ ? canonicalizeServerFnPayloadForCacheKey(body, [])
2674
+ : body;
2675
+ const bgReq = new Request(request, { body: bgBody, method: "POST" });
2610
2676
  const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
2611
2677
  if (
2612
2678
  bgOrigin.status === 200 &&
@@ -2660,8 +2726,8 @@ export function createDecoWorkerEntry(
2660
2726
  return resp;
2661
2727
  }
2662
2728
 
2663
- // Store in edge cache
2664
- if (serverFnCache) {
2729
+ // Store in edge cache (see sfnCarriesTracking above)
2730
+ if (serverFnCache && !sfnCarriesTracking) {
2665
2731
  try {
2666
2732
  const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
2667
2733
  const toStore = origin.clone();
@@ -2686,6 +2752,9 @@ export function createDecoWorkerEntry(
2686
2752
  for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
2687
2753
  resp.headers.set("X-Cache", "MISS");
2688
2754
  resp.headers.set("X-Cache-Profile", sfnProfile);
2755
+ // Same label the HTML path emits, so the documented probe works on a
2756
+ // deferred section too.
2757
+ if (sfnCarriesTracking) resp.headers.set("X-Cache-Store", "skipped-tracking");
2689
2758
  return resp;
2690
2759
  }
2691
2760
 
@@ -2731,6 +2800,13 @@ export function createDecoWorkerEntry(
2731
2800
  // Cacheable request — build segment-aware cache key
2732
2801
  const { key: cacheKey, segment } = buildCacheKey(request, env);
2733
2802
 
2803
+ // The key drops tracking params, but the renderer sees the raw URL and
2804
+ // embeds it (page URL, router match id, analytics payloads, pagination
2805
+ // base URLs). Storing that response under the clean key served one ad
2806
+ // visitor's `gclid` to everyone. Tracked requests still READ the clean
2807
+ // entry; only clean requests WRITE it.
2808
+ const skipStoreTracking = shouldStripTracking && requestCarriesTracking(url);
2809
+
2734
2810
  // Logged-in users always bypass the cache (personalized content)
2735
2811
  if (segment?.loggedIn) {
2736
2812
  const origin = await serverEntry.fetch(request, env, ctx);
@@ -2810,7 +2886,9 @@ export function createDecoWorkerEntry(
2810
2886
  return out;
2811
2887
  }
2812
2888
 
2813
- // Helper: store a response in Cache API with the full retention window
2889
+ // Helper: store a response in Cache API with the full retention window.
2890
+ // Callers own the tracking decision: a response is storable iff it was
2891
+ // rendered from the clean URL this `cacheKey` represents.
2814
2892
  function storeInCache(resp: Response) {
2815
2893
  if (!cache) return;
2816
2894
  try {
@@ -2830,10 +2908,46 @@ export function createDecoWorkerEntry(
2830
2908
  }
2831
2909
  }
2832
2910
 
2911
+ // A tracked request must revalidate from the CLEAN URL. The renderer
2912
+ // embeds the URL it was given, and the entry being refreshed is keyed
2913
+ // clean — so rendering the tracked URL here is either poisoning (what this
2914
+ // used to do) or dead weight (`storeInCache` drops it), which would leave
2915
+ // the entry stale for the whole SWR window while every ad visitor pays a
2916
+ // full SSR that refreshes nothing. Rendering clean costs the same fetch
2917
+ // and actually warms the entry, so paid traffic keeps the cache alive.
2918
+ function revalidationRequest(): Request {
2919
+ if (!skipStoreTracking) return request;
2920
+ const clean = new URL(cleanPathForCacheKey(url.toString()), url.origin);
2921
+ // A GET server fn can carry the tracking inside `payload` (the page it
2922
+ // loads), not in its own query — strip it there too, or this renders and
2923
+ // stores the tracked page under the clean key.
2924
+ const payload = clean.searchParams.get("payload");
2925
+ if (
2926
+ payload &&
2927
+ (clean.pathname.startsWith("/_serverFn/") || clean.pathname.startsWith("/_server/"))
2928
+ ) {
2929
+ clean.searchParams.set("payload", canonicalizeServerFnPayloadForCacheKey(payload, []));
2930
+ }
2931
+ // Build the init field by field instead of passing `request` as init:
2932
+ // whether the `new Request(url, request)` form carries `cf` over is
2933
+ // runtime-dependent, and a location matcher reading a dropped `cf` would
2934
+ // store a wrong-region render under a region-keyed entry. No body — this
2935
+ // path is GET-only (every other method bypasses the cache above) — and
2936
+ // no `signal`, so a client disconnecting mid-revalidation doesn't abort
2937
+ // the refresh that the next visitor is going to read.
2938
+ const cf = (request as unknown as { cf?: unknown }).cf;
2939
+ return new Request(clean.toString(), {
2940
+ method: request.method,
2941
+ headers: request.headers,
2942
+ redirect: request.redirect,
2943
+ ...(cf ? { cf } : {}),
2944
+ } as RequestInit);
2945
+ }
2946
+
2833
2947
  // Helper: background revalidation (fetch origin, store result)
2834
2948
  function revalidateInBackground() {
2835
2949
  ctx.waitUntil(
2836
- Promise.resolve(serverEntry.fetch(request, env, ctx))
2950
+ Promise.resolve(serverEntry.fetch(revalidationRequest(), env, ctx))
2837
2951
  .then((origin) => {
2838
2952
  // Never overwrite a good entry with a degraded 200 (a critical
2839
2953
  // section failed) — that would poison the cache. Leave the stale
@@ -3008,7 +3122,13 @@ export function createDecoWorkerEntry(
3008
3122
  const cacheOrigin = origin.headers.has("set-cookie")
3009
3123
  ? stripSafeCookiesForCache(origin, safeCookieSet)
3010
3124
  : origin;
3011
- storeInCache(cacheOrigin);
3012
- return dressResponse(origin, "MISS");
3125
+ // This render saw the tracked URL and embedded it — never store it under
3126
+ // the clean key. The next clean visitor fills the entry.
3127
+ if (!skipStoreTracking) storeInCache(cacheOrigin);
3128
+ return dressResponse(
3129
+ origin,
3130
+ "MISS",
3131
+ skipStoreTracking ? { "X-Cache-Store": "skipped-tracking" } : undefined,
3132
+ );
3013
3133
  }
3014
3134
  }