@decocms/blocks 7.66.7 → 7.67.1
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/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/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,
|