@decocms/blocks-admin 7.28.2 → 8.0.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": "7.28.2",
3
+ "version": "8.0.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": "7.28.2"
28
+ "@decocms/blocks": "8.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "react": "^19.0.0",
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { clearDraftCache, loadBlocks, setBlocks } from "@decocms/blocks/cms";
18
19
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
19
20
  import {
20
21
  clearInvokeHandlers,
@@ -139,3 +140,81 @@ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
139
140
  expect(cookies).toContain(COOKIE_C);
140
141
  });
141
142
  });
143
+
144
+ /**
145
+ * Draft binding: /deco/invoke is a separate request from the page render, so
146
+ * the page's request-scoped draft never reaches it. handleInvoke must
147
+ * re-resolve the draft from the request it received and bind it, or a
148
+ * client-fetched (lazy) section resolves its loaders against PUBLISHED blocks.
149
+ */
150
+ describe("handleInvoke — draft binding", () => {
151
+ const origFetch = globalThis.fetch;
152
+
153
+ beforeEach(() => {
154
+ clearInvokeHandlers();
155
+ clearDraftCache();
156
+ setBlocks({ "site/x": { value: "published" } });
157
+ registerInvokeHandlers({
158
+ "site/loaders/x.ts": async () => (loadBlocks() as Record<string, any>)["site/x"],
159
+ });
160
+ });
161
+ afterEach(() => {
162
+ clearInvokeHandlers();
163
+ clearDraftCache();
164
+ globalThis.fetch = origFetch;
165
+ delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
166
+ });
167
+
168
+ 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
+ });
174
+ }
175
+
176
+ it("resolves loaders against the DRAFT when the request carries an allowed draft cookie", async () => {
177
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
178
+ globalThis.fetch = (async () =>
179
+ new Response(JSON.stringify({ "site/x": { value: "draft" } }), {
180
+ headers: { "content-type": "application/json" },
181
+ })) as unknown as typeof fetch;
182
+
183
+ const res = await handleInvoke(
184
+ invokeReq({
185
+ "x-forwarded-host": "preview.example",
186
+ cookie: "__deco_draft=abc.preview-studio.decocms.com@vX",
187
+ }),
188
+ );
189
+ expect(await res.json()).toEqual({ value: "draft" });
190
+ });
191
+
192
+ it("resolves against PUBLISHED blocks when no draft cookie is present — no network", async () => {
193
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
194
+ let called = false;
195
+ globalThis.fetch = (async () => {
196
+ called = true;
197
+ return new Response("{}");
198
+ }) as unknown as typeof fetch;
199
+
200
+ const res = await handleInvoke(invokeReq({ "x-forwarded-host": "preview.example" }));
201
+ expect(await res.json()).toEqual({ value: "published" });
202
+ expect(called).toBe(false);
203
+ });
204
+
205
+ it("ignores a draft cookie on a non-allowed host (production stays published)", async () => {
206
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
207
+ globalThis.fetch = (async () =>
208
+ new Response(JSON.stringify({ "site/x": { value: "draft" } }), {
209
+ headers: { "content-type": "application/json" },
210
+ })) as unknown as typeof fetch;
211
+
212
+ const res = await handleInvoke(
213
+ invokeReq({
214
+ "x-forwarded-host": "prod.example",
215
+ cookie: "__deco_draft=abc.preview-studio.decocms.com@vX",
216
+ }),
217
+ );
218
+ expect(await res.json()).toEqual({ value: "published" });
219
+ });
220
+ });
@@ -13,6 +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
17
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
17
18
 
18
19
  export type InvokeLoader = (props: any, request: Request) => Promise<any>;
@@ -213,7 +214,27 @@ function warnMissingHandlerOnce(key: string): void {
213
214
  console.warn(`[invoke] ${unknownHandlerMessage(key)}`);
214
215
  }
215
216
 
217
+ /**
218
+ * Bind a draft, if the request carries one, for the whole invoke resolution.
219
+ *
220
+ * A loader/action reaches `loadBlocks()` while resolving its `__resolveType`
221
+ * refs; without this, `/deco/invoke` — a separate request from the page render,
222
+ * so the page's request-scoped draft binding never reaches it — would resolve
223
+ * against PUBLISHED blocks and hand a reviewer stale content in any
224
+ * client-fetched (lazy/on-scroll) or self-fetched section. `withBlocksOverride`
225
+ * uses AsyncLocalStorage, so each concurrent invoke gets its own override with
226
+ * 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.
229
+ */
216
230
  export async function handleInvoke(request: Request): Promise<Response> {
231
+ const draftBlocks = await resolveDraftForRequest(request);
232
+ return draftBlocks
233
+ ? withBlocksOverride(draftBlocks, () => dispatchInvoke(request))
234
+ : dispatchInvoke(request);
235
+ }
236
+
237
+ async function dispatchInvoke(request: Request): Promise<Response> {
217
238
  const url = new URL(request.url);
218
239
  const pathParts = url.pathname.split("/deco/invoke/");
219
240
  const invokeKey = pathParts[1] || "";