@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 +1 -1
- package/src/cms/resolve.test.ts +44 -0
- package/src/cms/resolve.ts +25 -10
- package/src/cms/sectionLoaders.test.ts +92 -0
- package/src/cms/sectionLoaders.ts +54 -17
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
|
}
|
|
@@ -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
|
-
*
|
|
217
|
-
* `LAYOUT_CACHE_TTL` (5 min).
|
|
218
|
-
*
|
|
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
|
|
232
|
-
`dependent (withDevice/withMobile/withSearchParam).
|
|
233
|
-
`
|
|
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
|
|
271
|
-
*
|
|
272
|
-
* not be layout-cached:
|
|
273
|
-
* section out of the
|
|
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
|
-
|
|
303
|
-
|
|
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(
|
|
343
|
+
layoutCache.delete(cacheKey);
|
|
307
344
|
return null;
|
|
308
345
|
}
|
|
309
346
|
return entry.section;
|
|
310
347
|
}
|
|
311
348
|
|
|
312
|
-
function setCachedLayout(
|
|
313
|
-
layoutCache.set(
|
|
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
|