@decocms/blocks 7.61.0 → 7.62.1
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/index.ts +4 -0
- package/src/cms/resolve.test.ts +109 -0
- package/src/cms/resolve.ts +91 -2
- package/src/cms/sectionLoaders.test.ts +92 -0
- package/src/cms/sectionLoaders.ts +54 -17
package/package.json
CHANGED
package/src/cms/index.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type {
|
|
|
85
85
|
DanglingReferenceHandler,
|
|
86
86
|
DecoPageResult,
|
|
87
87
|
DeferredSection,
|
|
88
|
+
Deferred,
|
|
88
89
|
MatcherContext,
|
|
89
90
|
PageSeo,
|
|
90
91
|
ResolvedSection,
|
|
@@ -97,10 +98,12 @@ export {
|
|
|
97
98
|
clearCommerceLoaders,
|
|
98
99
|
evaluateMatcher,
|
|
99
100
|
extractSeoFromProps,
|
|
101
|
+
asResolved,
|
|
100
102
|
extractSeoFromSections,
|
|
101
103
|
getAsyncRenderingConfig,
|
|
102
104
|
getDeferredRawProps,
|
|
103
105
|
isBot,
|
|
106
|
+
isDeferred,
|
|
104
107
|
isEagerRequest,
|
|
105
108
|
isSeoSection,
|
|
106
109
|
onBeforeResolve,
|
|
@@ -113,6 +116,7 @@ export {
|
|
|
113
116
|
registerNeverDeferSections,
|
|
114
117
|
registerSeoSections,
|
|
115
118
|
resolveDecoPage,
|
|
119
|
+
resolveDeferred,
|
|
116
120
|
resolveDeferredSection,
|
|
117
121
|
resolveDeferredSectionFull,
|
|
118
122
|
resolvePageSections,
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -25,15 +25,18 @@ import { findPageByPath } from "./loader";
|
|
|
25
25
|
import { getSection } from "./registry";
|
|
26
26
|
import type { AsyncRenderingConfig, DeferredSection, MatcherContext } from "./resolve";
|
|
27
27
|
import {
|
|
28
|
+
asResolved,
|
|
28
29
|
clearCommerceLoaders,
|
|
29
30
|
layoutCacheKey,
|
|
30
31
|
DEFAULT_FOLD_THRESHOLD,
|
|
31
32
|
extractSeoFromProps,
|
|
32
33
|
getAsyncRenderingConfig,
|
|
34
|
+
isDeferred,
|
|
33
35
|
isEagerRequest,
|
|
34
36
|
reExtractRawProps,
|
|
35
37
|
registerCommerceLoader,
|
|
36
38
|
registerMatcher,
|
|
39
|
+
resolveDeferred,
|
|
37
40
|
registerEagerSections,
|
|
38
41
|
registerAlwaysDeferSections,
|
|
39
42
|
registerNeverDeferSections,
|
|
@@ -1222,3 +1225,109 @@ describe("layoutCacheKey — the resolved-layout cache is segmented by device",
|
|
|
1222
1225
|
expect(noCtx).toBe(layoutCacheKey(KEY, { userAgent: "" }));
|
|
1223
1226
|
});
|
|
1224
1227
|
});
|
|
1228
|
+
|
|
1229
|
+
// ---------------------------------------------------------------------------
|
|
1230
|
+
// asResolved / deferred props
|
|
1231
|
+
// ---------------------------------------------------------------------------
|
|
1232
|
+
//
|
|
1233
|
+
// The CMS resolver is recursive and resolves EVERY `__resolveType` it finds in a
|
|
1234
|
+
// section's props — including branches the section will not render. Measured on
|
|
1235
|
+
// a real store, a PDP's `notFoundSections` (the 404 fallback) carried a shelf
|
|
1236
|
+
// whose `products` was a full-text search: 1703ms of the eager critical path of
|
|
1237
|
+
// every product page, result thrown away. `asResolved(v, true)` is the opt-out.
|
|
1238
|
+
|
|
1239
|
+
describe("asResolved — deferred props", () => {
|
|
1240
|
+
const LOADER = "test/loaders/expensive.ts";
|
|
1241
|
+
let calls = 0;
|
|
1242
|
+
|
|
1243
|
+
beforeEach(() => {
|
|
1244
|
+
calls = 0;
|
|
1245
|
+
clearCommerceLoaders();
|
|
1246
|
+
registerCommerceLoader(LOADER, async () => {
|
|
1247
|
+
calls += 1;
|
|
1248
|
+
return { products: ["p1", "p2"] };
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
afterEach(() => clearCommerceLoaders());
|
|
1253
|
+
|
|
1254
|
+
const expensive = { __resolveType: LOADER };
|
|
1255
|
+
|
|
1256
|
+
it("without asResolved the loader runs during the page pass", async () => {
|
|
1257
|
+
const out = (await resolveValue({ fallback: expensive })) as any;
|
|
1258
|
+
expect(calls).toBe(1);
|
|
1259
|
+
expect(out.fallback).toEqual({ products: ["p1", "p2"] });
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("asResolved(v) hands the value back untouched — the loader never runs", async () => {
|
|
1263
|
+
const out = (await resolveValue({ fallback: asResolved(expensive) })) as any;
|
|
1264
|
+
expect(calls).toBe(0);
|
|
1265
|
+
// The raw node comes through, NOT its resolution.
|
|
1266
|
+
expect(out.fallback).toEqual(expensive);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("asResolved(v, true) yields a thunk and does NOT resolve until called", async () => {
|
|
1270
|
+
const out = (await resolveValue({ fallback: asResolved(expensive, true) })) as any;
|
|
1271
|
+
|
|
1272
|
+
expect(calls).toBe(0);
|
|
1273
|
+
expect(isDeferred(out.fallback)).toBe(true);
|
|
1274
|
+
|
|
1275
|
+
expect(await resolveDeferred(out.fallback)).toEqual({ products: ["p1", "p2"] });
|
|
1276
|
+
expect(calls).toBe(1);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("the branch that never calls the thunk pays nothing", async () => {
|
|
1280
|
+
const out = (await resolveValue({
|
|
1281
|
+
shown: expensive,
|
|
1282
|
+
fallback: asResolved(expensive, true),
|
|
1283
|
+
})) as any;
|
|
1284
|
+
// Only the rendered branch resolved. This is the whole point: 1 upstream
|
|
1285
|
+
// call instead of 2 for a page that renders one of the two.
|
|
1286
|
+
expect(calls).toBe(1);
|
|
1287
|
+
expect(out.shown).toEqual({ products: ["p1", "p2"] });
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it("a thunk is dropped by JSON.stringify — the unused branch leaves the payload too", async () => {
|
|
1291
|
+
const out = (await resolveValue({ fallback: asResolved(expensive, true) })) as any;
|
|
1292
|
+
expect(JSON.parse(JSON.stringify(out))).toEqual({});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it("resolveDeferred is tolerant of a plain value (admin preview path)", async () => {
|
|
1296
|
+
// The admin preview does not run `onBeforeResolveProps`, so the prop arrives
|
|
1297
|
+
// already resolved. A section must read the same either way.
|
|
1298
|
+
expect(await resolveDeferred([{ a: 1 }])).toEqual([{ a: 1 }]);
|
|
1299
|
+
expect(await resolveDeferred(undefined)).toBeUndefined();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("the thunk resolves in THIS request's context, not a context-free one", async () => {
|
|
1303
|
+
// The invariant that makes deferring safe: a cookie/UA-gated prop must not
|
|
1304
|
+
// silently resolve against an anonymous request when the section calls it.
|
|
1305
|
+
const PROBE = "test/matchers/deferredProbe.ts";
|
|
1306
|
+
const seen: MatcherContext[] = [];
|
|
1307
|
+
registerMatcher(PROBE, (_rule, ctx) => {
|
|
1308
|
+
seen.push(ctx);
|
|
1309
|
+
return true;
|
|
1310
|
+
});
|
|
1311
|
+
const gated = {
|
|
1312
|
+
__resolveType: WELL_KNOWN_TYPES.MULTIVARIATE,
|
|
1313
|
+
variants: [{ rule: { __resolveType: PROBE }, value: expensive }],
|
|
1314
|
+
};
|
|
1315
|
+
const ctx: MatcherContext = {
|
|
1316
|
+
userAgent: "Mozilla/5.0 (iPhone) Mobile Safari",
|
|
1317
|
+
url: "https://store.com/x/p",
|
|
1318
|
+
cookies: { VtexIdclientAutCookie: "tok" },
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const out = (await resolveValue({ fallback: asResolved(gated, true) }, undefined, ctx)) as any;
|
|
1322
|
+
expect(seen).toHaveLength(0); // nothing evaluated yet
|
|
1323
|
+
|
|
1324
|
+
await resolveDeferred(out.fallback);
|
|
1325
|
+
|
|
1326
|
+
expect(seen).not.toHaveLength(0);
|
|
1327
|
+
for (const c of seen) {
|
|
1328
|
+
expect(c.cookies).toEqual(ctx.cookies);
|
|
1329
|
+
expect(c.userAgent).toBe(ctx.userAgent);
|
|
1330
|
+
expect(c.url).toBe(ctx.url);
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
});
|
package/src/cms/resolve.ts
CHANGED
|
@@ -869,8 +869,38 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
869
869
|
|
|
870
870
|
const childCtx: ResolveContext = { ...rctx, depth: rctx.depth + 1 };
|
|
871
871
|
|
|
872
|
-
//
|
|
873
|
-
|
|
872
|
+
// ── `asResolved` ─────────────────────────────────────────────────────────
|
|
873
|
+
//
|
|
874
|
+
// `{ __resolveType: "resolved", data }` hands `data` back untouched — the
|
|
875
|
+
// caller is telling us it is already resolved.
|
|
876
|
+
//
|
|
877
|
+
// With `deferred: true` it instead becomes a THUNK, and `data` is not walked
|
|
878
|
+
// at all during the page pass. That is the point: the CMS resolver is
|
|
879
|
+
// recursive and resolves every `__resolveType` it finds in a section's props,
|
|
880
|
+
// including branches the section will not render. Measured on a real store, a
|
|
881
|
+
// PDP's `notFoundSections` — the 404 fallback — carried a shelf whose
|
|
882
|
+
// `products` was a full-text search: 1703ms of the EAGER critical path of
|
|
883
|
+
// EVERY product page, with the result thrown away. Same shape applies to tab
|
|
884
|
+
// content, modal content and unmatched A/B branches.
|
|
885
|
+
//
|
|
886
|
+
// The thunk closes over `childCtx`, so resolving it later still sees THIS
|
|
887
|
+
// request: cookies, UA and url reach the matchers, and `rctx.memo` dedupes
|
|
888
|
+
// named-block references the page already resolved. It does NOT dedupe
|
|
889
|
+
// commerce loaders — those are keyed by their own cache layer, not the memo
|
|
890
|
+
// (see the "Named block reference (memoized)" branch), so a deferred prop
|
|
891
|
+
// pointing at the same loader as a rendered one still calls it twice.
|
|
892
|
+
//
|
|
893
|
+
// HAZARD, and it is why this is opt-in per prop: a thunk cannot be
|
|
894
|
+
// serialized. If a section defers a prop and never calls it, the prop reaches
|
|
895
|
+
// the client as `undefined` (JSON.stringify drops functions). Defer only what
|
|
896
|
+
// the section itself resolves, and only on the branch that needs it.
|
|
897
|
+
if (resolveType === "resolved") {
|
|
898
|
+
if (obj.deferred === true) {
|
|
899
|
+
const raw = obj.data;
|
|
900
|
+
return () => internalResolve(raw, childCtx);
|
|
901
|
+
}
|
|
902
|
+
return obj.data ?? null;
|
|
903
|
+
}
|
|
874
904
|
|
|
875
905
|
// Lazy section wrapper — unwrap single inner section
|
|
876
906
|
if (resolveType === WELL_KNOWN_TYPES.LAZY) {
|
|
@@ -1782,6 +1812,65 @@ export async function resolveSectionsList(
|
|
|
1782
1812
|
return [];
|
|
1783
1813
|
}
|
|
1784
1814
|
|
|
1815
|
+
// ---------------------------------------------------------------------------
|
|
1816
|
+
// Deferred props (`asResolved`)
|
|
1817
|
+
// ---------------------------------------------------------------------------
|
|
1818
|
+
|
|
1819
|
+
/** A prop deferred with `asResolved(value, true)`: call it to resolve. */
|
|
1820
|
+
export type Deferred<T> = () => Promise<T>;
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Mark a prop as already-resolved, or defer its resolution to the section.
|
|
1824
|
+
*
|
|
1825
|
+
* Use it from a section's `onBeforeResolveProps`, which runs on the RAW CMS
|
|
1826
|
+
* props before the resolver walks them:
|
|
1827
|
+
*
|
|
1828
|
+
* ```ts
|
|
1829
|
+
* export const onBeforeResolveProps = (props: Props) => ({
|
|
1830
|
+
* ...props,
|
|
1831
|
+
* // The 404 fallback: only the branch that renders it should pay for it.
|
|
1832
|
+
* notFoundSections: asResolved(props.notFoundSections, true),
|
|
1833
|
+
* })
|
|
1834
|
+
*
|
|
1835
|
+
* export const loader = async (props: Props) => {
|
|
1836
|
+
* if (props.page) return { ...props, notFoundSections: [] }
|
|
1837
|
+
* return { ...props, notFoundSections: await resolveDeferred(props.notFoundSections) }
|
|
1838
|
+
* }
|
|
1839
|
+
* ```
|
|
1840
|
+
*
|
|
1841
|
+
* - `asResolved(v)` — hand `v` back untouched; the resolver does not walk it.
|
|
1842
|
+
* - `asResolved(v, true)` — the section receives a {@link Deferred} thunk and
|
|
1843
|
+
* decides whether to pay for it.
|
|
1844
|
+
*
|
|
1845
|
+
* A deferred prop that is never called reaches the client as `undefined`: a
|
|
1846
|
+
* thunk cannot be serialized. That is deliberate — it is what keeps the unused
|
|
1847
|
+
* branch out of the hydration payload too — but it means you must resolve it on
|
|
1848
|
+
* the branch that renders it.
|
|
1849
|
+
*/
|
|
1850
|
+
export function asResolved<T>(value: T, defer?: boolean): T {
|
|
1851
|
+
return {
|
|
1852
|
+
__resolveType: "resolved",
|
|
1853
|
+
data: value,
|
|
1854
|
+
...(defer ? { deferred: true } : {}),
|
|
1855
|
+
} as unknown as T;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** True when a prop came through as a {@link Deferred} thunk. */
|
|
1859
|
+
export function isDeferred<T>(value: unknown): value is Deferred<T> {
|
|
1860
|
+
return typeof value === "function";
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Resolve a possibly-deferred prop.
|
|
1865
|
+
*
|
|
1866
|
+
* Tolerant of a plain value, so a section reads the same whether the prop was
|
|
1867
|
+
* deferred, marked already-resolved, or left alone — and so the admin preview
|
|
1868
|
+
* (which does not run `onBeforeResolveProps`) keeps working.
|
|
1869
|
+
*/
|
|
1870
|
+
export async function resolveDeferred<T>(value: Deferred<T> | T): Promise<T> {
|
|
1871
|
+
return isDeferred<T>(value) ? await value() : (value as T);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1785
1874
|
// ---------------------------------------------------------------------------
|
|
1786
1875
|
// Page-level SEO — extracted from registered SEO sections after resolution
|
|
1787
1876
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|