@decocms/blocks 7.62.0 → 7.62.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/blocks",
3
- "version": "7.62.0",
3
+ "version": "7.62.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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>) => {
@@ -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
- resolvedProps.__pageUrl = rctx.matcherCtx.url;
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
- // Safe re: cache fragmentation: commerce loaders run through this
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: ${rctx.matcherCtx.url}`,
1012
+ `skipping query-param injection: ${pageUrl}`,
998
1013
  );
999
1014
  }
1000
1015
  }
@@ -5,6 +5,7 @@ import type { ResolvedSection } from "./resolve";
5
5
  import {
6
6
  getDegradedSections,
7
7
  isLayoutSection,
8
+ layoutLoaderCacheKey,
8
9
  registerCacheableSections,
9
10
  registerLayoutSections,
10
11
  registerNonCriticalSections,
@@ -531,3 +532,94 @@ describe("runSectionLoaders — batch span", () => {
531
532
  expect(perSection).toHaveLength(3);
532
533
  });
533
534
  });
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Layout section cache — device is part of the key
538
+ // ---------------------------------------------------------------------------
539
+ //
540
+ // This cache holds a layout section's LOADER OUTPUT, and that is where
541
+ // `isMobile` is born (`ctx.device`, or a loader composed with `withDevice` /
542
+ // `withMobile`). Keyed by component path alone, the first request decided the
543
+ // variant for everyone for 5 minutes. Measured on a real store's PDP with
544
+ // Header/Footer layout-cached: desktop first and a mobile visitor got the
545
+ // desktop header (`h-[90px]`); mobile first and a desktop visitor got
546
+ // `id="header-mobile-menu"`.
547
+ //
548
+ // NOTE: this is a DIFFERENT cache from `resolvedLayoutCache` in `cms/resolve.ts`.
549
+ // That one holds the CMS prop resolution and never sees `isMobile`, so putting
550
+ // the axis there (which is what decocms/blocks#528 did) does not fix this.
551
+
552
+ describe("layout section cache — device axis", () => {
553
+ const MOBILE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile Safari";
554
+ const TABLET = "Mozilla/5.0 (iPad; CPU OS 17_0) Safari/605";
555
+ const DESKTOP = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120 Safari/537.36";
556
+
557
+ // `layoutCache` is module-global with a 5-minute TTL and no exported reset, so
558
+ // a shared component key would leak entries between cases.
559
+ let seq = 0;
560
+ const req = (ua: string) => new Request("https://store.com/p", { headers: { "user-agent": ua } });
561
+
562
+ /** A layout loader that stamps what device it saw — the leak made visible. */
563
+ const setupDeviceLayout = () => {
564
+ seq += 1;
565
+ const key = `site/sections/DeviceLayout${seq}.tsx`;
566
+ let runs = 0;
567
+ registerSectionLoader(key, async (props: Record<string, unknown>, request?: Request) => {
568
+ runs += 1;
569
+ const ua = request?.headers.get("user-agent") ?? "";
570
+ return { ...props, isMobile: /iPhone|Mobile/.test(ua), ua };
571
+ });
572
+ registerLayoutSections([key]);
573
+ return { key, runs: () => runs };
574
+ };
575
+
576
+ const load = async (key: string, ua: string) => {
577
+ const request = req(ua);
578
+ const out = await RequestContext.run(request, () =>
579
+ runSingleSectionLoader(makeSection(key), request),
580
+ );
581
+ return out.props as { isMobile: boolean; ua: string };
582
+ };
583
+
584
+ it("a mobile visitor does NOT get the variant a desktop visitor cached", async () => {
585
+ const { key, runs } = setupDeviceLayout();
586
+
587
+ const desktop = await load(key, DESKTOP);
588
+ expect(desktop.isMobile).toBe(false);
589
+
590
+ const mobile = await load(key, MOBILE);
591
+ // Without the axis this came back `false` — the desktop header on a phone.
592
+ expect(mobile.isMobile).toBe(true);
593
+ expect(runs()).toBe(2);
594
+ });
595
+
596
+ it("and the reverse: desktop after mobile", async () => {
597
+ const { key } = setupDeviceLayout();
598
+ expect((await load(key, MOBILE)).isMobile).toBe(true);
599
+ expect((await load(key, DESKTOP)).isMobile).toBe(false);
600
+ });
601
+
602
+ it("two visitors on the same device share the entry — the cache still works", async () => {
603
+ const { key, runs } = setupDeviceLayout();
604
+ await load(key, DESKTOP);
605
+ await load(key, DESKTOP);
606
+ expect(runs()).toBe(1);
607
+ });
608
+
609
+ it("tablet is its own entry", async () => {
610
+ const { key, runs } = setupDeviceLayout();
611
+ await load(key, MOBILE);
612
+ await load(key, TABLET);
613
+ expect(runs()).toBe(2);
614
+ });
615
+
616
+ it("layoutLoaderCacheKey: three devices, three keys; no request still keys", () => {
617
+ const keys = [MOBILE, TABLET, DESKTOP].map((ua) => layoutLoaderCacheKey("X", req(ua)));
618
+ expect(new Set(keys).size).toBe(3);
619
+ const bare = layoutLoaderCacheKey("X");
620
+ expect(bare).not.toContain("undefined");
621
+ // A request with no UA header must land on the same bucket as no request at
622
+ // all, so a health check does not fragment the desktop entry.
623
+ expect(bare).toBe(layoutLoaderCacheKey("X", new Request("https://store.com/p")));
624
+ });
625
+ });
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
13
13
  import { getCacheProfile } from "../sdk/cacheHeaders";
14
+ import { detectDevice } from "../sdk/detectDevice";
14
15
  import { djb2 } from "../sdk/djb2";
15
16
  import { withInflightTimeout } from "../sdk/inflightTimeout";
16
17
  import { withTracing } from "../sdk/observability";
@@ -212,10 +213,15 @@ export function registerSectionLoader(sectionKey: string, loader: SectionLoaderF
212
213
  *
213
214
  * Dev-only diagnostic: when a request-dependent loader (one built from
214
215
  * `withDevice`/`withMobile`/`withSearchParam`, possibly through `compose`)
215
- * is registered for a section that's also in `layoutSections`, the layout
216
- * cache will serve the first visitor's variant to every viewer for
217
- * `LAYOUT_CACHE_TTL` (5 min). We log a loud warning explaining the
218
- * remediation options. See #206.
216
+ * is registered for a section that's also in `layoutSections`, the layout cache
217
+ * may serve the first visitor's variant to every viewer for
218
+ * `LAYOUT_CACHE_TTL` (5 min). See #206.
219
+ *
220
+ * DEVICE is now segmented in the key ({@link layoutLoaderCacheKey}), so
221
+ * `withDevice`/`withMobile` are safe. The warning stays because
222
+ * `__requestDependent` is a single boolean and does not say WHICH signal the
223
+ * loader reads — a `withSearchParam` layout loader still contaminates, and we
224
+ * would rather warn on a safe case than stay silent on an unsafe one.
219
225
  */
220
226
  export function registerSectionLoaders(loaders: Record<string, SectionLoaderFn>): void {
221
227
  for (const [key, loader] of Object.entries(loaders)) {
@@ -228,9 +234,11 @@ export function registerSectionLoaders(loaders: Record<string, SectionLoaderFn>)
228
234
  if (requestDependent && layoutSections.has(key)) {
229
235
  console.warn(
230
236
  `[SectionLoaders] "${key}" is registered as a layout section ` +
231
- `(cached for 5min by component path) but its loader is request-` +
232
- `dependent (withDevice/withMobile/withSearchParam). The first ` +
233
- `visitor's variant will be served to all users for 5min. Fix: ` +
237
+ `(cached for 5min by component path + device) but its loader is ` +
238
+ `request-dependent (withDevice/withMobile/withSearchParam). ` +
239
+ `withDevice/withMobile are safe — device is in the key. If it ` +
240
+ `reads a search param, cookie or geo, the first visitor's variant ` +
241
+ `will be served to all users for 5min. Fix: ` +
234
242
  `(1) remove "export const layout = true" from the section, ` +
235
243
  `(2) call unregisterLayoutSections(["${key}"]) in setup.ts ` +
236
244
  `after applySectionConventions, or (3) move the request-` +
@@ -267,10 +275,11 @@ const layoutInflight = new Map<string, Promise<ResolvedSection>>();
267
275
  * Layout sections (Header, Footer, etc.) are cached server-side
268
276
  * for LAYOUT_CACHE_TTL to avoid redundant enrichment on every navigation.
269
277
  *
270
- * The cache key is the component path only — it does NOT include UA,
271
- * cookies, or geo. Sections whose loader depends on those signals must
272
- * not be layout-cached: see {@link unregisterLayoutSections} to opt a
273
- * section out of the auto-discovery done by `applySectionConventions`.
278
+ * The cache key is the component path plus the DEVICE (see
279
+ * {@link layoutLoaderCacheKey}) — it does NOT include cookies, geo or search
280
+ * params. A section whose loader depends on THOSE must not be layout-cached:
281
+ * see {@link unregisterLayoutSections} to opt a section out of the
282
+ * auto-discovery done by `applySectionConventions`.
274
283
  */
275
284
  export function registerLayoutSections(keys: string[]): void {
276
285
  for (const key of keys) {
@@ -299,18 +308,46 @@ export function isLayoutSection(key: string): boolean {
299
308
  return layoutSections.has(key);
300
309
  }
301
310
 
302
- function getCachedLayout(component: string): ResolvedSection | null {
303
- const entry = layoutCache.get(component);
311
+ /**
312
+ * Cache key for a layout section's LOADER OUTPUT, segmented by device.
313
+ *
314
+ * This is the cache that actually carried the device leak, and it is a
315
+ * different one from `resolvedLayoutCache` in `cms/resolve.ts` — that one holds
316
+ * the CMS prop resolution, which never sees `isMobile`. `isMobile` is produced
317
+ * HERE, by the section's own loader (`ctx.device`, or a loader composed with
318
+ * `withDevice`/`withMobile`), and this cache stored it under the component path
319
+ * alone. Measured on a real store's PDP with Header/Footer layout-cached: the
320
+ * first request decided the variant for everyone — desktop first and a mobile
321
+ * visitor got `h-[90px]`; mobile first and a desktop visitor got
322
+ * `id="header-mobile-menu"`.
323
+ *
324
+ * Device is the right default axis because it is the one signal the framework
325
+ * itself injects into every section loader, so a layout section can depend on it
326
+ * without the site opting into anything. A layout whose output does not vary by
327
+ * device just gets up to 3 identical entries.
328
+ *
329
+ * Still NOT covered, and still the reason the `registerSectionLoaders` warning
330
+ * below exists: a layout loader that varies on a search param, a cookie or geo.
331
+ * Those are site-specific; such a section belongs outside `layoutSections`.
332
+ *
333
+ * Exported for unit testing.
334
+ */
335
+ export function layoutLoaderCacheKey(component: string, request?: Request): string {
336
+ return `${component}::${detectDevice(request?.headers?.get?.("user-agent") ?? "")}`;
337
+ }
338
+
339
+ function getCachedLayout(cacheKey: string): ResolvedSection | null {
340
+ const entry = layoutCache.get(cacheKey);
304
341
  if (!entry) return null;
305
342
  if (Date.now() > entry.expiresAt) {
306
- layoutCache.delete(component);
343
+ layoutCache.delete(cacheKey);
307
344
  return null;
308
345
  }
309
346
  return entry.section;
310
347
  }
311
348
 
312
- function setCachedLayout(component: string, section: ResolvedSection): void {
313
- layoutCache.set(component, {
349
+ function setCachedLayout(cacheKey: string, section: ResolvedSection): void {
350
+ layoutCache.set(cacheKey, {
314
351
  section,
315
352
  expiresAt: Date.now() + LAYOUT_CACHE_TTL,
316
353
  });
@@ -324,7 +361,7 @@ function resolveLayoutSection(
324
361
  loader: SectionLoaderFn,
325
362
  request: Request,
326
363
  ): Promise<ResolvedSection> {
327
- const key = section.component;
364
+ const key = layoutLoaderCacheKey(section.component, request);
328
365
  const { index } = section;
329
366
 
330
367
  // Re-apply the caller's page-specific index onto a fresh object so the