@decocms/blocks 7.34.1 → 7.34.3
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 +1 -1
- package/src/cms/applySectionConventions.ts +8 -0
- package/src/cms/loader.test.ts +84 -1
- package/src/cms/loader.ts +54 -11
- package/src/cms/resolve.test.ts +17 -0
- package/src/cms/resolve.ts +28 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { registerSection, registerSectionsSync } from "./registry";
|
|
|
10
10
|
import {
|
|
11
11
|
type AsyncRenderingConfig,
|
|
12
12
|
getAsyncRenderingConfig,
|
|
13
|
+
registerAlwaysDeferSections,
|
|
13
14
|
registerEagerSections,
|
|
14
15
|
registerNeverDeferSections,
|
|
15
16
|
registerSeoSections,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
export interface SectionMetaEntry {
|
|
25
26
|
eager?: boolean;
|
|
26
27
|
neverDefer?: boolean;
|
|
28
|
+
deferred?: boolean;
|
|
27
29
|
cache?: string;
|
|
28
30
|
layout?: boolean;
|
|
29
31
|
sync?: boolean;
|
|
@@ -48,6 +50,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
48
50
|
|
|
49
51
|
const eagerSections: string[] = [];
|
|
50
52
|
const neverDeferSections: string[] = [];
|
|
53
|
+
const alwaysDeferSections: string[] = [];
|
|
51
54
|
const layoutSections: string[] = [];
|
|
52
55
|
const seoSections: string[] = [];
|
|
53
56
|
const cacheableSections: Record<string, CacheableSectionInput> = {};
|
|
@@ -55,6 +58,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
55
58
|
for (const [key, entry] of Object.entries(meta)) {
|
|
56
59
|
if (entry.eager) eagerSections.push(key);
|
|
57
60
|
if (entry.neverDefer) neverDeferSections.push(key);
|
|
61
|
+
if (entry.deferred) alwaysDeferSections.push(key);
|
|
58
62
|
if (entry.layout) layoutSections.push(key);
|
|
59
63
|
if (entry.seo) seoSections.push(key);
|
|
60
64
|
if (entry.cache) cacheableSections[key] = entry.cache as CacheableSectionInput;
|
|
@@ -88,6 +92,10 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
88
92
|
registerNeverDeferSections(neverDeferSections);
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
if (alwaysDeferSections.length > 0) {
|
|
96
|
+
registerAlwaysDeferSections(alwaysDeferSections);
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
// Always initialize asyncConfig so CMS Lazy deferral (`respectCmsLazy`) is
|
|
92
100
|
// active — this is what makes the admin ⚡ toggle the source of truth. Without
|
|
93
101
|
// it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
|
package/src/cms/loader.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { setDraftOverrideGetter } from "./draftSource";
|
|
3
|
+
import { findPageByPath, loadBlocks, matchPath, setBlocks } from "./loader";
|
|
3
4
|
|
|
4
5
|
// Mirrors the behavior of the original deco-cx/deco Fresh framework
|
|
5
6
|
// (runtime/features/render.tsx), which uses native `URLPattern` directly
|
|
@@ -198,3 +199,85 @@ describe("findPageByPath specificity", () => {
|
|
|
198
199
|
expect(findPageByPath("/nope")).toBeNull();
|
|
199
200
|
});
|
|
200
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("loadBlocks draft override — key percent-encoding", () => {
|
|
204
|
+
// The published decofile encodes special characters in block keys
|
|
205
|
+
// (`pages-Home%20(principal)-1`); the Studio draft-preview sandbox emits them
|
|
206
|
+
// raw (`pages-Home (principal)-1`). A naive key-merge ADDS the raw-keyed
|
|
207
|
+
// draft block instead of REPLACING the encoded published one, leaving two
|
|
208
|
+
// `pages-` blocks with path "/" — and findPageByPath returns the first
|
|
209
|
+
// (published) one, so the draft edit silently never renders. This shipped and
|
|
210
|
+
// made ~73% of casaevideo's pages (home + afiliados included) ignore drafts.
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
setBlocks({});
|
|
214
|
+
setDraftOverrideGetter(() => undefined);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("replaces an encoded base key with its raw-encoded draft twin (home)", () => {
|
|
218
|
+
setBlocks({
|
|
219
|
+
"pages-Home%20(principal)-1": {
|
|
220
|
+
name: "Home",
|
|
221
|
+
path: "/",
|
|
222
|
+
sections: [{ __resolveType: "published" }],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
setDraftOverrideGetter(() => ({
|
|
226
|
+
"pages-Home (principal)-1": {
|
|
227
|
+
name: "Home",
|
|
228
|
+
path: "/",
|
|
229
|
+
sections: [{ __resolveType: "draft" }],
|
|
230
|
+
},
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
// Exactly one page for "/" survives the merge — no encoded/raw duplicate.
|
|
234
|
+
const pageBlocks = Object.keys(loadBlocks()).filter((k) =>
|
|
235
|
+
k.startsWith("pages-"),
|
|
236
|
+
);
|
|
237
|
+
expect(pageBlocks).toHaveLength(1);
|
|
238
|
+
|
|
239
|
+
const match = findPageByPath("/");
|
|
240
|
+
const sections = match?.page.sections as Array<{ __resolveType: string }>;
|
|
241
|
+
expect(sections[0].__resolveType).toBe("draft");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("still replaces a plain (unescaped) base key", () => {
|
|
245
|
+
setBlocks({
|
|
246
|
+
"pages-disney-1": {
|
|
247
|
+
name: "Disney",
|
|
248
|
+
path: "/disney",
|
|
249
|
+
sections: [{ __resolveType: "published" }],
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
setDraftOverrideGetter(() => ({
|
|
253
|
+
"pages-disney-1": {
|
|
254
|
+
name: "Disney",
|
|
255
|
+
path: "/disney",
|
|
256
|
+
sections: [{ __resolveType: "draft" }],
|
|
257
|
+
},
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
const match = findPageByPath("/disney");
|
|
261
|
+
const sections = match?.page.sections as Array<{ __resolveType: string }>;
|
|
262
|
+
expect(sections[0].__resolveType).toBe("draft");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("a null draft value removes the encoded base twin too", () => {
|
|
266
|
+
setBlocks({
|
|
267
|
+
"pages-Home%20(principal)-1": {
|
|
268
|
+
name: "Home",
|
|
269
|
+
path: "/",
|
|
270
|
+
sections: [{ __resolveType: "published" }],
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
setDraftOverrideGetter(() => ({
|
|
274
|
+
"pages-Home (principal)-1": null,
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
expect(findPageByPath("/")).toBeNull();
|
|
278
|
+
const pageBlocks = Object.keys(loadBlocks()).filter((k) =>
|
|
279
|
+
k.startsWith("pages-"),
|
|
280
|
+
);
|
|
281
|
+
expect(pageBlocks).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
package/src/cms/loader.ts
CHANGED
|
@@ -85,6 +85,59 @@ export function setBlocks(blocks: Record<string, unknown>) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Canonicalise a block key so an override block matches its base twin even when
|
|
90
|
+
* the two decofiles disagree on percent-encoding of the key.
|
|
91
|
+
*
|
|
92
|
+
* The site's published decofile encodes special characters in block keys
|
|
93
|
+
* (`pages-Home%20(principal)-287364`); the Studio draft-preview sandbox emits
|
|
94
|
+
* them raw (`pages-Home (principal)-287364`). Decoding collapses both spellings
|
|
95
|
+
* to the same canonical form. Defensive: a malformed `%` sequence makes
|
|
96
|
+
* `decodeURIComponent` throw, so fall back to the raw key rather than 500 the
|
|
97
|
+
* merge.
|
|
98
|
+
*/
|
|
99
|
+
function canonicalBlockKey(key: string): string {
|
|
100
|
+
try {
|
|
101
|
+
return decodeURIComponent(key);
|
|
102
|
+
} catch {
|
|
103
|
+
return key;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Merge an override decofile on top of the base blocks.
|
|
109
|
+
*
|
|
110
|
+
* An override entry REPLACES the base block of the same logical key — including
|
|
111
|
+
* a base twin spelled with different percent-encoding. A naive key-merge would
|
|
112
|
+
* instead ADD the differently-encoded override block, leaving two `pages-`
|
|
113
|
+
* blocks with the same `.path`; `findPageByPath` returns the first (base) one,
|
|
114
|
+
* so a draft-preview edit would silently never render. Canonicalising the base
|
|
115
|
+
* keys lets the override find and displace its twin.
|
|
116
|
+
*
|
|
117
|
+
* A `null`/`undefined` override value deletes the block (its twin too), so a
|
|
118
|
+
* draft that removes a block is honoured.
|
|
119
|
+
*/
|
|
120
|
+
function mergeOverride(
|
|
121
|
+
base: Record<string, unknown>,
|
|
122
|
+
override: Record<string, unknown>,
|
|
123
|
+
): Record<string, unknown> {
|
|
124
|
+
const merged = { ...base };
|
|
125
|
+
const baseByCanonical = new Map<string, string>();
|
|
126
|
+
for (const baseKey of Object.keys(merged)) {
|
|
127
|
+
baseByCanonical.set(canonicalBlockKey(baseKey), baseKey);
|
|
128
|
+
}
|
|
129
|
+
for (const [key, value] of Object.entries(override)) {
|
|
130
|
+
const twin = baseByCanonical.get(canonicalBlockKey(key));
|
|
131
|
+
if (twin !== undefined && twin !== key) delete merged[twin];
|
|
132
|
+
if (value === null || value === undefined) {
|
|
133
|
+
delete merged[key];
|
|
134
|
+
} else {
|
|
135
|
+
merged[key] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return merged;
|
|
139
|
+
}
|
|
140
|
+
|
|
88
141
|
/**
|
|
89
142
|
* Load the current blocks. If running inside a `withBlocksOverride` scope
|
|
90
143
|
* (admin preview) or a request carrying a draft-preview override, that
|
|
@@ -101,17 +154,7 @@ export function loadBlocks(): Record<string, unknown> {
|
|
|
101
154
|
// over an ambient draft: the caller named the exact blocks to render, so a
|
|
102
155
|
// draft pointer on the same request must not silently replace them.
|
|
103
156
|
const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride();
|
|
104
|
-
if (override)
|
|
105
|
-
const merged = { ...blockData };
|
|
106
|
-
for (const [key, value] of Object.entries(override)) {
|
|
107
|
-
if (value === null || value === undefined) {
|
|
108
|
-
delete merged[key];
|
|
109
|
-
} else {
|
|
110
|
-
merged[key] = value;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return merged;
|
|
114
|
-
}
|
|
157
|
+
if (override) return mergeOverride(blockData, override);
|
|
115
158
|
return blockData;
|
|
116
159
|
}
|
|
117
160
|
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
isEagerRequest,
|
|
33
33
|
registerCommerceLoader,
|
|
34
34
|
registerEagerSections,
|
|
35
|
+
registerAlwaysDeferSections,
|
|
35
36
|
registerNeverDeferSections,
|
|
36
37
|
resolveDecoPage,
|
|
37
38
|
resolveDeferredSectionFull,
|
|
@@ -468,6 +469,22 @@ describe("shouldDeferSection — admin is the source of truth", () => {
|
|
|
468
469
|
expect(shouldDeferSection(section, 5, mkCfg({ foldThreshold: 3 }), false)).toBe(true);
|
|
469
470
|
expect(shouldDeferSection(section, 1, mkCfg({ foldThreshold: 3 }), false)).toBe(false);
|
|
470
471
|
});
|
|
472
|
+
|
|
473
|
+
it("`export const deferred = true` defers one section even with respectCmsLazy off", () => {
|
|
474
|
+
const key = "site/sections/HeavyPLP.tsx";
|
|
475
|
+
registerAlwaysDeferSections([key]);
|
|
476
|
+
const section = { __resolveType: key };
|
|
477
|
+
// Not ⚡-wrapped and respectCmsLazy disabled — the per-section flag is the
|
|
478
|
+
// only thing forcing deferral here.
|
|
479
|
+
expect(shouldDeferSection(section, 0, mkCfg({ respectCmsLazy: false }), false)).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("`export const deferred = true` still stays eager for bots (SEO)", () => {
|
|
483
|
+
const key = "site/sections/HeavyPLPBot.tsx";
|
|
484
|
+
registerAlwaysDeferSections([key]);
|
|
485
|
+
const section = { __resolveType: key };
|
|
486
|
+
expect(shouldDeferSection(section, 0, mkCfg({ respectCmsLazy: false }), true)).toBe(false);
|
|
487
|
+
});
|
|
471
488
|
});
|
|
472
489
|
|
|
473
490
|
// ---------------------------------------------------------------------------
|
package/src/cms/resolve.ts
CHANGED
|
@@ -243,6 +243,28 @@ function isNeverDeferSection(key: string): boolean {
|
|
|
243
243
|
return (G.__deco.neverDeferSectionKeys as Set<string> | undefined)?.has(key) ?? false;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Permanent alwaysDefer section registry
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Register sections that declared `export const deferred = true`.
|
|
252
|
+
* These sections are ALWAYS deferred (rendered client-side on scroll),
|
|
253
|
+
* independent of the global `respectCmsLazy` config — the per-section opt-in
|
|
254
|
+
* for async-rendering a single heavy section (e.g. a PLP grid) without
|
|
255
|
+
* turning on deferral site-wide. Bots stay eager via the `isBotReq`
|
|
256
|
+
* short-circuit in {@link shouldDeferSection}.
|
|
257
|
+
*/
|
|
258
|
+
export function registerAlwaysDeferSections(keys: string[]): void {
|
|
259
|
+
G.__deco.alwaysDeferSectionKeys ??= new Set();
|
|
260
|
+
const set: Set<string> = G.__deco.alwaysDeferSectionKeys;
|
|
261
|
+
for (const k of keys) set.add(k);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isAlwaysDeferSection(key: string): boolean {
|
|
265
|
+
return (G.__deco.alwaysDeferSectionKeys as Set<string> | undefined)?.has(key) ?? false;
|
|
266
|
+
}
|
|
267
|
+
|
|
246
268
|
// ---------------------------------------------------------------------------
|
|
247
269
|
// Deferred rawProps cache — keeps rawProps server-side to trim HTML payload
|
|
248
270
|
// ---------------------------------------------------------------------------
|
|
@@ -1497,6 +1519,12 @@ export function shouldDeferSection(
|
|
|
1497
1519
|
const finalKey = resolveFinalSectionKey(section, matcherCtx);
|
|
1498
1520
|
if (!finalKey) return false;
|
|
1499
1521
|
|
|
1522
|
+
// ── PER-SECTION FORCE-DEFER (`export const deferred = true`) ───────────────
|
|
1523
|
+
// Explicit code-level opt-in to defer one section regardless of the global
|
|
1524
|
+
// `respectCmsLazy` config — lets a site async-render a single heavy section
|
|
1525
|
+
// without a site-wide skeleton sweep. Bots already returned eager above.
|
|
1526
|
+
if (isAlwaysDeferSection(finalKey)) return true;
|
|
1527
|
+
|
|
1500
1528
|
// ── ADMIN IS THE SOURCE OF TRUTH ──────────────────────────────────────────
|
|
1501
1529
|
// If the editor marked the section ⚡ (wrapped in CMS Lazy/Deferred at any
|
|
1502
1530
|
// nesting level, including multivariate flags), it is deferred —
|