@decocms/blocks 7.60.0 → 7.61.0
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 +52 -0
- package/src/cms/resolve.ts +42 -12
package/package.json
CHANGED
package/src/cms/resolve.test.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { getSection } from "./registry";
|
|
|
26
26
|
import type { AsyncRenderingConfig, DeferredSection, MatcherContext } from "./resolve";
|
|
27
27
|
import {
|
|
28
28
|
clearCommerceLoaders,
|
|
29
|
+
layoutCacheKey,
|
|
29
30
|
DEFAULT_FOLD_THRESHOLD,
|
|
30
31
|
extractSeoFromProps,
|
|
31
32
|
getAsyncRenderingConfig,
|
|
@@ -1170,3 +1171,54 @@ describe("hidden array items (never matcher)", () => {
|
|
|
1170
1171
|
expect(result.items).toEqual([null, { label: "kept" }]);
|
|
1171
1172
|
});
|
|
1172
1173
|
});
|
|
1174
|
+
|
|
1175
|
+
// ---------------------------------------------------------------------------
|
|
1176
|
+
// resolved-layout cache — device is part of the key
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
//
|
|
1179
|
+
// The key used to be the component path alone, so the first visitor's variant
|
|
1180
|
+
// was served to everyone for the whole TTL. A real store had to leave Header
|
|
1181
|
+
// and Footer OUT of registerLayoutSections because of it, and then re-resolved
|
|
1182
|
+
// both on every page and every navigation. These cases pin the axis; without
|
|
1183
|
+
// them a refactor could silently collapse the key again and the symptom (a
|
|
1184
|
+
// mobile header on desktop) only shows up in production, minutes later.
|
|
1185
|
+
|
|
1186
|
+
describe("layoutCacheKey — the resolved-layout cache is segmented by device", () => {
|
|
1187
|
+
const MOBILE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile Safari";
|
|
1188
|
+
const TABLET_UA = "Mozilla/5.0 (iPad; CPU OS 17_0) Safari/605";
|
|
1189
|
+
const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120 Safari/537.36";
|
|
1190
|
+
const KEY = "site/sections/Header.tsx";
|
|
1191
|
+
|
|
1192
|
+
// The key used to be the component path alone. A site whose Header takes a
|
|
1193
|
+
// per-request device prop therefore served the first visitor's variant to
|
|
1194
|
+
// everyone for the whole 5-minute TTL — a mobile header on desktop. The only
|
|
1195
|
+
// escape was leaving Header/Footer out of `registerLayoutSections`, which is
|
|
1196
|
+
// what a real store did, and it then re-resolved both on every page and every
|
|
1197
|
+
// navigation.
|
|
1198
|
+
|
|
1199
|
+
it("mobile, tablet and desktop are three distinct keys", () => {
|
|
1200
|
+
const keys = [MOBILE_UA, TABLET_UA, DESKTOP_UA].map((ua) => layoutCacheKey(KEY, { userAgent: ua }));
|
|
1201
|
+
expect(new Set(keys).size).toBe(3);
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it("the same device yields the same key — the cache still works", () => {
|
|
1205
|
+
expect(layoutCacheKey(KEY, { userAgent: DESKTOP_UA })).toBe(
|
|
1206
|
+
layoutCacheKey(KEY, { userAgent: DESKTOP_UA }),
|
|
1207
|
+
);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("different components never share a key on the same device", () => {
|
|
1211
|
+
expect(layoutCacheKey("site/sections/Header.tsx", { userAgent: MOBILE_UA })).not.toBe(
|
|
1212
|
+
layoutCacheKey("site/sections/Footer.tsx", { userAgent: MOBILE_UA }),
|
|
1213
|
+
);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("a missing userAgent still produces a key (no crash, no undefined axis)", () => {
|
|
1217
|
+
const noCtx = layoutCacheKey(KEY);
|
|
1218
|
+
expect(noCtx).toContain(KEY);
|
|
1219
|
+
expect(noCtx).not.toContain("undefined");
|
|
1220
|
+
// An absent UA must land on the SAME bucket as a request whose UA is empty,
|
|
1221
|
+
// so a health check and a real desktop hit do not fragment the cache twice.
|
|
1222
|
+
expect(noCtx).toBe(layoutCacheKey(KEY, { userAgent: "" }));
|
|
1223
|
+
});
|
|
1224
|
+
});
|
package/src/cms/resolve.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
registerActionSchemas,
|
|
15
15
|
registerLoaderSchemas,
|
|
16
16
|
} from "./schema";
|
|
17
|
+
import { detectDevice } from "../sdk/detectDevice";
|
|
17
18
|
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
18
19
|
|
|
19
20
|
// globalThis-backed: share state across Vite server function split modules
|
|
@@ -1105,6 +1106,33 @@ interface ResolvedSectionsCache {
|
|
|
1105
1106
|
const resolvedLayoutCache = new Map<string, ResolvedSectionsCache>();
|
|
1106
1107
|
const resolvedLayoutInflight = new Map<string, Promise<ResolvedSection[]>>();
|
|
1107
1108
|
|
|
1109
|
+
/**
|
|
1110
|
+
* Cache key for a resolved layout block, segmented by DEVICE.
|
|
1111
|
+
*
|
|
1112
|
+
* The key used to be the component path alone, and that made the cache unusable
|
|
1113
|
+
* for any site whose Header/Footer takes a per-request device prop: the first
|
|
1114
|
+
* visitor's variant was served to everyone for the whole TTL (mobile header on
|
|
1115
|
+
* desktop and vice-versa). The only escape was to leave those sections out of
|
|
1116
|
+
* `registerLayoutSections` entirely — which is what a real store did, and it
|
|
1117
|
+
* then re-resolved Header and Footer on every single page and navigation.
|
|
1118
|
+
*
|
|
1119
|
+
* Device is the right default axis because it is the one thing the framework
|
|
1120
|
+
* itself injects into every section (`sectionLoaderContext` → `detectDevice`),
|
|
1121
|
+
* so a layout section can depend on it WITHOUT the site opting into anything.
|
|
1122
|
+
* A layout that does not vary by device just gets up to 3 identical entries —
|
|
1123
|
+
* a bounded, negligible cost against silently serving the wrong markup.
|
|
1124
|
+
*
|
|
1125
|
+
* Anything else a layout varies on (region, sales channel, locale) is
|
|
1126
|
+
* site-specific and NOT covered here: those sections should stay out of
|
|
1127
|
+
* `registerLayoutSections` until there is a demonstrated need for a `vary`
|
|
1128
|
+
* hook. Adding axes nobody asked for only shrinks the hit rate.
|
|
1129
|
+
*
|
|
1130
|
+
* Exported for unit testing.
|
|
1131
|
+
*/
|
|
1132
|
+
export function layoutCacheKey(blockKey: string, matcherCtx?: MatcherContext): string {
|
|
1133
|
+
return `${blockKey}::${detectDevice(matcherCtx?.userAgent ?? "")}`;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1108
1136
|
function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
|
|
1109
1137
|
const entry = resolvedLayoutCache.get(blockKey);
|
|
1110
1138
|
if (!entry) return null;
|
|
@@ -2127,20 +2155,21 @@ async function resolveDecoPageImpl(
|
|
|
2127
2155
|
const layoutKey = isRawSectionLayout(section);
|
|
2128
2156
|
|
|
2129
2157
|
if (layoutKey) {
|
|
2130
|
-
const
|
|
2158
|
+
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2159
|
+
const cached = getCachedResolvedLayout(cacheKey);
|
|
2131
2160
|
if (cached) return cached;
|
|
2132
2161
|
|
|
2133
|
-
const inflight = resolvedLayoutInflight.get(
|
|
2162
|
+
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
2134
2163
|
if (inflight) return inflight;
|
|
2135
2164
|
|
|
2136
2165
|
const p = withInflightTimeout(
|
|
2137
2166
|
resolveRawSection(section, rctx).then((results) => {
|
|
2138
|
-
setCachedResolvedLayout(
|
|
2167
|
+
setCachedResolvedLayout(cacheKey, results);
|
|
2139
2168
|
return results;
|
|
2140
2169
|
}),
|
|
2141
|
-
`resolvedLayout ${
|
|
2142
|
-
).finally(() => resolvedLayoutInflight.delete(
|
|
2143
|
-
resolvedLayoutInflight.set(
|
|
2170
|
+
`resolvedLayout ${cacheKey}`,
|
|
2171
|
+
).finally(() => resolvedLayoutInflight.delete(cacheKey));
|
|
2172
|
+
resolvedLayoutInflight.set(cacheKey, p);
|
|
2144
2173
|
return p;
|
|
2145
2174
|
}
|
|
2146
2175
|
|
|
@@ -2224,20 +2253,21 @@ export async function resolvePageSections(
|
|
|
2224
2253
|
const layoutKey = isRawSectionLayout(section);
|
|
2225
2254
|
|
|
2226
2255
|
if (layoutKey) {
|
|
2227
|
-
const
|
|
2256
|
+
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2257
|
+
const cached = getCachedResolvedLayout(cacheKey);
|
|
2228
2258
|
if (cached) return cached;
|
|
2229
2259
|
|
|
2230
|
-
const inflight = resolvedLayoutInflight.get(
|
|
2260
|
+
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
2231
2261
|
if (inflight) return inflight;
|
|
2232
2262
|
|
|
2233
2263
|
const p = withInflightTimeout(
|
|
2234
2264
|
resolveRawSection(section, rctx).then((results) => {
|
|
2235
|
-
setCachedResolvedLayout(
|
|
2265
|
+
setCachedResolvedLayout(cacheKey, results);
|
|
2236
2266
|
return results;
|
|
2237
2267
|
}),
|
|
2238
|
-
`resolvedLayout ${
|
|
2239
|
-
).finally(() => resolvedLayoutInflight.delete(
|
|
2240
|
-
resolvedLayoutInflight.set(
|
|
2268
|
+
`resolvedLayout ${cacheKey}`,
|
|
2269
|
+
).finally(() => resolvedLayoutInflight.delete(cacheKey));
|
|
2270
|
+
resolvedLayoutInflight.set(cacheKey, p);
|
|
2241
2271
|
return p;
|
|
2242
2272
|
}
|
|
2243
2273
|
|