@decocms/blocks 7.66.6 → 7.67.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 +1 -1
- package/src/cms/client.ts +5 -0
- package/src/cms/deferredTrigger.test.ts +32 -0
- package/src/cms/deferredTrigger.ts +58 -0
- package/src/cms/index.ts +2 -0
- package/src/cms/loader.test.ts +73 -0
- package/src/cms/loader.ts +31 -1
- package/src/cms/resolve.test.ts +14 -0
- package/src/cms/resolve.ts +32 -0
package/package.json
CHANGED
package/src/cms/client.ts
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* `sdk/requestContextStorage.browser.ts`), so it's already safe for a
|
|
26
26
|
* browser bundle.
|
|
27
27
|
* - `schema.ts` has no imports at all.
|
|
28
|
+
* - `deferredTrigger.ts` has no imports at all — it only reads the
|
|
29
|
+
* `globalThis.__deco.asyncConfig` bag `resolve.ts` writes, which is exactly
|
|
30
|
+
* why it is a standalone module instead of living in `resolve.ts`.
|
|
28
31
|
*
|
|
29
32
|
* Deliberately NOT re-exported here: `loader.ts`, `resolve.ts`,
|
|
30
33
|
* `sectionLoaders.ts`, `loadDecofileDirectory.ts`, `blockSource.ts`, and
|
|
@@ -33,6 +36,8 @@
|
|
|
33
36
|
* storage concerns that only make sense server-side — import them from
|
|
34
37
|
* `@decocms/blocks/cms` instead.
|
|
35
38
|
*/
|
|
39
|
+
export type { DeferredTrigger } from "./deferredTrigger";
|
|
40
|
+
export { DEFAULT_DEFERRED_TRIGGER, getDeferredTrigger } from "./deferredTrigger";
|
|
36
41
|
export type { OnBeforeResolveProps, SectionModule, SectionOptions } from "./registry";
|
|
37
42
|
export {
|
|
38
43
|
getResolvedComponent,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_DEFERRED_TRIGGER, getDeferredTrigger } from "./deferredTrigger";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `getDeferredTrigger()` is the client-side half of `deferredTrigger`. It must
|
|
7
|
+
* read the same `globalThis.__deco.asyncConfig` bag `setAsyncRenderingConfig()`
|
|
8
|
+
* writes WITHOUT importing `resolve.ts` — that module pulls `node:async_hooks`
|
|
9
|
+
* and `node:fs/promises`, which a browser bundle cannot take (see the header of
|
|
10
|
+
* `cms/client.ts`). So this file imports only the standalone module, and sets
|
|
11
|
+
* the global by hand.
|
|
12
|
+
*/
|
|
13
|
+
describe("getDeferredTrigger", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
delete (globalThis as any).__deco;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to intersection when setAsyncRenderingConfig() never ran", () => {
|
|
19
|
+
expect(getDeferredTrigger()).toBe("intersection");
|
|
20
|
+
expect(DEFAULT_DEFERRED_TRIGGER).toBe("intersection");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to intersection when the config exists but omits the option", () => {
|
|
24
|
+
(globalThis as any).__deco = { asyncConfig: { respectCmsLazy: true } };
|
|
25
|
+
expect(getDeferredTrigger()).toBe("intersection");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("reads load off the shared global", () => {
|
|
29
|
+
(globalThis as any).__deco = { asyncConfig: { deferredTrigger: "load" } };
|
|
30
|
+
expect(getDeferredTrigger()).toBe("load");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe reader for the deferred-section trigger mode.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module — with zero imports — so a browser bundle can reach
|
|
5
|
+
* it without dragging in `resolve.ts` (and its `node:async_hooks` /
|
|
6
|
+
* `node:fs/promises` chain). Same reasoning as `cms/client.ts`; see the header
|
|
7
|
+
* comment there.
|
|
8
|
+
*
|
|
9
|
+
* The value itself is written by `setAsyncRenderingConfig()` in `resolve.ts`,
|
|
10
|
+
* into the same `globalThis.__deco.asyncConfig` bag the server reads.
|
|
11
|
+
*
|
|
12
|
+
* WHERE THE SETTING HAS TO RUN: this is read in the browser, so
|
|
13
|
+
* `setAsyncRenderingConfig()` only takes effect if it runs in a module the
|
|
14
|
+
* client bundle also loads — normally `setup.ts`, imported from `router.tsx`.
|
|
15
|
+
* Set it from server-only code (the worker entry, `server.ts`) and the server
|
|
16
|
+
* defers the section as expected while the client silently falls back to
|
|
17
|
+
* `"intersection"`, which looks exactly like the option being ignored.
|
|
18
|
+
*
|
|
19
|
+
* WHO READS IT: only `@decocms/tanstack` (`DeferredSectionWrapper` in
|
|
20
|
+
* `hooks/DecoPageRenderer.tsx`). It lives here because that is where
|
|
21
|
+
* `AsyncRenderingConfig` lives; in `@decocms/nextjs` it is a no-op.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How a deferred (⚡) section decides it's time to fetch its real markup.
|
|
26
|
+
*
|
|
27
|
+
* - `"intersection"` — wait until the skeleton is within 300px of the viewport.
|
|
28
|
+
* Cheapest, and the default.
|
|
29
|
+
* - `"load"` — fetch as soon as the wrapper mounts, without waiting for scroll.
|
|
30
|
+
* This is what Deco on Fresh does through `DispatchAsyncRender`'s
|
|
31
|
+
* `partialTriggerMode: "load"`: the page ships a light skeleton HTML and then
|
|
32
|
+
* materializes every deferred section right after hydration. Sites migrating
|
|
33
|
+
* from Fresh need this to keep parity — under `"intersection"` alone, content
|
|
34
|
+
* below the fold does not exist until the user scrolls, which loses
|
|
35
|
+
* below-the-fold analytics impressions and leaves the document short.
|
|
36
|
+
*
|
|
37
|
+
* The tradeoff of `"load"` is a request burst: every deferred section fires its
|
|
38
|
+
* serverFn POST in the same commit, on first load and again on every SPA
|
|
39
|
+
* navigation. That is precisely what the rAF + observer path exists to avoid,
|
|
40
|
+
* and it is also exactly what Fresh does — so it is the right default for a
|
|
41
|
+
* migrated site and the wrong one for a page with many heavy deferred sections.
|
|
42
|
+
* The switch is site-wide; a per-section override (honouring the `loading` prop
|
|
43
|
+
* the CMS `Lazy.tsx` wrapper already carries, which the resolver currently
|
|
44
|
+
* discards) would be the finer-grained successor.
|
|
45
|
+
*/
|
|
46
|
+
export type DeferredTrigger = "intersection" | "load";
|
|
47
|
+
|
|
48
|
+
export const DEFAULT_DEFERRED_TRIGGER: DeferredTrigger = "intersection";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the configured trigger. Falls back to `"intersection"` when
|
|
52
|
+
* `setAsyncRenderingConfig()` was never called or omitted the option, so
|
|
53
|
+
* existing sites are not regressed by a framework bump.
|
|
54
|
+
*/
|
|
55
|
+
export function getDeferredTrigger(): DeferredTrigger {
|
|
56
|
+
const config = (globalThis as any).__deco?.asyncConfig;
|
|
57
|
+
return config?.deferredTrigger ?? DEFAULT_DEFERRED_TRIGGER;
|
|
58
|
+
}
|
package/src/cms/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ export {
|
|
|
15
15
|
revisionKey,
|
|
16
16
|
snapshotKey,
|
|
17
17
|
} from "./blockSource";
|
|
18
|
+
export type { DeferredTrigger } from "./deferredTrigger";
|
|
19
|
+
export { DEFAULT_DEFERRED_TRIGGER, getDeferredTrigger } from "./deferredTrigger";
|
|
18
20
|
export type {
|
|
19
21
|
DraftPointer,
|
|
20
22
|
ResolveDraftForRequestOptions,
|
package/src/cms/loader.test.ts
CHANGED
|
@@ -222,6 +222,79 @@ describe("findPageByPath specificity", () => {
|
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
describe("getAllPages — page blocks without the `pages-` prefix", () => {
|
|
226
|
+
afterEach(() => {
|
|
227
|
+
setBlocks({});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("routes a page block whose key lacks the prefix", () => {
|
|
231
|
+
// Decofiles carried over from Deco on Fresh hold page blocks under
|
|
232
|
+
// arbitrary keys: renaming a page in the CMS renames its snapshot file,
|
|
233
|
+
// and the key follows the filename. Fresh enumerated pages by
|
|
234
|
+
// __resolveType, so these routed fine there.
|
|
235
|
+
setBlocks({
|
|
236
|
+
"plp-verao": {
|
|
237
|
+
name: "Verão",
|
|
238
|
+
path: "/verao",
|
|
239
|
+
sections: [],
|
|
240
|
+
__resolveType: "website/pages/Page.tsx",
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const match = findPageByPath("/verao");
|
|
245
|
+
expect(match?.blockKey).toBe("plp-verao");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("still ranks by path specificity across both key shapes", () => {
|
|
249
|
+
setBlocks({
|
|
250
|
+
"pages-catch-all": {
|
|
251
|
+
name: "Catch all",
|
|
252
|
+
path: "/*",
|
|
253
|
+
sections: [],
|
|
254
|
+
},
|
|
255
|
+
"plp-inverno": {
|
|
256
|
+
name: "Inverno",
|
|
257
|
+
path: "/inverno",
|
|
258
|
+
sections: [],
|
|
259
|
+
__resolveType: "website/pages/Page.tsx",
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// The literal must win over the catch-all regardless of which one
|
|
264
|
+
// carries the prefix.
|
|
265
|
+
expect(findPageByPath("/inverno")?.blockKey).toBe("plp-inverno");
|
|
266
|
+
expect(findPageByPath("/qualquer-outra")?.blockKey).toBe("pages-catch-all");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("ignores non-page blocks that happen to carry a path", () => {
|
|
270
|
+
// A loader block is not a page: no __resolveType match, no prefix.
|
|
271
|
+
setBlocks({
|
|
272
|
+
"some-loader": {
|
|
273
|
+
name: "Not a page",
|
|
274
|
+
path: "/not-a-page",
|
|
275
|
+
sections: [],
|
|
276
|
+
__resolveType: "site/loaders/whatever.ts",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(findPageByPath("/not-a-page")).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("keeps honouring the prefix when there is no __resolveType", () => {
|
|
284
|
+
// The admin emits prefixed keys and does not always write a
|
|
285
|
+
// __resolveType onto the snapshot — the fast path must still apply.
|
|
286
|
+
setBlocks({
|
|
287
|
+
"pages-sem-tipo": {
|
|
288
|
+
name: "Sem tipo",
|
|
289
|
+
path: "/sem-tipo",
|
|
290
|
+
sections: [],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(findPageByPath("/sem-tipo")?.blockKey).toBe("pages-sem-tipo");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
225
298
|
describe("loadBlocks draft override — key percent-encoding", () => {
|
|
226
299
|
// The published decofile encodes special characters in block keys
|
|
227
300
|
// (`pages-Home%20(principal)-1`); the Studio draft emits them raw
|
package/src/cms/loader.ts
CHANGED
|
@@ -282,6 +282,36 @@ function pathSpecificityKey(path: string): [number, number, number] {
|
|
|
282
282
|
return [hasWildcard ? 0 : 1, literals, params];
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// Literal rather than WELL_KNOWN_TYPES.PAGE: resolve.ts already imports from
|
|
286
|
+
// this module, so importing back would close a cycle.
|
|
287
|
+
const PAGE_RESOLVE_TYPE = "website/pages/Page.tsx";
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Is this block a page?
|
|
291
|
+
*
|
|
292
|
+
* The `pages-` prefix is kept as a fast path because it is what the admin
|
|
293
|
+
* emits and it short-circuits the vast majority of blocks without a property
|
|
294
|
+
* read. But it cannot be the *only* test: a decofile carried over from Deco on
|
|
295
|
+
* Fresh can hold page blocks under any key — renaming one in the CMS renames
|
|
296
|
+
* its snapshot file, and `loadDecofileDirectory` preserves whatever key the
|
|
297
|
+
* filename already had.
|
|
298
|
+
*
|
|
299
|
+
* Those blocks are pages in every way that matters (`website/pages/Page.tsx`,
|
|
300
|
+
* a `path`, `sections`), and on Fresh they routed fine: `website/loaders/pages.ts`
|
|
301
|
+
* enumerated by `__resolveType`, not by key. Filtering on the prefix alone makes
|
|
302
|
+
* them invisible, so every one of their URLs falls through to whatever catch-all
|
|
303
|
+
* the site has — usually a 404 — and they disappear from the CMS sitemap too.
|
|
304
|
+
* One storefront in this state has 205 such blocks against 107 prefixed ones.
|
|
305
|
+
*/
|
|
306
|
+
function isPageBlock(key: string, block: unknown): boolean {
|
|
307
|
+
if (key.startsWith("pages-")) return true;
|
|
308
|
+
return (
|
|
309
|
+
typeof block === "object" &&
|
|
310
|
+
block !== null &&
|
|
311
|
+
(block as Resolvable).__resolveType === PAGE_RESOLVE_TYPE
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
285
315
|
export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
286
316
|
const blocks = loadBlocks();
|
|
287
317
|
const pages: Array<{
|
|
@@ -291,7 +321,7 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
|
291
321
|
}> = [];
|
|
292
322
|
|
|
293
323
|
for (const [key, block] of Object.entries(blocks)) {
|
|
294
|
-
if (!key
|
|
324
|
+
if (!isPageBlock(key, block)) continue;
|
|
295
325
|
const page = block as DecoPage;
|
|
296
326
|
if (!page.sections) continue;
|
|
297
327
|
if (!page.path) continue;
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -460,6 +460,17 @@ describe("async rendering config defaults", () => {
|
|
|
460
460
|
expect(cfg!.foldThreshold).toBe(Infinity);
|
|
461
461
|
expect(cfg!.respectCmsLazy).toBe(true);
|
|
462
462
|
expect(cfg!.botAwareSeo).toBe(false); // opt-in — off by default
|
|
463
|
+
expect(cfg!.deferredTrigger).toBe("intersection"); // opt-in — a bump changes nothing
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("carries deferredTrigger through, and a later partial call does not reset it", () => {
|
|
467
|
+
setAsyncRenderingConfig({ deferredTrigger: "load" });
|
|
468
|
+
expect(getAsyncRenderingConfig()!.deferredTrigger).toBe("load");
|
|
469
|
+
|
|
470
|
+
// applySectionConventions() re-calls this with only `alwaysEager` after the
|
|
471
|
+
// site's setup.ts ran — the trigger must survive that merge.
|
|
472
|
+
setAsyncRenderingConfig({ alwaysEager: ["site/sections/Header.tsx"] });
|
|
473
|
+
expect(getAsyncRenderingConfig()!.deferredTrigger).toBe("load");
|
|
463
474
|
});
|
|
464
475
|
|
|
465
476
|
it("preserves an explicit finite foldThreshold (opt-in)", () => {
|
|
@@ -469,6 +480,9 @@ describe("async rendering config defaults", () => {
|
|
|
469
480
|
});
|
|
470
481
|
|
|
471
482
|
describe("shouldDeferSection — admin is the source of truth", () => {
|
|
483
|
+
// Deliberately does NOT set `deferredTrigger`: this literal stands in for the
|
|
484
|
+
// ones outside the package, and it has to keep compiling after the field was
|
|
485
|
+
// added. If that ever breaks, the option stopped being backwards compatible.
|
|
472
486
|
const mkCfg = (over: Partial<AsyncRenderingConfig> = {}): AsyncRenderingConfig => ({
|
|
473
487
|
respectCmsLazy: true,
|
|
474
488
|
foldThreshold: Infinity,
|
package/src/cms/resolve.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } fro
|
|
|
8
8
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
9
9
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
10
10
|
import { stripTrackingParams } from "../sdk/urlUtils";
|
|
11
|
+
import { DEFAULT_DEFERRED_TRIGGER, type DeferredTrigger } from "./deferredTrigger";
|
|
11
12
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
12
13
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
13
14
|
import {
|
|
@@ -113,6 +114,34 @@ export interface AsyncRenderingConfig {
|
|
|
113
114
|
* @default true
|
|
114
115
|
*/
|
|
115
116
|
respectCmsLazy: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* When a deferred (⚡) section fetches its real markup on the client.
|
|
119
|
+
*
|
|
120
|
+
* `"intersection"` (default) waits for the skeleton to come within 300px of
|
|
121
|
+
* the viewport. `"load"` fetches as soon as the wrapper mounts — the
|
|
122
|
+
* behaviour Deco on Fresh gets from `DispatchAsyncRender`'s
|
|
123
|
+
* `partialTriggerMode: "load"`, where the page ships a light skeleton and
|
|
124
|
+
* then materializes every deferred section right after hydration.
|
|
125
|
+
*
|
|
126
|
+
* Sites migrating from Fresh generally want `"load"`: under `"intersection"`
|
|
127
|
+
* alone, everything below the fold does not exist until the user scrolls, so
|
|
128
|
+
* the document stays short and below-the-fold analytics impressions are lost.
|
|
129
|
+
* The cost is a request burst — every deferred section POSTs in the same
|
|
130
|
+
* commit, on load and on each SPA navigation.
|
|
131
|
+
*
|
|
132
|
+
* Read on the CLIENT, so it must be set from a module the browser bundle also
|
|
133
|
+
* loads (normally `setup.ts`, imported from `router.tsx`). Setting it from
|
|
134
|
+
* server-only code leaves the client on `"intersection"` with no warning.
|
|
135
|
+
* Only `@decocms/tanstack` reads it; a no-op in `@decocms/nextjs`.
|
|
136
|
+
*
|
|
137
|
+
* Optional on purpose. `setAsyncRenderingConfig()` always fills it, so the
|
|
138
|
+
* stored config never actually lacks it — but this interface is exported from
|
|
139
|
+
* `@decocms/blocks/cms`, and a required field would break the typecheck of
|
|
140
|
+
* anyone outside the package who builds an `AsyncRenderingConfig` literal.
|
|
141
|
+
* The only sanctioned reader, `getDeferredTrigger()`, already defaults.
|
|
142
|
+
* @default "intersection"
|
|
143
|
+
*/
|
|
144
|
+
deferredTrigger?: DeferredTrigger;
|
|
116
145
|
/**
|
|
117
146
|
* Fold threshold: sections at or above this flat index are DEFERRED
|
|
118
147
|
* (rendered as a skeleton and loaded on scroll), so their resolved props are
|
|
@@ -193,12 +222,15 @@ export function setAsyncRenderingConfig(config?: {
|
|
|
193
222
|
foldThreshold?: number;
|
|
194
223
|
alwaysEager?: string[];
|
|
195
224
|
respectCmsLazy?: boolean;
|
|
225
|
+
deferredTrigger?: DeferredTrigger;
|
|
196
226
|
botAwareSeo?: boolean;
|
|
197
227
|
}): void {
|
|
198
228
|
const existing = getAsyncConfig();
|
|
199
229
|
const merged = new Set([...(existing?.alwaysEager ?? []), ...(config?.alwaysEager ?? [])]);
|
|
200
230
|
G.__deco.asyncConfig = {
|
|
201
231
|
respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
|
|
232
|
+
deferredTrigger:
|
|
233
|
+
config?.deferredTrigger ?? existing?.deferredTrigger ?? DEFAULT_DEFERRED_TRIGGER,
|
|
202
234
|
foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? DEFAULT_FOLD_THRESHOLD,
|
|
203
235
|
alwaysEager: merged,
|
|
204
236
|
botAwareSeo: config?.botAwareSeo ?? existing?.botAwareSeo ?? false,
|