@decocms/blocks 7.62.1 → 7.62.3
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 +1 -1
- package/src/cms/resolve.test.ts +44 -0
- package/src/cms/resolve.ts +25 -10
- package/src/sdk/cacheHeaders.test.ts +29 -0
- package/src/sdk/cacheHeaders.ts +29 -4
- package/src/sdk/urlUtils.ts +11 -0
package/package.json
CHANGED
package/src/cms/resolve.test.ts
CHANGED
|
@@ -239,6 +239,50 @@ describe("commerce loader auto-injects URL search params as props", () => {
|
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
it("strips tracking params from __pageUrl and never injects them as props", async () => {
|
|
243
|
+
const calls: Array<Record<string, unknown>> = [];
|
|
244
|
+
registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
|
|
245
|
+
calls.push({ ...props });
|
|
246
|
+
return null;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, {
|
|
250
|
+
url: "https://store.com/produto/sabonete/p?skuId=12345&utm_source=google&gclid=abc&srsltid=xyz",
|
|
251
|
+
path: "/produto/sabonete/p",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(calls[0]).toMatchObject({
|
|
255
|
+
slug: "sabonete",
|
|
256
|
+
skuId: "12345",
|
|
257
|
+
__pageUrl: "https://store.com/produto/sabonete/p?skuId=12345",
|
|
258
|
+
});
|
|
259
|
+
expect(calls[0]).not.toHaveProperty("utm_source");
|
|
260
|
+
expect(calls[0]).not.toHaveProperty("gclid");
|
|
261
|
+
expect(calls[0]).not.toHaveProperty("srsltid");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The point of the strip: `createCachedLoader`'s default keyFn is
|
|
265
|
+
// JSON.stringify(props), so identical props mean a shared cache entry. A
|
|
266
|
+
// campaign tail used to mint a fresh one on every paid landing.
|
|
267
|
+
it("gives a paid-traffic landing the same props as the clean URL", async () => {
|
|
268
|
+
const calls: Array<Record<string, unknown>> = [];
|
|
269
|
+
registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
|
|
270
|
+
calls.push({ ...props });
|
|
271
|
+
return null;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const at = (url: string) =>
|
|
275
|
+
resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, {
|
|
276
|
+
url,
|
|
277
|
+
path: "/produto/sabonete/p",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await at("https://store.com/produto/sabonete/p");
|
|
281
|
+
await at("https://store.com/produto/sabonete/p?utm_source=google&gclid=abc&fbclid=d");
|
|
282
|
+
|
|
283
|
+
expect(JSON.stringify(calls[1])).toBe(JSON.stringify(calls[0]));
|
|
284
|
+
});
|
|
285
|
+
|
|
242
286
|
it("does NOT override a CMS-configured prop with a URL param of the same name", async () => {
|
|
243
287
|
const calls: Array<Record<string, unknown>> = [];
|
|
244
288
|
registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
|
package/src/cms/resolve.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { stickyDecide } from "../sdk/experiments";
|
|
|
5
5
|
import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
|
|
6
6
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
7
7
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
8
|
+
import { stripTrackingParams } from "../sdk/urlUtils";
|
|
8
9
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
9
10
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
10
11
|
import {
|
|
@@ -956,21 +957,35 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
956
957
|
resolvedProps.__pagePath = rctx.matcherCtx.path;
|
|
957
958
|
}
|
|
958
959
|
if (rctx.matcherCtx.url) {
|
|
959
|
-
|
|
960
|
+
// Strip tracking params before anything here reaches props.
|
|
961
|
+
//
|
|
962
|
+
// The comment this replaces claimed the enrichment was "safe re: cache
|
|
963
|
+
// fragmentation" because commerce loaders run without a framework-level
|
|
964
|
+
// cache. That was wrong — `getCommerceLoader()` returns the
|
|
965
|
+
// `createCachedLoader`-wrapped functions, whose default keyFn hashes
|
|
966
|
+
// exactly these props — so a `?utm_source`/`gclid`/`srsltid` minted a
|
|
967
|
+
// fresh cache entry and made every paid-traffic landing a permanent cold
|
|
968
|
+
// miss. Measured on a production storefront: 0.2–1.5% hit rate on the
|
|
969
|
+
// default keyFn vs 73.5% on a loader that hand-rolled a key excluding it.
|
|
970
|
+
//
|
|
971
|
+
// Stripping here rather than in the keyFn covers `__pageUrl` and the
|
|
972
|
+
// param spread below in one place, and matches how the edge cache key is
|
|
973
|
+
// already normalized in workerEntry. No loader reads tracking params.
|
|
974
|
+
//
|
|
975
|
+
// Deliberately does NOT drop `__pageUrl`/`__pagePath` themselves: on a
|
|
976
|
+
// PDP the path IS the product identity (the CMS block leaves `slug`
|
|
977
|
+
// null) and on a PLP the query carries the facets. Dropping them would
|
|
978
|
+
// merge distinct results onto one entry and serve wrong content.
|
|
979
|
+
const pageUrl = stripTrackingParams(rctx.matcherCtx.url);
|
|
980
|
+
resolvedProps.__pageUrl = pageUrl;
|
|
960
981
|
// Auto-inject URL search params as top-level props so loaders that
|
|
961
982
|
// expect `props.skuId` / `props.q` / `props.page` (the apps-start
|
|
962
983
|
// canonical shape) get them populated on direct entry (Google
|
|
963
984
|
// Shopping deep links, paid ads, email campaigns). Existing values
|
|
964
985
|
// from the CMS block win — URL params are a fallback, not an
|
|
965
986
|
// override.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
// path without a framework-level cache (the section/page cache
|
|
969
|
-
// layer hashes section.props BEFORE this enrichment in
|
|
970
|
-
// sectionLoaders.ts), so adding query params here does not
|
|
971
|
-
// fragment any cache key.
|
|
972
|
-
if (URL.canParse(rctx.matcherCtx.url)) {
|
|
973
|
-
const url = new URL(rctx.matcherCtx.url);
|
|
987
|
+
if (URL.canParse(pageUrl)) {
|
|
988
|
+
const url = new URL(pageUrl);
|
|
974
989
|
for (const [k, v] of url.searchParams.entries()) {
|
|
975
990
|
if (resolvedProps[k] !== undefined) continue;
|
|
976
991
|
// `page` is skipped on purpose (#391): loaders that read `page`
|
|
@@ -994,7 +1009,7 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
994
1009
|
// upstream is wrong — surface it so the caller can fix it.
|
|
995
1010
|
console.warn(
|
|
996
1011
|
`[CMS] malformed matcherCtx.url for "${resolveType}"; ` +
|
|
997
|
-
`skipping query-param injection: ${
|
|
1012
|
+
`skipping query-param injection: ${pageUrl}`,
|
|
998
1013
|
);
|
|
999
1014
|
}
|
|
1000
1015
|
}
|
|
@@ -88,6 +88,35 @@ describe("canonicalizeServerFnPayloadForCacheKey — variant-param cache key", (
|
|
|
88
88
|
expect(a).toBe(b);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
it("strips tracking params — paid traffic shares the canonical entry", () => {
|
|
92
|
+
const paid = canonicalizeServerFnPayloadForCacheKey(
|
|
93
|
+
envelope("/aneis?filter.color=ouro&utm_source=google&gclid=abc&srsltid=xyz"),
|
|
94
|
+
);
|
|
95
|
+
const canonical = canonicalizeServerFnPayloadForCacheKey(
|
|
96
|
+
envelope("/aneis?filter.color=ouro"),
|
|
97
|
+
);
|
|
98
|
+
expect(paid).toBe(canonical);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("canonicalizes absolute URLs too, not just paths", () => {
|
|
102
|
+
// Sites embed one in the payload (the deferred-section `pageUrl` is built
|
|
103
|
+
// with `new URL(...)`), and the old guard skipped anything not starting
|
|
104
|
+
// with "/" — so not even skuId was stripped there.
|
|
105
|
+
const messy = canonicalizeServerFnPayloadForCacheKey(
|
|
106
|
+
envelope("https://store.com/mala-preta/p?skuId=148940&utm_source=ads"),
|
|
107
|
+
);
|
|
108
|
+
const canonical = canonicalizeServerFnPayloadForCacheKey(
|
|
109
|
+
envelope("https://store.com/mala-preta/p"),
|
|
110
|
+
);
|
|
111
|
+
expect(messy).toBe(canonical);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("leaves non-URL strings alone", () => {
|
|
115
|
+
const a = canonicalizeServerFnPayloadForCacheKey(envelope("mala preta ?utm_source=x"));
|
|
116
|
+
expect(a).toBe(canonicalizeServerFnPayloadForCacheKey(envelope("mala preta ?utm_source=x")));
|
|
117
|
+
expect(a).toContain("utm_source");
|
|
118
|
+
});
|
|
119
|
+
|
|
91
120
|
it("keeps PLP filter/search params (they DO change the response)", () => {
|
|
92
121
|
const filtered = canonicalizeServerFnPayloadForCacheKey(
|
|
93
122
|
envelope("/c/shoes?filter.size=40"),
|
package/src/sdk/cacheHeaders.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { isTrackingParam } from "./urlUtils";
|
|
21
|
+
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Types
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
@@ -520,9 +522,11 @@ export const DEFAULT_SERVERFN_CACHE_IGNORE_PARAMS = ["skuId", "idsku"];
|
|
|
520
522
|
/**
|
|
521
523
|
* Canonicalize a TanStack GET server-fn `payload` for use in the edge cache
|
|
522
524
|
* key: re-serialize it (so equivalent requests always produce byte-identical
|
|
523
|
-
* keys) and strip the
|
|
524
|
-
*
|
|
525
|
-
*
|
|
525
|
+
* keys) and strip the cache-irrelevant search params — `ignoreParams` plus every
|
|
526
|
+
* known tracking param — from any embedded route path or absolute URL. This
|
|
527
|
+
* makes `/product/p?skuId=148940`, `/product/p?utm_source=ads` and `/product/p`
|
|
528
|
+
* share one cache entry, since `loadCmsPage` resolves all three to the same
|
|
529
|
+
* page.
|
|
526
530
|
*
|
|
527
531
|
* Returns the original payload unchanged on any parse failure (fail-safe: the
|
|
528
532
|
* caller keeps the raw payload, never worse than before).
|
|
@@ -532,13 +536,34 @@ export function canonicalizeServerFnPayloadForCacheKey(
|
|
|
532
536
|
ignoreParams: string[] = DEFAULT_SERVERFN_CACHE_IGNORE_PARAMS,
|
|
533
537
|
): string {
|
|
534
538
|
try {
|
|
539
|
+
const drop = (sp: URLSearchParams): void => {
|
|
540
|
+
for (const p of ignoreParams) sp.delete(p);
|
|
541
|
+
// Tracking params never change what the server fn resolves, but they DO
|
|
542
|
+
// change this key — so a shopper arriving from paid media mints a brand
|
|
543
|
+
// new entry on every SPA interaction. Measured on a production
|
|
544
|
+
// storefront: `/_serverFn/:id` at 84% MISS, 1401ms average.
|
|
545
|
+
for (const k of [...sp.keys()]) if (isTrackingParam(k)) sp.delete(k);
|
|
546
|
+
};
|
|
535
547
|
const stripQuery = (s: string): string => {
|
|
548
|
+
// Absolute URL: sites embed one in the payload (the deferred-section
|
|
549
|
+
// `pageUrl` is built with `new URL(...)`), and it fragments the key
|
|
550
|
+
// exactly like a relative path does — but the guard below skipped it, so
|
|
551
|
+
// not even `skuId` was canonicalized there.
|
|
552
|
+
if (/^https?:\/\//i.test(s)) {
|
|
553
|
+
try {
|
|
554
|
+
const u = new URL(s);
|
|
555
|
+
drop(u.searchParams);
|
|
556
|
+
return u.toString();
|
|
557
|
+
} catch {
|
|
558
|
+
return s;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
536
561
|
if (!s.startsWith("/") || s.startsWith("//")) return s;
|
|
537
562
|
const qIdx = s.indexOf("?");
|
|
538
563
|
if (qIdx < 0) return s;
|
|
539
564
|
const path = s.slice(0, qIdx);
|
|
540
565
|
const sp = new URLSearchParams(s.slice(qIdx + 1));
|
|
541
|
-
|
|
566
|
+
drop(sp);
|
|
542
567
|
const rest = sp.toString();
|
|
543
568
|
return rest ? `${path}?${rest}` : path;
|
|
544
569
|
};
|
package/src/sdk/urlUtils.ts
CHANGED
|
@@ -44,6 +44,17 @@ export function registerTrackingParams(params: string[]): void {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Whether a search param is a known tracking/attribution param.
|
|
49
|
+
*
|
|
50
|
+
* Exposed so cache-key builders can filter params in place without having to
|
|
51
|
+
* round-trip through a `URL` (server-fn payloads carry bare relative paths) and
|
|
52
|
+
* without keeping a second copy of the list.
|
|
53
|
+
*/
|
|
54
|
+
export function isTrackingParam(param: string): boolean {
|
|
55
|
+
return UTM_PARAMS.has(param.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
/**
|
|
48
59
|
* Strip UTM and tracking parameters from a URL.
|
|
49
60
|
*
|