@decocms/blocks 7.61.0 → 7.62.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.61.0",
3
+ "version": "7.62.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
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,
@@ -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
+ });
@@ -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
- // "resolved" short-circuit
873
- if (resolveType === "resolved") return obj.data ?? null;
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
  // ---------------------------------------------------------------------------