@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.66.7",
3
+ "version": "7.67.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
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,
@@ -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,
@@ -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,