@decocms/blocks-admin 8.0.0 → 8.1.0-next.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-admin",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "lint:unused": "knip"
26
26
  },
27
27
  "dependencies": {
28
- "@decocms/blocks": "8.0.0"
28
+ "@decocms/blocks": "8.1.0-next.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "react": "^19.0.0",
@@ -25,7 +25,7 @@ export {
25
25
  setInvokeLoaders,
26
26
  } from "./invoke";
27
27
  export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
28
- export { handleMeta, setMetaData } from "./meta";
28
+ export { handleMeta, setMetaData, setMetaLoader } from "./meta";
29
29
  export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
30
30
  export {
31
31
  type PreviewResolution,
@@ -58,7 +58,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
58
58
  });
59
59
 
60
60
  const request = makeInvokeRequest("vtex/actions/addItemsToCart");
61
- const response = await RequestContext.run(request, () => handleInvoke(request));
61
+ const response = await RequestContext.run(request, () =>
62
+ handleInvoke(request),
63
+ );
62
64
 
63
65
  const cookies = response.headers.getSetCookie();
64
66
  expect(cookies).toHaveLength(3);
@@ -77,7 +79,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
77
79
  });
78
80
 
79
81
  const request = makeInvokeRequest("vtex/actions/foo");
80
- const response = await RequestContext.run(request, () => handleInvoke(request));
82
+ const response = await RequestContext.run(request, () =>
83
+ handleInvoke(request),
84
+ );
81
85
 
82
86
  // The regressed bug appended a single comma-joined string, so
83
87
  // `getSetCookie()` returned a 1-element array. The fix appends each
@@ -94,7 +98,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
94
98
  });
95
99
 
96
100
  const request = makeInvokeRequest("vtex/actions/withHeader");
97
- const response = await RequestContext.run(request, () => handleInvoke(request));
101
+ const response = await RequestContext.run(request, () =>
102
+ handleInvoke(request),
103
+ );
98
104
  expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
99
105
  });
100
106
 
@@ -104,7 +110,9 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
104
110
  });
105
111
 
106
112
  const request = makeInvokeRequest("vtex/loaders/productList");
107
- const response = await RequestContext.run(request, () => handleInvoke(request));
113
+ const response = await RequestContext.run(request, () =>
114
+ handleInvoke(request),
115
+ );
108
116
  expect(response.headers.getSetCookie()).toEqual([]);
109
117
  });
110
118
  });
@@ -131,7 +139,9 @@ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
131
139
  "vtex/actions/addItemsToCart": { orderFormId: "x" },
132
140
  "vtex/loaders/productList": {},
133
141
  });
134
- const response = await RequestContext.run(request, () => handleInvoke(request));
142
+ const response = await RequestContext.run(request, () =>
143
+ handleInvoke(request),
144
+ );
135
145
 
136
146
  const cookies = response.headers.getSetCookie();
137
147
  expect(cookies).toHaveLength(3);
@@ -155,7 +165,8 @@ describe("handleInvoke — draft binding", () => {
155
165
  clearDraftCache();
156
166
  setBlocks({ "site/x": { value: "published" } });
157
167
  registerInvokeHandlers({
158
- "site/loaders/x.ts": async () => (loadBlocks() as Record<string, any>)["site/x"],
168
+ "site/loaders/x.ts": async () =>
169
+ (loadBlocks() as Record<string, any>)["site/x"],
159
170
  });
160
171
  });
161
172
  afterEach(() => {
@@ -166,11 +177,14 @@ describe("handleInvoke — draft binding", () => {
166
177
  });
167
178
 
168
179
  function invokeReq(headers: Record<string, string>): Request {
169
- return new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
170
- method: "POST",
171
- headers: { "content-type": "application/json", ...headers },
172
- body: "{}",
173
- });
180
+ return new Request(
181
+ "https://preview.example/deco/invoke/site/loaders/x.ts",
182
+ {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json", ...headers },
185
+ body: "{}",
186
+ },
187
+ );
174
188
  }
175
189
 
176
190
  it("resolves loaders against the DRAFT when the request carries an allowed draft cookie", async () => {
@@ -183,7 +197,8 @@ describe("handleInvoke — draft binding", () => {
183
197
  const res = await handleInvoke(
184
198
  invokeReq({
185
199
  "x-forwarded-host": "preview.example",
186
- cookie: "__deco_draft=abc.preview-studio.decocms.com@vX",
200
+ cookie:
201
+ "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
187
202
  }),
188
203
  );
189
204
  expect(await res.json()).toEqual({ value: "draft" });
@@ -197,7 +212,9 @@ describe("handleInvoke — draft binding", () => {
197
212
  return new Response("{}");
198
213
  }) as unknown as typeof fetch;
199
214
 
200
- const res = await handleInvoke(invokeReq({ "x-forwarded-host": "preview.example" }));
215
+ const res = await handleInvoke(
216
+ invokeReq({ "x-forwarded-host": "preview.example" }),
217
+ );
201
218
  expect(await res.json()).toEqual({ value: "published" });
202
219
  expect(called).toBe(false);
203
220
  });
@@ -212,7 +229,8 @@ describe("handleInvoke — draft binding", () => {
212
229
  const res = await handleInvoke(
213
230
  invokeReq({
214
231
  "x-forwarded-host": "prod.example",
215
- cookie: "__deco_draft=abc.preview-studio.decocms.com@vX",
232
+ cookie:
233
+ "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
216
234
  }),
217
235
  );
218
236
  expect(await res.json()).toEqual({ value: "published" });
@@ -13,7 +13,7 @@
13
13
  * copies those headers into the final HTTP Response.
14
14
  */
15
15
 
16
- import { resolveDraftForRequest, withBlocksOverride } from "@decocms/blocks/cms";
16
+ import { resolveDraftForRequest, withDraftBlocks } from "@decocms/blocks/cms";
17
17
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
18
18
 
19
19
  export type InvokeLoader = (props: any, request: Request) => Promise<any>;
@@ -95,11 +95,13 @@ function forwardCtxHeadersTo(response: Response): void {
95
95
  }
96
96
 
97
97
  const isDev =
98
- typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
98
+ typeof globalThis.process !== "undefined" &&
99
+ globalThis.process.env?.NODE_ENV === "development";
99
100
 
100
101
  function selectFields(data: unknown, select?: string[]): unknown {
101
102
  if (!select?.length || !data || typeof data !== "object") return data;
102
- if (Array.isArray(data)) return data.map((item) => selectFields(item, select));
103
+ if (Array.isArray(data))
104
+ return data.map((item) => selectFields(item, select));
103
105
  const result: Record<string, unknown> = {};
104
106
  for (const key of select) {
105
107
  if (key in (data as Record<string, unknown>)) {
@@ -131,7 +133,9 @@ async function parseBody(request: Request): Promise<any> {
131
133
  for (const [key, value] of formData.entries()) {
132
134
  if (obj[key] !== undefined) {
133
135
  // Multiple values → array
134
- const existing = Array.isArray(obj[key]) ? (obj[key] as unknown[]) : [obj[key]];
136
+ const existing = Array.isArray(obj[key])
137
+ ? (obj[key] as unknown[])
138
+ : [obj[key]];
135
139
  existing.push(value);
136
140
  obj[key] = existing;
137
141
  } else {
@@ -193,10 +197,9 @@ function unknownHandlerMessage(key: string): string {
193
197
  const kind = isAction ? "action" : isLoader ? "loader" : "loader/action";
194
198
  const dir = isAction ? "src/actions/" : "src/loaders/";
195
199
  const cliHint = "npm run generate:loaders";
196
- const setupHint =
197
- isAction
198
- ? "setInvokeActions(() => COMMERCE_ACTIONS)"
199
- : "setInvokeLoaders(() => COMMERCE_LOADERS)";
200
+ const setupHint = isAction
201
+ ? "setInvokeActions(() => COMMERCE_ACTIONS)"
202
+ : "setInvokeLoaders(() => COMMERCE_LOADERS)";
200
203
  return (
201
204
  `Unknown handler: ${key}. ` +
202
205
  `Check that the ${kind} exists in ${dir} (file path must match the key — ` +
@@ -221,16 +224,17 @@ function warnMissingHandlerOnce(key: string): void {
221
224
  * refs; without this, `/deco/invoke` — a separate request from the page render,
222
225
  * so the page's request-scoped draft binding never reaches it — would resolve
223
226
  * against PUBLISHED blocks and hand a reviewer stale content in any
224
- * client-fetched (lazy/on-scroll) or self-fetched section. `withBlocksOverride`
227
+ * client-fetched (lazy/on-scroll) or self-fetched section. `withDraftBlocks`
225
228
  * uses AsyncLocalStorage, so each concurrent invoke gets its own override with
226
229
  * no cross-request bleed (the same isolation the layout-cache race taught us to
227
- * keep). Inert unless the draft gate passes — no draft, no wrap, published path
228
- * untouched.
230
+ * keep) — and composes the draft as a SNAPSHOT, the same semantics the page
231
+ * render applies, so a block deleted in the draft is deleted here too. Inert
232
+ * unless the draft gate passes — no draft, no wrap, published path untouched.
229
233
  */
230
234
  export async function handleInvoke(request: Request): Promise<Response> {
231
235
  const draftBlocks = await resolveDraftForRequest(request);
232
236
  return draftBlocks
233
- ? withBlocksOverride(draftBlocks, () => dispatchInvoke(request))
237
+ ? withDraftBlocks(draftBlocks, () => dispatchInvoke(request))
234
238
  : dispatchInvoke(request);
235
239
  }
236
240
 
@@ -258,7 +262,10 @@ async function dispatchInvoke(request: Request): Promise<Response> {
258
262
  return result;
259
263
  }
260
264
  const filtered = selectFields(result, select);
261
- const response = new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
265
+ const response = new Response(JSON.stringify(filtered), {
266
+ status: 200,
267
+ headers: JSON_HEADERS,
268
+ });
262
269
  forwardCtxHeadersTo(response);
263
270
  return response;
264
271
  } catch (error) {
@@ -267,7 +274,12 @@ async function dispatchInvoke(request: Request): Promise<Response> {
267
274
  }
268
275
 
269
276
  // Batch invoke
270
- if (request.method === "POST" && body && typeof body === "object" && !Array.isArray(body)) {
277
+ if (
278
+ request.method === "POST" &&
279
+ body &&
280
+ typeof body === "object" &&
281
+ !Array.isArray(body)
282
+ ) {
271
283
  const results: Record<string, unknown> = {};
272
284
 
273
285
  const entries = Object.entries(body as Record<string, unknown>);
@@ -286,7 +298,11 @@ async function dispatchInvoke(request: Request): Promise<Response> {
286
298
  // RequestContext.responseHeaders (which forwardCtxHeadersTo()
287
299
  // below propagates onto the batch response).
288
300
  if (result instanceof Response) {
289
- try { result = await result.json(); } catch { result = null; }
301
+ try {
302
+ result = await result.json();
303
+ } catch {
304
+ result = null;
305
+ }
290
306
  }
291
307
  results[key] = selectFields(result, select);
292
308
  } catch (error) {
@@ -299,7 +315,10 @@ async function dispatchInvoke(request: Request): Promise<Response> {
299
315
  }),
300
316
  );
301
317
 
302
- const response = new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
318
+ const response = new Response(JSON.stringify(results), {
319
+ status: 200,
320
+ headers: JSON_HEADERS,
321
+ });
303
322
  // All batch handlers share the same RequestContext, so any Set-Cookie
304
323
  // they appended (e.g. from VTEX vtexFetchWithCookies) is in
305
324
  // `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
package/src/admin/meta.ts CHANGED
@@ -8,6 +8,8 @@ import { composeMeta, type MetaResponse } from "@decocms/blocks/cms";
8
8
  const G = globalThis as unknown as {
9
9
  __deco_meta_data?: MetaResponse | null;
10
10
  __deco_meta_etag?: string | null;
11
+ __deco_meta_loader?: (() => Promise<MetaResponse>) | null;
12
+ __deco_meta_loading?: Promise<MetaResponse | null> | null;
11
13
  };
12
14
 
13
15
  function getMetaData(): MetaResponse | null {
@@ -35,6 +37,60 @@ function setCachedEtag(etag: string | null) {
35
37
  */
36
38
  export function invalidateMetaCache() {
37
39
  setCachedEtag(null);
40
+ // Drop the composed schema too, but ONLY when it can be rebuilt. With a
41
+ // loader registered the next request re-imports and re-composes (composeMeta
42
+ // injects page schemas over the current blocks, so a decofile change really
43
+ // does invalidate it). With data set explicitly via `setMetaData` there is
44
+ // nothing to reload — clearing it would 503 the admin permanently.
45
+ if (G.__deco_meta_loader) setMetaDataInternal(null);
46
+ }
47
+
48
+ /**
49
+ * Register the schema loader instead of resolving it now.
50
+ *
51
+ * The schema is a JSON Schema bundle covering every section and app, and it is
52
+ * needed by exactly one route: `/live/_meta`. Loading it at boot meant every
53
+ * isolate parsed it and pinned the composed graph on `globalThis` for its whole
54
+ * life — for traffic that never touches the admin. Same shape as the decofile
55
+ * bug: a large JSON graph permanently reachable from a module-level binding.
56
+ *
57
+ * `createAdminSetup` has always documented this as lazy; it just wasn't.
58
+ */
59
+ export function setMetaLoader(loader: () => Promise<MetaResponse>) {
60
+ G.__deco_meta_loader = loader;
61
+ }
62
+
63
+ /**
64
+ * The composed schema, importing + composing it on first use.
65
+ *
66
+ * Concurrent first requests share one in-flight load. A failed load is NOT
67
+ * latched: the rejection is swallowed to `null` (the caller answers 503) and
68
+ * the next request retries, so a transient import failure can't disable the
69
+ * admin for the isolate's life.
70
+ */
71
+ async function ensureMetaData(): Promise<MetaResponse | null> {
72
+ const existing = getMetaData();
73
+ if (existing) return existing;
74
+
75
+ const loader = G.__deco_meta_loader;
76
+ if (!loader) return null;
77
+
78
+ G.__deco_meta_loading ??= loader()
79
+ .then((data) => {
80
+ const composed = composeMeta(data);
81
+ setMetaDataInternal(composed);
82
+ setCachedEtag(null);
83
+ return composed;
84
+ })
85
+ .catch((err) => {
86
+ console.warn("[deco] admin meta schema failed to load:", err);
87
+ return null;
88
+ })
89
+ .finally(() => {
90
+ G.__deco_meta_loading = null;
91
+ });
92
+
93
+ return G.__deco_meta_loading;
38
94
  }
39
95
 
40
96
  /**
@@ -62,8 +118,8 @@ function getEtag(): string {
62
118
  return etag;
63
119
  }
64
120
 
65
- export function handleMeta(request: Request): Response {
66
- const metaData = getMetaData();
121
+ export async function handleMeta(request: Request): Promise<Response> {
122
+ const metaData = await ensureMetaData();
67
123
  if (!metaData) {
68
124
  return new Response(JSON.stringify({ error: "Schema not initialized" }), {
69
125
  status: 503,
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { MetaResponse } from "@decocms/blocks/cms";
3
+ import { createAdminSetup } from "../createAdminSetup";
4
+ import { handleMeta, invalidateMetaCache, setMetaData, setMetaLoader } from "./meta";
5
+
6
+ /**
7
+ * The admin JSON Schema bundle covers every section and app, and exactly one
8
+ * route needs it. Loading it at boot made every isolate parse it and pin the
9
+ * composed graph on globalThis for its whole life — for traffic that never
10
+ * touches the admin. Same shape as the decofile bug.
11
+ *
12
+ * These tests pin the property that matters: nothing loads the schema until a
13
+ * request asks for it.
14
+ */
15
+
16
+ const G = globalThis as unknown as Record<string, unknown>;
17
+
18
+ function reset() {
19
+ G.__deco_meta_data = null;
20
+ G.__deco_meta_etag = null;
21
+ G.__deco_meta_loader = null;
22
+ G.__deco_meta_loading = null;
23
+ }
24
+
25
+ const SCHEMA = {
26
+ definitions: { "site/sections/Hero.tsx": { type: "object" } },
27
+ } as unknown as MetaResponse;
28
+ const req = () => new Request("https://site.test/live/_meta");
29
+
30
+ beforeEach(reset);
31
+
32
+ describe("lazy admin meta schema", () => {
33
+ it("does NOT invoke the loader at setup time", () => {
34
+ const meta = vi.fn(() => Promise.resolve(SCHEMA));
35
+ createAdminSetup({ meta, css: "/app.css" });
36
+ // The whole point: boot must not touch the schema.
37
+ expect(meta).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("invokes it on the first /live/_meta, once", async () => {
41
+ const meta = vi.fn(() => Promise.resolve(SCHEMA));
42
+ createAdminSetup({ meta, css: "/app.css" });
43
+
44
+ const first = await handleMeta(req());
45
+ expect(first.status).toBe(200);
46
+ expect(meta).toHaveBeenCalledTimes(1);
47
+
48
+ await handleMeta(req());
49
+ expect(meta).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("shares one in-flight load across concurrent first requests", async () => {
53
+ let release!: (v: unknown) => void;
54
+ const meta = vi.fn(
55
+ () =>
56
+ new Promise<never>((r) => {
57
+ release = r as never;
58
+ }),
59
+ );
60
+ setMetaLoader(meta as unknown as () => Promise<typeof SCHEMA>);
61
+
62
+ const all = Promise.all([handleMeta(req()), handleMeta(req()), handleMeta(req())]);
63
+ release(SCHEMA);
64
+ const responses = await all;
65
+
66
+ expect(meta).toHaveBeenCalledTimes(1);
67
+ for (const r of responses) expect(r.status).toBe(200);
68
+ });
69
+
70
+ it("serves the COMPOSED schema, not the raw input", async () => {
71
+ setMetaLoader(() => Promise.resolve(SCHEMA));
72
+ const body = (await (await handleMeta(req())).json()) as { framework?: unknown };
73
+ // `framework` is composeMeta's own sentinel (it is the field it sets and
74
+ // then uses as its idempotency guard), so its presence proves the lazy
75
+ // path still composes rather than serving the file through.
76
+ expect(body.framework).toBeDefined();
77
+ });
78
+
79
+ it("retries after a failed load instead of latching a dead isolate", async () => {
80
+ const meta = vi
81
+ .fn<() => Promise<typeof SCHEMA>>()
82
+ .mockRejectedValueOnce(new Error("chunk load failed"))
83
+ .mockResolvedValueOnce(SCHEMA);
84
+ setMetaLoader(meta);
85
+
86
+ expect((await handleMeta(req())).status).toBe(503);
87
+ expect((await handleMeta(req())).status).toBe(200);
88
+ expect(meta).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ it("503s when no loader and no data were ever provided", async () => {
92
+ expect((await handleMeta(req())).status).toBe(503);
93
+ });
94
+
95
+ it("still honors an explicitly set schema, with no loader", async () => {
96
+ setMetaData(SCHEMA);
97
+ expect((await handleMeta(req())).status).toBe(200);
98
+ });
99
+
100
+ it("keeps explicitly-set data across invalidation — there is nothing to reload", async () => {
101
+ // Clearing it would 503 the admin permanently for a site on setMetaData.
102
+ setMetaData(SCHEMA);
103
+ invalidateMetaCache();
104
+ expect((await handleMeta(req())).status).toBe(200);
105
+ });
106
+
107
+ it("re-composes after invalidation when a loader can rebuild it", async () => {
108
+ const meta = vi.fn(() => Promise.resolve(SCHEMA));
109
+ setMetaLoader(meta);
110
+ await handleMeta(req());
111
+ invalidateMetaCache();
112
+ await handleMeta(req());
113
+ expect(meta).toHaveBeenCalledTimes(2);
114
+ });
115
+
116
+ it("answers 304 to a matching If-None-Match", async () => {
117
+ setMetaLoader(() => Promise.resolve(SCHEMA));
118
+ const etag = (await handleMeta(req())).headers.get("etag")!;
119
+ const res = await handleMeta(
120
+ new Request("https://site.test/live/_meta", { headers: { "if-none-match": etag } }),
121
+ );
122
+ expect(res.status).toBe(304);
123
+ });
124
+ });
@@ -31,6 +31,18 @@ function wrapInHtmlShell(sectionHtml: string): string {
31
31
  return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
32
32
  }
33
33
 
34
+ /**
35
+ * DOM id for a section's wrapper element. Must stay in sync with the
36
+ * production renderer (`DecoPageRenderer.tsx`) so the admin's in-place
37
+ * Fast Preview replace can match `[data-manifest-key]` / `#id` 1:1.
38
+ */
39
+ function sectionDomId(key: string): string {
40
+ return key
41
+ .replace(/\//g, "-")
42
+ .replace(/\.tsx$/, "")
43
+ .replace(/^site-sections-/, "");
44
+ }
45
+
34
46
  /**
35
47
  * Render a single ResolvedSection to an HTML string.
36
48
  * Uses the pre-cached renderToString and the preview wrapper.
@@ -44,8 +56,17 @@ async function renderResolvedSection(section: ResolvedSection): Promise<string>
44
56
  const renderToString = await getRenderToString();
45
57
  const mod = await sectionLoader();
46
58
  const element = createElement(mod.default, section.props);
59
+ // Match the production wrapper (DecoPageRenderer.tsx) so the admin's
60
+ // in-place Fast Preview replace can map each section to its live-DOM
61
+ // counterpart via [data-manifest-key]. Without it, sections rendered on
62
+ // the /live/previews path come back unwrapped and end up glued together.
63
+ const sectionEl = createElement(
64
+ "section",
65
+ { id: sectionDomId(section.key), "data-manifest-key": section.key },
66
+ element,
67
+ );
47
68
  const Wrapper = getPreviewWrapper();
48
- const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
69
+ const wrapped = Wrapper ? createElement(Wrapper, null, sectionEl) : sectionEl;
49
70
  return renderToString(wrapped);
50
71
  }
51
72
 
@@ -126,4 +126,29 @@ describe("resolvePreviewRequest", () => {
126
126
  expect(html).toContain("hero-html");
127
127
  expect(html).toContain("editor::inject");
128
128
  });
129
+
130
+ it("wraps each rendered section in <section data-manifest-key> so the in-place preview replace can map it", async () => {
131
+ setBlocks({
132
+ "/pages/home": {
133
+ __resolveType: WELL_KNOWN_TYPES.PAGE,
134
+ sections: [
135
+ { __resolveType: HERO, label: "a" },
136
+ { __resolveType: HERO, label: "b" },
137
+ ],
138
+ },
139
+ });
140
+
141
+ const response = await handleRender(
142
+ new Request(`http://localhost/live/previews/${encodeURIComponent("/pages/home")}`),
143
+ );
144
+ const html = await response.text();
145
+
146
+ // Same wrapper markup as production (DecoPageRenderer.tsx): id derived from
147
+ // the section key, data-manifest-key set to the raw key.
148
+ expect(html).toContain(`<section id="Hero" data-manifest-key="${HERO}">`);
149
+ // Both sections are individually wrapped — not concatenated into one blob.
150
+ expect(html.match(/data-manifest-key/g)?.length).toBe(2);
151
+ expect(html).toContain("hero-a");
152
+ expect(html).toContain("hero-b");
153
+ });
129
154
  });
@@ -162,7 +162,11 @@ export async function resolvePreviewRequest(request: Request): Promise<PreviewRe
162
162
  return { type: "unknown", component };
163
163
  }
164
164
 
165
- const resolvedProps = (await resolveValue(props)) as Record<string, unknown>;
165
+ const resolvedProps = (await resolveValue(
166
+ props,
167
+ undefined,
168
+ buildPreviewMatcherCtx(request),
169
+ )) as Record<string, unknown>;
166
170
  const { __resolveType: _, ...cleanProps } = resolvedProps;
167
171
  return {
168
172
  type: "sections",
@@ -70,6 +70,30 @@ export interface AppRegistryEntry {
70
70
 
71
71
  export type AppRegistry = readonly AppRegistryEntry[];
72
72
 
73
+ // One-shot re-configuration registered by autoconfigApps() and triggered by the
74
+ // first request (reconfigureAppsOnce). See reconfigureAppsOnce() below for why.
75
+ let pendingReinit: (() => Promise<void>) | null = null;
76
+ let reinitPromise: Promise<void> | null = null;
77
+
78
+ /**
79
+ * Re-run app configuration once, the first time it's called from within a
80
+ * request scope. Idempotent — subsequent calls await the same in-flight promise
81
+ * (or return immediately once done).
82
+ *
83
+ * Why this exists: autoconfigApps() runs at module-init (setup.ts), BEFORE any
84
+ * request. In CF Workers production/preview the `env` binding (holding
85
+ * DECO_CRYPTO_KEY) is only available inside the fetch handler, so the startup
86
+ * pass decrypts nothing and configures apps with null credentials. workerEntry
87
+ * calls this after setRuntimeEnv(env) so configure() re-runs with the real key
88
+ * now reachable via getRuntimeEnv() inside crypto.ts's getEnvVar().
89
+ */
90
+ export function reconfigureAppsOnce(): Promise<void> {
91
+ if (reinitPromise) return reinitPromise;
92
+ if (!pendingReinit) return Promise.resolve(); // nothing registered yet
93
+ reinitPromise = pendingReinit();
94
+ return reinitPromise;
95
+ }
96
+
73
97
  async function configureAllApps(
74
98
  blocks: Record<string, unknown>,
75
99
  registry: AppRegistry,
@@ -137,12 +161,39 @@ export async function autoconfigApps(
137
161
  await setupApps(apps);
138
162
  }
139
163
 
164
+ // Latest blocks, kept current by onChange so the first-request re-init below
165
+ // uses fresh content if the decofile was reloaded before the first request.
166
+ let latestBlocks = blocks;
167
+
140
168
  // Re-configure on admin hot-reload
141
169
  onChange(async (newBlocks) => {
142
170
  if (typeof document !== "undefined") return;
171
+ latestBlocks = newBlocks;
143
172
  const updatedApps = await configureAllApps(newBlocks, registry);
144
173
  if (updatedApps.length > 0) {
145
174
  await setupApps(updatedApps);
146
175
  }
147
176
  });
177
+
178
+ // Register the one-shot re-init fired on the first request (where the CF
179
+ // Workers `env` — and thus DECO_CRYPTO_KEY — becomes reachable). See
180
+ // reconfigureAppsOnce() for the full rationale.
181
+ pendingReinit = async () => {
182
+ const reconfigured = await configureAllApps(latestBlocks, registry);
183
+ if (reconfigured.length > 0) {
184
+ await setupApps(reconfigured);
185
+ }
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Test-only: clear the one-shot re-init state.
191
+ *
192
+ * `pendingReinit` / `reinitPromise` are module-level and latching by design
193
+ * (see `reconfigureAppsOnce`), so without this a test that registers apps
194
+ * leaks that registration into every later test in the same module graph.
195
+ */
196
+ export function __resetAutoconfigStateForTests(): void {
197
+ pendingReinit = null;
198
+ reinitPromise = null;
148
199
  }
@@ -6,17 +6,17 @@
6
6
  * here because these four steps need admin/index.ts's setters, which
7
7
  * runtime cannot import without creating a circular dependency.
8
8
  */
9
- import {
10
- setInvokeLoaders,
11
- setMetaData,
12
- setPreviewWrapper,
13
- setRenderShell,
14
- } from "./admin/index";
9
+ import { setInvokeLoaders, setMetaLoader, setPreviewWrapper, setRenderShell } from "./admin/index";
15
10
 
16
11
  export interface AdminSetupOptions {
17
12
  /**
18
- * Lazy loader for admin meta schema — only fetched when admin requests it:
13
+ * Lazy loader for admin meta schema — only invoked on the first `/live/_meta`
14
+ * request, never at boot:
19
15
  * `() => import("./server/admin/meta.gen.json").then(m => m.default)`
16
+ *
17
+ * Keep it a dynamic `import()`. A static import would defeat the laziness:
18
+ * the module graph would pull the schema in at boot no matter when this thunk
19
+ * runs.
20
20
  */
21
21
  meta: () => Promise<any>;
22
22
 
@@ -43,8 +43,12 @@ export interface AdminSetupOptions {
43
43
  * createSiteSetup() (@decocms/blocks/setup).
44
44
  */
45
45
  export function createAdminSetup(options: AdminSetupOptions): void {
46
- // 7. Admin meta schema (lazy)
47
- options.meta().then((data) => setMetaData(data));
46
+ // 7. Admin meta schema — REGISTERED, not loaded. The schema is a JSON Schema
47
+ // bundle covering every section and app, and exactly one route needs it
48
+ // (`/live/_meta`). Invoking the thunk here made every isolate parse it at boot
49
+ // and pin the composed graph for its whole life, for traffic that never
50
+ // touches the admin.
51
+ setMetaLoader(options.meta);
48
52
 
49
53
  // 8. Render shell
50
54
  setRenderShell({