@decocms/blocks 7.23.0 → 7.25.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.23.0",
3
+ "version": "7.25.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/src/cms/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
2
+ export { applySectionConventions } from "./applySectionConventions";
1
3
  export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
2
4
  export {
3
5
  BUILD_HASH_ENV,
@@ -50,10 +52,11 @@ export type {
50
52
  } from "./resolve";
51
53
  export {
52
54
  addSkipResolveType,
55
+ cacheDeferredRawProps,
56
+ clearCommerceLoaders,
53
57
  evaluateMatcher,
54
58
  extractSeoFromProps,
55
59
  extractSeoFromSections,
56
- cacheDeferredRawProps,
57
60
  getAsyncRenderingConfig,
58
61
  getDeferredRawProps,
59
62
  isBot,
@@ -63,45 +66,24 @@ export {
63
66
  registerBotPattern,
64
67
  registerCommerceLoader,
65
68
  registerCommerceLoaders,
66
- unregisterCommerceLoader,
67
- clearCommerceLoaders,
68
- registerMatcher,
69
69
  registerEagerSections,
70
+ registerMatcher,
70
71
  registerNeverDeferSections,
71
72
  registerSeoSections,
72
73
  resolveDecoPage,
73
- resolvePageSections,
74
- resolvePageSeoBlock,
75
74
  resolveDeferredSection,
76
75
  resolveDeferredSectionFull,
76
+ resolvePageSections,
77
+ resolvePageSeoBlock,
77
78
  resolveValue,
78
79
  setAsyncRenderingConfig,
79
80
  setDanglingReferenceHandler,
80
81
  setResolveErrorHandler,
82
+ unregisterCommerceLoader,
81
83
  WELL_KNOWN_TYPES,
82
84
  } from "./resolve";
83
- export type { SectionLoaderFn } from "./sectionLoaders";
84
85
  export type { SectionLoaderContext } from "./sectionLoaderContext";
85
86
  export { buildSectionLoaderContext } from "./sectionLoaderContext";
86
- export {
87
- isLayoutSection,
88
- registerCacheableSections,
89
- registerLayoutSections,
90
- registerSectionLoader,
91
- registerSectionLoaders,
92
- runSectionLoaders,
93
- runSingleSectionLoader,
94
- unregisterLayoutSections,
95
- } from "./sectionLoaders";
96
- export {
97
- compose,
98
- withDevice,
99
- withMobile,
100
- withSearchParam,
101
- withSectionLoader,
102
- } from "./sectionMixins";
103
- export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
104
- export { applySectionConventions } from "./applySectionConventions";
105
87
  export type {
106
88
  ActionConfig,
107
89
  AppSchemas,
@@ -123,3 +105,25 @@ export {
123
105
  registerMatcherSchema,
124
106
  registerMatcherSchemas,
125
107
  } from "./schema";
108
+ export type { SectionLoaderFn } from "./sectionLoaders";
109
+ export {
110
+ getDegradedSections,
111
+ isCriticalSection,
112
+ isLayoutSection,
113
+ markSectionDegraded,
114
+ registerCacheableSections,
115
+ registerLayoutSections,
116
+ registerNonCriticalSections,
117
+ registerSectionLoader,
118
+ registerSectionLoaders,
119
+ runSectionLoaders,
120
+ runSingleSectionLoader,
121
+ unregisterLayoutSections,
122
+ } from "./sectionLoaders";
123
+ export {
124
+ compose,
125
+ withDevice,
126
+ withMobile,
127
+ withSearchParam,
128
+ withSectionLoader,
129
+ } from "./sectionMixins";
@@ -13,7 +13,7 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
13
13
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
14
14
  import { findPageByPath, loadBlocks } from "./loader";
15
15
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
16
- import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
16
+ import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
17
17
 
18
18
  // globalThis-backed: share state across Vite server function split modules
19
19
  const G = globalThis as any;
@@ -911,6 +911,11 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
911
911
  return await commerceLoader(resolvedProps);
912
912
  } catch (error) {
913
913
  onResolveError(error, resolveType, "Commerce loader");
914
+ // Commerce loaders (VTEX product/PLP/PDP data) are the primary source of
915
+ // page-body data — this is exactly the layer that fails during a VTEX
916
+ // outage. Flag the page degraded so the edge won't cache the empty
917
+ // result as a healthy 200 (see X-Deco-Degraded in cmsRoute/workerEntry).
918
+ markSectionDegraded(resolveType);
914
919
  return null;
915
920
  }
916
921
  }
@@ -1,10 +1,13 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { configureTracer, type Span } from "../sdk/observability";
3
+ import { RequestContext } from "../sdk/requestContext";
3
4
  import type { ResolvedSection } from "./resolve";
4
5
  import {
6
+ getDegradedSections,
5
7
  isLayoutSection,
6
8
  registerCacheableSections,
7
9
  registerLayoutSections,
10
+ registerNonCriticalSections,
8
11
  registerSectionLoader,
9
12
  registerSectionLoaders,
10
13
  runSectionLoaders,
@@ -19,6 +22,7 @@ beforeEach(() => {
19
22
  G.__deco.sectionLoaderRegistry.clear();
20
23
  G.__deco.layoutSections.clear();
21
24
  G.__deco.cacheableSections.clear();
25
+ G.__deco.nonCriticalSections.clear();
22
26
  });
23
27
 
24
28
  const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
@@ -28,6 +32,78 @@ const makeSection = (component: string, props: Record<string, unknown> = {}): Re
28
32
  index: 0,
29
33
  });
30
34
 
35
+ describe("section degradation tracking (anti-cache-poisoning)", () => {
36
+ const failing = async () => {
37
+ throw new Error("VTEX down");
38
+ };
39
+
40
+ it("marks a critical section as degraded when its loader throws", async () => {
41
+ registerSectionLoader("site/sections/ProductShelf.tsx", failing);
42
+ const section = makeSection("site/sections/ProductShelf.tsx");
43
+ const request = new Request("https://store.com/p");
44
+
45
+ const degraded = await RequestContext.run(request, async () => {
46
+ // Loader throws → section degrades to raw props (does not reject).
47
+ const result = await runSingleSectionLoader(section, request);
48
+ expect(result.component).toBe("site/sections/ProductShelf.tsx");
49
+ return getDegradedSections();
50
+ });
51
+
52
+ expect(degraded).toContain("site/sections/ProductShelf.tsx");
53
+ });
54
+
55
+ it("does NOT mark a non-critical section as degraded", async () => {
56
+ registerSectionLoader("site/sections/Decorative.tsx", failing);
57
+ registerNonCriticalSections(["site/sections/Decorative.tsx"]);
58
+ const section = makeSection("site/sections/Decorative.tsx");
59
+ const request = new Request("https://store.com/p");
60
+
61
+ const degraded = await RequestContext.run(request, async () => {
62
+ await runSingleSectionLoader(section, request);
63
+ return getDegradedSections();
64
+ });
65
+
66
+ expect(degraded).toEqual([]);
67
+ });
68
+
69
+ it("does NOT mark a failing LAYOUT section as degraded (avoids site-wide poisoning)", async () => {
70
+ registerSectionLoader("site/sections/Header.tsx", failing);
71
+ registerLayoutSections(["site/sections/Header.tsx"]);
72
+ const section = makeSection("site/sections/Header.tsx");
73
+ const request = new Request("https://store.com/p");
74
+
75
+ const degraded = await RequestContext.run(request, async () => {
76
+ await runSingleSectionLoader(section, request);
77
+ return getDegradedSections();
78
+ });
79
+
80
+ // A flaky Header/Footer must not flip every page to X-Deco-Degraded.
81
+ expect(degraded).toEqual([]);
82
+ });
83
+
84
+ it("does not flag a page whose loaders all succeed", async () => {
85
+ registerSectionLoader("site/sections/Ok.tsx", async (p: Record<string, unknown>) => p);
86
+ const section = makeSection("site/sections/Ok.tsx");
87
+ const request = new Request("https://store.com/p");
88
+
89
+ const degraded = await RequestContext.run(request, async () => {
90
+ await runSingleSectionLoader(section, request);
91
+ return getDegradedSections();
92
+ });
93
+
94
+ expect(degraded).toEqual([]);
95
+ });
96
+
97
+ it("is a no-op outside a request scope (does not throw)", async () => {
98
+ registerSectionLoader("site/sections/NoScope.tsx", failing);
99
+ const section = makeSection("site/sections/NoScope.tsx");
100
+ const request = new Request("https://store.com/p");
101
+ // No RequestContext.run wrapper — must not throw.
102
+ await expect(runSingleSectionLoader(section, request)).resolves.toBeTruthy();
103
+ expect(getDegradedSections()).toEqual([]);
104
+ });
105
+ });
106
+
31
107
  describe("runSingleSectionLoader — page context injection", () => {
32
108
  it("injects __pageUrl and __pagePath into loader props", async () => {
33
109
  // Two-arg signature so loader.mock.calls[0] types as [props, req].
@@ -9,6 +9,7 @@
9
9
  * inside the TanStack Start server function.
10
10
  */
11
11
 
12
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
12
13
  import { getCacheProfile } from "../sdk/cacheHeaders";
13
14
  import { djb2 } from "../sdk/djb2";
14
15
  import { withInflightTimeout } from "../sdk/inflightTimeout";
@@ -33,9 +34,56 @@ if (!G.__deco) G.__deco = {};
33
34
  if (!G.__deco.sectionLoaderRegistry) G.__deco.sectionLoaderRegistry = new Map();
34
35
  if (!G.__deco.layoutSections) G.__deco.layoutSections = new Set();
35
36
  if (!G.__deco.cacheableSections) G.__deco.cacheableSections = new Map();
37
+ if (!G.__deco.nonCriticalSections) G.__deco.nonCriticalSections = new Set();
36
38
 
37
39
  const loaderRegistry: Map<string, SectionLoaderFn> = G.__deco.sectionLoaderRegistry;
38
40
 
41
+ // ---------------------------------------------------------------------------
42
+ // Degradation tracking (anti-cache-poisoning)
43
+ //
44
+ // When a section loader throws, the section renders with raw (un-enriched)
45
+ // props — e.g. a product shelf with no products during a VTEX outage. Today
46
+ // that degraded page is emitted as a healthy 200 and the edge caches it for the
47
+ // full retention window, so stale-if-error never fires and users see an empty
48
+ // page for hours. We record each CRITICAL section degradation on a
49
+ // request-scoped collector; the CMS route reads it and emits `X-Deco-Degraded`
50
+ // so the worker refuses to cache the broken page and serves stale instead.
51
+ //
52
+ // Every section is critical by default (a registered loader failing means
53
+ // intended data is missing). Decorative sections whose empty state is harmless
54
+ // can opt out via `registerNonCriticalSections`.
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const DEGRADED_BAG_KEY = "deco:degraded-sections";
58
+ const nonCriticalSections: Set<string> = G.__deco.nonCriticalSections;
59
+
60
+ /** Mark section keys whose loader failure should NOT make the page uncacheable. */
61
+ export function registerNonCriticalSections(keys: string[]): void {
62
+ for (const k of keys) nonCriticalSections.add(k);
63
+ }
64
+
65
+ /** A section is critical (its failure degrades the page) unless opted out. */
66
+ export function isCriticalSection(key: string): boolean {
67
+ return !nonCriticalSections.has(key);
68
+ }
69
+
70
+ /**
71
+ * Record that a critical section rendered with raw props because its loader
72
+ * threw. No-op outside a request scope or for non-critical sections.
73
+ */
74
+ export function markSectionDegraded(component: string): void {
75
+ if (!isCriticalSection(component)) return;
76
+ const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY) ?? new Set<string>();
77
+ set.add(component);
78
+ RequestContext.setBag(DEGRADED_BAG_KEY, set);
79
+ }
80
+
81
+ /** Component keys of critical sections that degraded during this request. */
82
+ export function getDegradedSections(): string[] {
83
+ const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY);
84
+ return set ? [...set] : [];
85
+ }
86
+
39
87
  // ---------------------------------------------------------------------------
40
88
  // Cacheable section loaders — SWR cache for section loader results
41
89
  // ---------------------------------------------------------------------------
@@ -432,6 +480,11 @@ async function runSingleSectionLoaderImpl(
432
480
  result = await resolveLayoutSection(section, wrapped, request);
433
481
  } catch (error) {
434
482
  console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
483
+ // Deliberately NOT marked degraded: layout sections (Header/Footer/Theme)
484
+ // render on every page, so a flaky layout loader would flip the whole
485
+ // site to X-Deco-Degraded and defeat edge caching everywhere. A failed
486
+ // layout renders raw chrome, which is acceptable — page-body data
487
+ // integrity (product shelves/PLP/PDP) is what the degraded signal guards.
435
488
  result = section;
436
489
  }
437
490
  } else {
@@ -441,6 +494,7 @@ async function runSingleSectionLoaderImpl(
441
494
  result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
442
495
  } catch (error) {
443
496
  console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
497
+ markSectionDegraded(section.component);
444
498
  result = section;
445
499
  }
446
500
  } else {
@@ -449,6 +503,7 @@ async function runSingleSectionLoaderImpl(
449
503
  result = { ...section, props: enrichedProps };
450
504
  } catch (error) {
451
505
  console.error(`[SectionLoader] Error in "${section.component}":`, error);
506
+ markSectionDegraded(section.component);
452
507
  result = section;
453
508
  }
454
509
  }