@decocms/blocks 7.22.2 → 7.24.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.22.2",
3
+ "version": "7.24.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,43 +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
- export {
85
- isLayoutSection,
86
- registerCacheableSections,
87
- registerLayoutSections,
88
- registerSectionLoader,
89
- registerSectionLoaders,
90
- runSectionLoaders,
91
- runSingleSectionLoader,
92
- unregisterLayoutSections,
93
- } from "./sectionLoaders";
94
- export {
95
- compose,
96
- withDevice,
97
- withMobile,
98
- withSearchParam,
99
- withSectionLoader,
100
- } from "./sectionMixins";
101
- export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
102
- export { applySectionConventions } from "./applySectionConventions";
85
+ export type { SectionLoaderContext } from "./sectionLoaderContext";
86
+ export { buildSectionLoaderContext } from "./sectionLoaderContext";
103
87
  export type {
104
88
  ActionConfig,
105
89
  AppSchemas,
@@ -121,3 +105,25 @@ export {
121
105
  registerMatcherSchema,
122
106
  registerMatcherSchemas,
123
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
  }
@@ -0,0 +1,111 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { RequestContext } from "../sdk/requestContext";
3
+ import { buildSectionLoaderContext } from "./sectionLoaderContext";
4
+
5
+ const MOBILE_UA =
6
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
7
+ const DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
8
+ const TABLET_UA = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15";
9
+
10
+ function reqWithUA(ua: string, url = "https://store.example/path?q=shoes"): Request {
11
+ return new Request(url, { headers: { "user-agent": ua } });
12
+ }
13
+
14
+ describe("buildSectionLoaderContext", () => {
15
+ describe("device", () => {
16
+ it("detects mobile / desktop / tablet from the request User-Agent", () => {
17
+ expect(buildSectionLoaderContext(reqWithUA(MOBILE_UA)).device).toBe("mobile");
18
+ expect(buildSectionLoaderContext(reqWithUA(DESKTOP_UA)).device).toBe("desktop");
19
+ expect(buildSectionLoaderContext(reqWithUA(TABLET_UA)).device).toBe("tablet");
20
+ });
21
+
22
+ it("defaults to desktop when there is no User-Agent", () => {
23
+ expect(buildSectionLoaderContext(new Request("https://store.example/")).device).toBe(
24
+ "desktop",
25
+ );
26
+ });
27
+
28
+ it("never throws for a minimal/mock request without headers", () => {
29
+ const badReq = { url: "not a url" } as unknown as Request;
30
+ expect(() => buildSectionLoaderContext(badReq)).not.toThrow();
31
+ expect(buildSectionLoaderContext(badReq).device).toBe("desktop");
32
+ });
33
+ });
34
+
35
+ describe("app state", () => {
36
+ it("resolves ctx.<appName> to the app's registered state inside a request scope", async () => {
37
+ const req = reqWithUA(DESKTOP_UA);
38
+ await RequestContext.run(req, async () => {
39
+ RequestContext.setBag("app:vtex:state", { config: { account: "acme" } });
40
+ const ctx = buildSectionLoaderContext(req);
41
+ expect((ctx as any).vtex).toEqual({ config: { account: "acme" } });
42
+ expect(ctx.getAppState("vtex")).toEqual({ config: { account: "acme" } });
43
+ });
44
+ });
45
+
46
+ it("returns undefined for an unconfigured app (no fake object)", async () => {
47
+ const req = reqWithUA(DESKTOP_UA);
48
+ await RequestContext.run(req, async () => {
49
+ const ctx = buildSectionLoaderContext(req);
50
+ expect((ctx as any).salesforce).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ it("returns undefined for app state outside a request scope", () => {
55
+ const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA));
56
+ expect((ctx as any).vtex).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe("response.headers", () => {
61
+ it("writes through to RequestContext.responseHeaders inside a scope", async () => {
62
+ const req = reqWithUA(DESKTOP_UA);
63
+ await RequestContext.run(req, async () => {
64
+ const ctx = buildSectionLoaderContext(req);
65
+ ctx.response.headers.set("set-cookie", "a=1");
66
+ expect(RequestContext.responseHeaders.get("set-cookie")).toBe("a=1");
67
+ });
68
+ });
69
+
70
+ it("degrades to an inert Headers outside a scope (does not throw)", () => {
71
+ const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA));
72
+ expect(() => ctx.response.headers.set("x", "y")).not.toThrow();
73
+ });
74
+ });
75
+
76
+ describe("invoke (server-side self-fetch)", () => {
77
+ let fetchMock: ReturnType<typeof vi.fn>;
78
+
79
+ beforeEach(() => {
80
+ fetchMock = vi
81
+ .fn()
82
+ .mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
83
+ vi.stubGlobal("fetch", fetchMock);
84
+ });
85
+
86
+ afterEach(() => {
87
+ vi.unstubAllGlobals();
88
+ });
89
+
90
+ it("POSTs to the request's absolute origin /deco/invoke/<key>", async () => {
91
+ const ctx = buildSectionLoaderContext(reqWithUA(DESKTOP_UA, "https://store.example/pdp"));
92
+ const result = await ctx.invoke.vtex.loaders.product.detailsPageGQL({ slug: "x" });
93
+
94
+ expect(result).toEqual({ ok: true });
95
+ const [url, init] = fetchMock.mock.calls[0];
96
+ expect(url).toBe("https://store.example/deco/invoke/vtex/loaders/product/detailsPageGQL");
97
+ expect(init.method).toBe("POST");
98
+ expect(JSON.parse(init.body)).toEqual({ slug: "x" });
99
+ });
100
+
101
+ it("injects the request AbortSignal when invoked inside a request scope", async () => {
102
+ const req = reqWithUA(DESKTOP_UA, "https://store.example/pdp");
103
+ await RequestContext.run(req, async () => {
104
+ const ctx = buildSectionLoaderContext(req);
105
+ await ctx.invoke.vtex.loaders.x({});
106
+ const [, init] = fetchMock.mock.calls[0];
107
+ expect(init.signal).toBeInstanceOf(AbortSignal);
108
+ });
109
+ });
110
+ });
111
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Section-loader compatibility context (`ctx`) — issue #305.
3
+ *
4
+ * deco.cx (Fresh) section loaders use a 3-arg signature
5
+ * `(props, req, ctx: AppContext)`, where `ctx` exposes device detection,
6
+ * `ctx.invoke.*`, per-app state (`ctx.vtex`, `ctx.salesforce`, …) and
7
+ * `ctx.response.headers`. `@decocms/blocks` invokes loaders with only
8
+ * `(props, req)`, so migrated loaders read `undefined` and throw.
9
+ *
10
+ * Rather than delete `ctx` (which would force rewriting every migrated
11
+ * loader by hand), we re-assemble a **real** compat `ctx` from the primitives
12
+ * the framework already has — no fabricated stubs (policy D3):
13
+ *
14
+ * - `device` → {@link detectDevice} on the request User-Agent. Derived from
15
+ * `req` (NOT `RequestContext`) on purpose: the `createServerFn` path used in
16
+ * dev / SPA navigation is not wrapped in `RequestContext.run`, so a
17
+ * RequestContext-only device would silently degrade to "desktop" there.
18
+ * - `invoke` → the same nested invoke proxy the client uses, but pointed at
19
+ * an absolute self-origin and routed through `RequestContext.fetch` so the
20
+ * request's AbortSignal propagates. `ctx.invoke.vtex.loaders.x(props)` works
21
+ * server-side via a self-fetch to `/deco/invoke`.
22
+ * - app state → `RequestContext.getAppState(name)`; any unknown property access
23
+ * (`ctx.vtex`, `ctx.salesforce`, …) resolves to the app's registered state
24
+ * or `undefined`. Real lookup, not a fake object.
25
+ * - `response.headers` → `RequestContext.responseHeaders` when in a request
26
+ * scope; an inert `Headers` otherwise (writes don't propagate in dev/SPA but
27
+ * never crash).
28
+ */
29
+
30
+ import { type Device, detectDevice } from "../sdk/detectDevice";
31
+ import { createAppInvokeWith } from "../sdk/invoke";
32
+ import { RequestContext } from "../sdk/requestContext";
33
+
34
+ /**
35
+ * The compat context handed to section loaders as the 3rd argument.
36
+ *
37
+ * It is indexable (`[appName: string]: unknown`) because Fresh loaders read
38
+ * per-app state off `ctx` directly (`ctx.vtex`, `ctx.salesforce`, …). Those
39
+ * reads resolve through {@link RequestContext.getAppState}. Migrated code
40
+ * should still optional-chain deep app-state reads (`ctx.vtex?.config`), since
41
+ * an app that isn't configured yields `undefined`.
42
+ */
43
+ export interface SectionLoaderContext {
44
+ /** Device type detected from the request User-Agent. */
45
+ device: Device;
46
+ /**
47
+ * Nested invoke proxy (`ctx.invoke.vtex.loaders.x(props)`), bound to this
48
+ * request's origin and AbortSignal. Works server-side via self-fetch.
49
+ */
50
+ invoke: any;
51
+ /** Outgoing response headers (e.g. for Set-Cookie forwarding). */
52
+ response: { headers: Headers };
53
+ /** Typed access to an app's request-scoped state. */
54
+ getAppState: <T>(appName: string) => T | undefined;
55
+ /** Per-app state read directly off ctx (Fresh compatibility). */
56
+ [appName: string]: unknown;
57
+ }
58
+
59
+ /**
60
+ * Build the compat {@link SectionLoaderContext} for a single loader call.
61
+ * Cheap to construct (device detect + lazy proxies), so it's fine to build
62
+ * per section loader invocation.
63
+ */
64
+ export function buildSectionLoaderContext(req: Request): SectionLoaderContext {
65
+ // Defensive: `req` may be a minimal/mock request without a real `headers`
66
+ // object. Building the ctx must never throw — that would take down the
67
+ // loader before it runs.
68
+ const device = detectDevice(req.headers?.get?.("user-agent") ?? "");
69
+
70
+ let origin = "";
71
+ try {
72
+ origin = new URL(req.url).origin;
73
+ } catch {
74
+ // Relative/opaque request URL — fall back to a relative base path.
75
+ }
76
+
77
+ const invoke = createAppInvokeWith({
78
+ basePath: `${origin}/deco/invoke`,
79
+ fetcher: (input, init) => RequestContext.fetch(input, init),
80
+ });
81
+
82
+ const base = {
83
+ device,
84
+ invoke,
85
+ get response(): { headers: Headers } {
86
+ // `responseHeaders` throws outside a request scope (dev/SPA serverFn
87
+ // path). Degrade to an inert Headers rather than crash the loader.
88
+ let headers: Headers;
89
+ try {
90
+ headers = RequestContext.responseHeaders;
91
+ } catch {
92
+ headers = new Headers();
93
+ }
94
+ return { headers };
95
+ },
96
+ getAppState<T>(appName: string): T | undefined {
97
+ return RequestContext.getAppState<T>(appName);
98
+ },
99
+ };
100
+
101
+ const known = new Set(["device", "invoke", "response", "getAppState"]);
102
+
103
+ // Wrap so unknown property access (`ctx.vtex`, `ctx.salesforce`, …) resolves
104
+ // to the app's registered state. Known fields fall through to `base`.
105
+ return new Proxy(base as SectionLoaderContext, {
106
+ get(target, prop, receiver) {
107
+ if (typeof prop === "string" && !known.has(prop) && !(prop in target)) {
108
+ return RequestContext.getAppState(prop);
109
+ }
110
+ return Reflect.get(target, prop, receiver);
111
+ },
112
+ });
113
+ }
@@ -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].
@@ -82,6 +158,59 @@ describe("runSingleSectionLoader — page context injection", () => {
82
158
  const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
83
159
  expect(result).toBe(section);
84
160
  });
161
+
162
+ it("passes a 3rd-arg compat ctx (device from UA) — regular path (#305)", async () => {
163
+ const loader = vi.fn(
164
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
165
+ ...props,
166
+ seenDevice: ctx?.device,
167
+ }),
168
+ );
169
+ registerSectionLoader("site/sections/Ctx.tsx", loader);
170
+
171
+ const section = makeSection("site/sections/Ctx.tsx", {});
172
+ const mobileReq = new Request("https://store.com/", {
173
+ headers: { "user-agent": "iPhone Mobile Safari" },
174
+ });
175
+ const result = await runSingleSectionLoader(section, mobileReq);
176
+
177
+ expect((result.props as Record<string, unknown>).seenDevice).toBe("mobile");
178
+ });
179
+
180
+ it("passes the compat ctx through layout and cacheable paths (#305)", async () => {
181
+ const layoutLoader = vi.fn(
182
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
183
+ ...props,
184
+ seenDevice: ctx?.device,
185
+ }),
186
+ );
187
+ const cacheableLoader = vi.fn(
188
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
189
+ ...props,
190
+ seenDevice: ctx?.device,
191
+ }),
192
+ );
193
+ registerSectionLoader("site/sections/CtxHeader.tsx", layoutLoader);
194
+ registerLayoutSections(["site/sections/CtxHeader.tsx"]);
195
+ registerSectionLoader("site/sections/CtxShelf.tsx", cacheableLoader);
196
+ registerCacheableSections({ "site/sections/CtxShelf.tsx": { maxAge: 60_000 } });
197
+
198
+ const mobileReq = new Request("https://store.com/", {
199
+ headers: { "user-agent": "iPhone Mobile Safari" },
200
+ });
201
+
202
+ const layoutResult = await runSingleSectionLoader(
203
+ makeSection("site/sections/CtxHeader.tsx"),
204
+ mobileReq,
205
+ );
206
+ const cacheableResult = await runSingleSectionLoader(
207
+ makeSection("site/sections/CtxShelf.tsx"),
208
+ mobileReq,
209
+ );
210
+
211
+ expect((layoutResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
212
+ expect((cacheableResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
213
+ });
85
214
  });
86
215
 
87
216
  describe("runSingleSectionLoader — cache keying", () => {
@@ -151,9 +280,7 @@ describe("unregisterLayoutSections", () => {
151
280
  });
152
281
 
153
282
  it("is a no-op for keys that were never registered", () => {
154
- expect(() =>
155
- unregisterLayoutSections(["site/sections/Never.tsx"]),
156
- ).not.toThrow();
283
+ expect(() => unregisterLayoutSections(["site/sections/Never.tsx"])).not.toThrow();
157
284
  expect(isLayoutSection("site/sections/Never.tsx")).toBe(false);
158
285
  });
159
286
  });
@@ -190,10 +317,7 @@ describe("registerSectionLoaders — request-dependent + layout warning (#206)",
190
317
  it("warns when a composed loader contains any request-dependent mixin", () => {
191
318
  registerLayoutSections(["site/sections/Footer.tsx"]);
192
319
  registerSectionLoaders({
193
- "site/sections/Footer.tsx": compose(
194
- withSearchParam(),
195
- async (props) => props,
196
- ),
320
+ "site/sections/Footer.tsx": compose(withSearchParam(), async (props) => props),
197
321
  });
198
322
  expect(warnSpy).toHaveBeenCalled();
199
323
  });
@@ -9,15 +9,23 @@
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";
15
16
  import { withTracing } from "../sdk/observability";
16
17
  import type { ResolvedSection } from "./resolve";
18
+ import { buildSectionLoaderContext, type SectionLoaderContext } from "./sectionLoaderContext";
17
19
 
18
20
  export type SectionLoaderFn = (
19
21
  props: Record<string, unknown>,
20
22
  req: Request,
23
+ /**
24
+ * Compat context (issue #305). Optional so 2-arg loaders/mixins remain
25
+ * valid — they simply ignore the extra argument. Framework-supplied at the
26
+ * call site via {@link buildSectionLoaderContext}.
27
+ */
28
+ ctx?: SectionLoaderContext,
21
29
  ) => Promise<Record<string, unknown>> | Record<string, unknown>;
22
30
 
23
31
  // globalThis-backed: server function split modules need access
@@ -26,9 +34,56 @@ if (!G.__deco) G.__deco = {};
26
34
  if (!G.__deco.sectionLoaderRegistry) G.__deco.sectionLoaderRegistry = new Map();
27
35
  if (!G.__deco.layoutSections) G.__deco.layoutSections = new Set();
28
36
  if (!G.__deco.cacheableSections) G.__deco.cacheableSections = new Map();
37
+ if (!G.__deco.nonCriticalSections) G.__deco.nonCriticalSections = new Set();
29
38
 
30
39
  const loaderRegistry: Map<string, SectionLoaderFn> = G.__deco.sectionLoaderRegistry;
31
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
+
32
87
  // ---------------------------------------------------------------------------
33
88
  // Cacheable section loaders — SWR cache for section loader results
34
89
  // ---------------------------------------------------------------------------
@@ -368,9 +423,14 @@ function injectPageContext(
368
423
  return enriched;
369
424
  }
370
425
 
371
- /** Wrap a loader so it receives __pageUrl/__pagePath in its props. */
426
+ /**
427
+ * Wrap a loader so it receives __pageUrl/__pagePath in its props AND the
428
+ * 3rd-arg compat `ctx` (issue #305). This is the single choke point that all
429
+ * four invocation paths (regular, layout, cacheable, SWR refresh) route
430
+ * through, so building `ctx` here covers every path.
431
+ */
372
432
  function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
373
- return (props, req) => loader(injectPageContext(props, req), req);
433
+ return (props, req) => loader(injectPageContext(props, req), req, buildSectionLoaderContext(req));
374
434
  }
375
435
 
376
436
  /**
@@ -420,6 +480,11 @@ async function runSingleSectionLoaderImpl(
420
480
  result = await resolveLayoutSection(section, wrapped, request);
421
481
  } catch (error) {
422
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.
423
488
  result = section;
424
489
  }
425
490
  } else {
@@ -429,6 +494,7 @@ async function runSingleSectionLoaderImpl(
429
494
  result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
430
495
  } catch (error) {
431
496
  console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
497
+ markSectionDegraded(section.component);
432
498
  result = section;
433
499
  }
434
500
  } else {
@@ -437,6 +503,7 @@ async function runSingleSectionLoaderImpl(
437
503
  result = { ...section, props: enrichedProps };
438
504
  } catch (error) {
439
505
  console.error(`[SectionLoader] Error in "${section.component}":`, error);
506
+ markSectionDegraded(section.component);
440
507
  result = section;
441
508
  }
442
509
  }
@@ -83,10 +83,10 @@ export function withSearchParam(): SectionLoaderFn {
83
83
  * ```
84
84
  */
85
85
  export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
86
- const composed: SectionLoaderFn = async (props, req) => {
86
+ const composed: SectionLoaderFn = async (props, req, ctx) => {
87
87
  let result = { ...props };
88
88
  for (const mixin of mixins) {
89
- const partial = await mixin(result, req);
89
+ const partial = await mixin(result, req, ctx);
90
90
  result = { ...result, ...partial };
91
91
  }
92
92
  return result;
@@ -138,12 +138,14 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
138
138
  export function withSectionLoader(
139
139
  modImport: () => Promise<unknown>,
140
140
  ): SectionLoaderFn {
141
- return async (props, req) => {
141
+ return async (props, req, ctx) => {
142
142
  const mod = (await modImport()) as { loader?: unknown } | undefined;
143
143
  const loader = mod?.loader;
144
144
  if (typeof loader !== "function") return props;
145
145
  try {
146
- const result = await (loader as SectionLoaderFn)(props, req);
146
+ // Forward the compat `ctx` (issue #305) so migrated Fresh loaders that
147
+ // read `ctx.device`/`ctx.invoke`/`ctx.<app>` get the real 3rd argument.
148
+ const result = await (loader as SectionLoaderFn)(props, req, ctx);
147
149
  return result ?? props;
148
150
  } catch (error) {
149
151
  console.error("[withSectionLoader] section loader threw:", error);
package/src/sdk/invoke.ts CHANGED
@@ -199,12 +199,71 @@ export function createAppInvoke<T extends Record<string, any>>(
199
199
  basePath?: string,
200
200
  ): NestedFromFlat<T>;
201
201
  export function createAppInvoke(basePath = "/deco/invoke"): any {
202
+ return createAppInvokeWith({ basePath });
203
+ }
204
+
205
+ /**
206
+ * Default nested invoke proxy bound to `/deco/invoke`.
207
+ *
208
+ * Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
209
+ * call. Importing this singleton means no per-site boilerplate.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * import { invoke } from "@decocms/start/sdk/invoke";
214
+ *
215
+ * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
216
+ * await invoke.site.loaders.Wishlist.getWishlist({});
217
+ * ```
218
+ *
219
+ * For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
220
+ * ```ts
221
+ * const invoke = createAppInvoke<Handlers>("/my/invoke");
222
+ * ```
223
+ */
224
+ export const invoke = createAppInvoke();
225
+
226
+ /**
227
+ * A `fetch`-compatible function. Lets callers route the invoke proxy's HTTP
228
+ * calls through a custom transport — e.g. server-side, an absolute-origin
229
+ * self-fetch that injects the request's AbortSignal (`RequestContext.fetch`).
230
+ */
231
+ export type InvokeFetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
232
+
233
+ /**
234
+ * Like {@link createAppInvoke}, but with an injectable `fetcher`.
235
+ *
236
+ * The default `createAppInvoke` targets a relative path (`/deco/invoke`) via
237
+ * the global `fetch`, which only works in the browser. Server-side callers
238
+ * (e.g. a section loader's `ctx.invoke`) need an **absolute** base URL plus a
239
+ * fetcher that forwards the request's AbortSignal. This factory keeps the exact
240
+ * same nested-proxy semantics and 404→`.ts` retry, only swapping the transport.
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * const origin = new URL(req.url).origin;
245
+ * const invoke = createAppInvokeWith({
246
+ * basePath: `${origin}/deco/invoke`,
247
+ * fetcher: (input, init) => RequestContext.fetch(input, init),
248
+ * });
249
+ * await invoke.vtex.loaders.product.detailsPageGQL({ slug });
250
+ * ```
251
+ */
252
+ export function createAppInvokeWith(options?: { basePath?: string; fetcher?: InvokeFetcher }): any;
253
+ export function createAppInvokeWith<T extends Record<string, any>>(options?: {
254
+ basePath?: string;
255
+ fetcher?: InvokeFetcher;
256
+ }): NestedFromFlat<T>;
257
+ export function createAppInvokeWith(options?: { basePath?: string; fetcher?: InvokeFetcher }): any {
258
+ const basePath = options?.basePath ?? "/deco/invoke";
259
+ const fetcher: InvokeFetcher = options?.fetcher ?? ((input, init) => fetch(input, init));
260
+
202
261
  function buildProxy(path: string[]): any {
203
262
  return new Proxy(
204
263
  Object.assign(async (props: any) => {
205
264
  const key = path.join("/");
206
265
  for (const k of [key, `${key}.ts`]) {
207
- const response = await fetch(`${basePath}/${k}`, {
266
+ const response = await fetcher(`${basePath}/${k}`, {
208
267
  method: "POST",
209
268
  headers: { "Content-Type": "application/json" },
210
269
  body: JSON.stringify(props ?? {}),
@@ -237,24 +296,3 @@ export function createAppInvoke(basePath = "/deco/invoke"): any {
237
296
 
238
297
  return buildProxy([]);
239
298
  }
240
-
241
- /**
242
- * Default nested invoke proxy bound to `/deco/invoke`.
243
- *
244
- * Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
245
- * call. Importing this singleton means no per-site boilerplate.
246
- *
247
- * @example
248
- * ```ts
249
- * import { invoke } from "@decocms/start/sdk/invoke";
250
- *
251
- * await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
252
- * await invoke.site.loaders.Wishlist.getWishlist({});
253
- * ```
254
- *
255
- * For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
256
- * ```ts
257
- * const invoke = createAppInvoke<Handlers>("/my/invoke");
258
- * ```
259
- */
260
- export const invoke = createAppInvoke();
@@ -4,8 +4,22 @@
4
4
  * without depending on the Deno-specific @deco/deco package.
5
5
  */
6
6
 
7
+ /**
8
+ * Compat context handed to ported deco.cx (Fresh) section loaders as the 3rd
9
+ * argument (issue #305). `state` is the app state; `device`/`invoke`/`response`
10
+ * mirror what Fresh's `ctx` exposed, and the index signature lets migrated
11
+ * loaders read per-app state directly off `ctx` (`ctx.vtex`, `ctx.salesforce`).
12
+ * Deep reads should still be optional-chained — an unconfigured app is
13
+ * `undefined`. See `@decocms/blocks/cms`'s `SectionLoaderContext` for the
14
+ * runtime shape.
15
+ */
7
16
  export interface FnContext<TState = any> {
8
17
  state: TState;
18
+ device?: "mobile" | "tablet" | "desktop";
19
+ invoke?: any;
20
+ response?: { headers: Headers };
21
+ getAppState?: <T>(appName: string) => T | undefined;
22
+ [key: string]: any;
9
23
  }
10
24
 
11
25
  export type App<TManifest = any, TState = any, TDeps extends any[] = any[]> = {