@decocms/blocks 7.22.2 → 7.23.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.23.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/src/cms/index.ts CHANGED
@@ -81,6 +81,8 @@ export {
81
81
  WELL_KNOWN_TYPES,
82
82
  } from "./resolve";
83
83
  export type { SectionLoaderFn } from "./sectionLoaders";
84
+ export type { SectionLoaderContext } from "./sectionLoaderContext";
85
+ export { buildSectionLoaderContext } from "./sectionLoaderContext";
84
86
  export {
85
87
  isLayoutSection,
86
88
  registerCacheableSections,
@@ -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
+ }
@@ -82,6 +82,59 @@ describe("runSingleSectionLoader — page context injection", () => {
82
82
  const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
83
83
  expect(result).toBe(section);
84
84
  });
85
+
86
+ it("passes a 3rd-arg compat ctx (device from UA) — regular path (#305)", async () => {
87
+ const loader = vi.fn(
88
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
89
+ ...props,
90
+ seenDevice: ctx?.device,
91
+ }),
92
+ );
93
+ registerSectionLoader("site/sections/Ctx.tsx", loader);
94
+
95
+ const section = makeSection("site/sections/Ctx.tsx", {});
96
+ const mobileReq = new Request("https://store.com/", {
97
+ headers: { "user-agent": "iPhone Mobile Safari" },
98
+ });
99
+ const result = await runSingleSectionLoader(section, mobileReq);
100
+
101
+ expect((result.props as Record<string, unknown>).seenDevice).toBe("mobile");
102
+ });
103
+
104
+ it("passes the compat ctx through layout and cacheable paths (#305)", async () => {
105
+ const layoutLoader = vi.fn(
106
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
107
+ ...props,
108
+ seenDevice: ctx?.device,
109
+ }),
110
+ );
111
+ const cacheableLoader = vi.fn(
112
+ async (props: Record<string, unknown>, _req: Request, ctx?: { device?: string }) => ({
113
+ ...props,
114
+ seenDevice: ctx?.device,
115
+ }),
116
+ );
117
+ registerSectionLoader("site/sections/CtxHeader.tsx", layoutLoader);
118
+ registerLayoutSections(["site/sections/CtxHeader.tsx"]);
119
+ registerSectionLoader("site/sections/CtxShelf.tsx", cacheableLoader);
120
+ registerCacheableSections({ "site/sections/CtxShelf.tsx": { maxAge: 60_000 } });
121
+
122
+ const mobileReq = new Request("https://store.com/", {
123
+ headers: { "user-agent": "iPhone Mobile Safari" },
124
+ });
125
+
126
+ const layoutResult = await runSingleSectionLoader(
127
+ makeSection("site/sections/CtxHeader.tsx"),
128
+ mobileReq,
129
+ );
130
+ const cacheableResult = await runSingleSectionLoader(
131
+ makeSection("site/sections/CtxShelf.tsx"),
132
+ mobileReq,
133
+ );
134
+
135
+ expect((layoutResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
136
+ expect((cacheableResult.props as Record<string, unknown>).seenDevice).toBe("mobile");
137
+ });
85
138
  });
86
139
 
87
140
  describe("runSingleSectionLoader — cache keying", () => {
@@ -151,9 +204,7 @@ describe("unregisterLayoutSections", () => {
151
204
  });
152
205
 
153
206
  it("is a no-op for keys that were never registered", () => {
154
- expect(() =>
155
- unregisterLayoutSections(["site/sections/Never.tsx"]),
156
- ).not.toThrow();
207
+ expect(() => unregisterLayoutSections(["site/sections/Never.tsx"])).not.toThrow();
157
208
  expect(isLayoutSection("site/sections/Never.tsx")).toBe(false);
158
209
  });
159
210
  });
@@ -190,10 +241,7 @@ describe("registerSectionLoaders — request-dependent + layout warning (#206)",
190
241
  it("warns when a composed loader contains any request-dependent mixin", () => {
191
242
  registerLayoutSections(["site/sections/Footer.tsx"]);
192
243
  registerSectionLoaders({
193
- "site/sections/Footer.tsx": compose(
194
- withSearchParam(),
195
- async (props) => props,
196
- ),
244
+ "site/sections/Footer.tsx": compose(withSearchParam(), async (props) => props),
197
245
  });
198
246
  expect(warnSpy).toHaveBeenCalled();
199
247
  });
@@ -14,10 +14,17 @@ import { djb2 } from "../sdk/djb2";
14
14
  import { withInflightTimeout } from "../sdk/inflightTimeout";
15
15
  import { withTracing } from "../sdk/observability";
16
16
  import type { ResolvedSection } from "./resolve";
17
+ import { buildSectionLoaderContext, type SectionLoaderContext } from "./sectionLoaderContext";
17
18
 
18
19
  export type SectionLoaderFn = (
19
20
  props: Record<string, unknown>,
20
21
  req: Request,
22
+ /**
23
+ * Compat context (issue #305). Optional so 2-arg loaders/mixins remain
24
+ * valid — they simply ignore the extra argument. Framework-supplied at the
25
+ * call site via {@link buildSectionLoaderContext}.
26
+ */
27
+ ctx?: SectionLoaderContext,
21
28
  ) => Promise<Record<string, unknown>> | Record<string, unknown>;
22
29
 
23
30
  // globalThis-backed: server function split modules need access
@@ -368,9 +375,14 @@ function injectPageContext(
368
375
  return enriched;
369
376
  }
370
377
 
371
- /** Wrap a loader so it receives __pageUrl/__pagePath in its props. */
378
+ /**
379
+ * Wrap a loader so it receives __pageUrl/__pagePath in its props AND the
380
+ * 3rd-arg compat `ctx` (issue #305). This is the single choke point that all
381
+ * four invocation paths (regular, layout, cacheable, SWR refresh) route
382
+ * through, so building `ctx` here covers every path.
383
+ */
372
384
  function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
373
- return (props, req) => loader(injectPageContext(props, req), req);
385
+ return (props, req) => loader(injectPageContext(props, req), req, buildSectionLoaderContext(req));
374
386
  }
375
387
 
376
388
  /**
@@ -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[]> = {