@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.60.0",
3
+ "version": "7.61.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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
+ });
@@ -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 cached = getCachedResolvedLayout(layoutKey);
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(layoutKey);
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(layoutKey, results);
2167
+ setCachedResolvedLayout(cacheKey, results);
2139
2168
  return results;
2140
2169
  }),
2141
- `resolvedLayout ${layoutKey}`,
2142
- ).finally(() => resolvedLayoutInflight.delete(layoutKey));
2143
- resolvedLayoutInflight.set(layoutKey, p);
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 cached = getCachedResolvedLayout(layoutKey);
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(layoutKey);
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(layoutKey, results);
2265
+ setCachedResolvedLayout(cacheKey, results);
2236
2266
  return results;
2237
2267
  }),
2238
- `resolvedLayout ${layoutKey}`,
2239
- ).finally(() => resolvedLayoutInflight.delete(layoutKey));
2240
- resolvedLayoutInflight.set(layoutKey, p);
2268
+ `resolvedLayout ${cacheKey}`,
2269
+ ).finally(() => resolvedLayoutInflight.delete(cacheKey));
2270
+ resolvedLayoutInflight.set(cacheKey, p);
2241
2271
  return p;
2242
2272
  }
2243
2273