@decocms/blocks 7.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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,547 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock dependencies before importing the module under test
4
+ vi.mock("./sectionLoaders", () => ({
5
+ isLayoutSection: () => false,
6
+ runSingleSectionLoader: vi.fn(async (section: any) => section),
7
+ }));
8
+
9
+ vi.mock("../sdk/normalizeUrls", () => ({
10
+ normalizeUrlsInObject: vi.fn(<T>(x: T) => x),
11
+ }));
12
+
13
+ vi.mock("./loader", () => ({
14
+ findPageByPath: vi.fn(),
15
+ loadBlocks: vi.fn(() => ({})),
16
+ }));
17
+
18
+ vi.mock("./registry", () => ({
19
+ getSection: vi.fn(),
20
+ }));
21
+
22
+ import {
23
+ clearCommerceLoaders,
24
+ DEFAULT_FOLD_THRESHOLD,
25
+ getAsyncRenderingConfig,
26
+ isEagerRequest,
27
+ registerCommerceLoader,
28
+ registerEagerSections,
29
+ registerNeverDeferSections,
30
+ resolveDeferredSectionFull,
31
+ resolvePageSeoBlock,
32
+ resolveSectionsList,
33
+ resolveValue,
34
+ setAsyncRenderingConfig,
35
+ shouldDeferSection,
36
+ WELL_KNOWN_TYPES,
37
+ } from "./resolve";
38
+ import { runSingleSectionLoader } from "./sectionLoaders";
39
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
40
+ import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
41
+
42
+ describe("resolveDeferredSectionFull", () => {
43
+ it("resolves a deferred section and preserves index", async () => {
44
+ const ds: DeferredSection = {
45
+ component: "site/sections/ProductShelf.tsx",
46
+ key: "site/sections/ProductShelf.tsx",
47
+ index: 5,
48
+ propsHash: "test",
49
+ rawProps: { title: "Best Sellers" },
50
+ };
51
+
52
+ const request = new Request("https://store.com/");
53
+
54
+ // resolveDeferredSection depends on ensureInitialized() and CMS internals.
55
+ // Since we can't easily mock the full resolution pipeline, we test that
56
+ // the function composes correctly by verifying it calls the right deps.
57
+ // A full integration test would require a running CMS context.
58
+
59
+ // For now, verify the function signature is correct and types align
60
+ expect(typeof resolveDeferredSectionFull).toBe("function");
61
+ expect(resolveDeferredSectionFull.length).toBe(4); // ds, pagePath, request, matcherCtx?
62
+ });
63
+
64
+ it("runSingleSectionLoader is called with enriched section", async () => {
65
+ // Verify the mock is correctly set up
66
+ const mockSection = {
67
+ component: "test",
68
+ props: { title: "hi" },
69
+ key: "test",
70
+ index: 3,
71
+ };
72
+ const request = new Request("https://store.com/");
73
+
74
+ const result = await (runSingleSectionLoader as any)(mockSection, request);
75
+ expect(result).toEqual(mockSection);
76
+ });
77
+
78
+ it("normalizeUrlsInObject is used for output normalization", () => {
79
+ const input = { url: "https://store.com/image.jpg" };
80
+ const result = (normalizeUrlsInObject as any)(input);
81
+ expect(result).toEqual(input); // mock passes through
82
+ });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // resolveSectionsList — page-level variant wrapper support
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("resolveSectionsList", () => {
90
+ const makeRctx = (matcherCtx = {}) => ({
91
+ routeParams: {},
92
+ matcherCtx,
93
+ memo: new Map(),
94
+ depth: 0,
95
+ });
96
+
97
+ it("returns array as-is when value is already an array", async () => {
98
+ const sections = [{ __resolveType: "section-A" }, { __resolveType: "section-B" }];
99
+ const result = await resolveSectionsList(sections, makeRctx());
100
+ expect(result).toEqual(sections);
101
+ });
102
+
103
+ it("returns empty array for null/undefined/non-object", async () => {
104
+ expect(await resolveSectionsList(null, makeRctx())).toEqual([]);
105
+ expect(await resolveSectionsList(undefined, makeRctx())).toEqual([]);
106
+ expect(await resolveSectionsList("string", makeRctx())).toEqual([]);
107
+ expect(await resolveSectionsList(42, makeRctx())).toEqual([]);
108
+ });
109
+
110
+ it("resolves page-level variant wrapper without __resolveType", async () => {
111
+ // Simulates CMS admin wrapping all sections in a device variant
112
+ // Rule has no __resolveType → evaluateMatcher returns true (match-all)
113
+ const sectionsArray = [
114
+ { __resolveType: "Header - 01" },
115
+ { __resolveType: "site/sections/Account/PersonalData.tsx" },
116
+ { __resolveType: "Footer - 01" },
117
+ ];
118
+
119
+ const variantWrapper = {
120
+ variants: [
121
+ {
122
+ rule: { mobile: true, tablet: true, desktop: true },
123
+ value: sectionsArray,
124
+ },
125
+ ],
126
+ };
127
+
128
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
129
+ expect(result).toEqual(sectionsArray);
130
+ });
131
+
132
+ it("returns empty when no variant matches in page-level wrapper", async () => {
133
+ // All variants have __resolveType in rule → evaluateMatcher returns false
134
+ // (unregistered matcher defaults to false)
135
+ const variantWrapper = {
136
+ variants: [
137
+ {
138
+ rule: { __resolveType: "website/matchers/device.ts", mobile: true },
139
+ value: [{ __resolveType: "MobileOnly" }],
140
+ },
141
+ ],
142
+ };
143
+
144
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
145
+ expect(result).toEqual([]);
146
+ });
147
+
148
+ it("picks first matching variant in page-level wrapper", async () => {
149
+ const desktopSections = [{ __resolveType: "DesktopLayout" }];
150
+ const mobileSections = [{ __resolveType: "MobileLayout" }];
151
+
152
+ const variantWrapper = {
153
+ variants: [
154
+ {
155
+ // No __resolveType → evaluateMatcher returns true (first match wins)
156
+ rule: { desktop: true },
157
+ value: desktopSections,
158
+ },
159
+ {
160
+ rule: { mobile: true },
161
+ value: mobileSections,
162
+ },
163
+ ],
164
+ };
165
+
166
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
167
+ expect(result).toEqual(desktopSections);
168
+ });
169
+
170
+ it("returns empty for object without __resolveType and without variants", async () => {
171
+ const result = await resolveSectionsList({ someKey: "value" }, makeRctx());
172
+ expect(result).toEqual([]);
173
+ });
174
+
175
+ it("respects max depth limit (20)", async () => {
176
+ // Build 21 levels of nested variant wrappers to exceed MAX_RESOLVE_DEPTH=20
177
+ let wrapper: any = [{ __resolveType: "deep" }];
178
+ for (let i = 0; i < 21; i++) {
179
+ wrapper = { variants: [{ rule: {}, value: wrapper }] };
180
+ }
181
+ const result = await resolveSectionsList(wrapper, makeRctx());
182
+ expect(result).toEqual([]);
183
+ });
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Commerce loader auto-injects URL query params as top-level props
188
+ // ---------------------------------------------------------------------------
189
+ //
190
+ // Regression guard for Google Shopping deep links (and any direct entry
191
+ // with `?skuId=…`, `?q=…`, etc.): the apps-start canonical commerce
192
+ // loaders read `props.skuId` to pre-select a variant. The framework
193
+ // injects URL search params into `resolvedProps` at the commerce-loader
194
+ // call site so loaders see them on direct navigation. CMS-configured
195
+ // props win over URL params (URL is a fallback, not an override).
196
+
197
+ describe("commerce loader auto-injects URL search params as props", () => {
198
+ const KEY = "site/loaders/__test/queryInjectLoader";
199
+
200
+ beforeEach(() => {
201
+ clearCommerceLoaders();
202
+ });
203
+
204
+ afterEach(() => {
205
+ clearCommerceLoaders();
206
+ });
207
+
208
+ it("populates props.skuId from ?skuId= when CMS does not set it", async () => {
209
+ const calls: Array<Record<string, unknown>> = [];
210
+ registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
211
+ calls.push({ ...props });
212
+ return null;
213
+ });
214
+
215
+ await resolveValue(
216
+ { __resolveType: KEY, slug: "sabonete" },
217
+ undefined,
218
+ {
219
+ url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
220
+ path: "/produto/sabonete/p",
221
+ },
222
+ );
223
+
224
+ expect(calls).toHaveLength(1);
225
+ expect(calls[0]).toMatchObject({
226
+ slug: "sabonete",
227
+ skuId: "12345",
228
+ size: "M",
229
+ __pagePath: "/produto/sabonete/p",
230
+ __pageUrl: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
231
+ });
232
+ });
233
+
234
+ it("does NOT override a CMS-configured prop with a URL param of the same name", async () => {
235
+ const calls: Array<Record<string, unknown>> = [];
236
+ registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
237
+ calls.push({ ...props });
238
+ return null;
239
+ });
240
+
241
+ await resolveValue(
242
+ { __resolveType: KEY, skuId: "cms-locked-sku" },
243
+ undefined,
244
+ { url: "https://store.com/p?skuId=url-value", path: "/p" },
245
+ );
246
+
247
+ expect(calls[0]?.skuId).toBe("cms-locked-sku");
248
+ });
249
+
250
+ it("decodes URL-encoded values", async () => {
251
+ const calls: Array<Record<string, unknown>> = [];
252
+ registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
253
+ calls.push({ ...props });
254
+ return null;
255
+ });
256
+
257
+ await resolveValue({ __resolveType: KEY }, undefined, {
258
+ url: "https://store.com/?q=preto%20azul",
259
+ path: "/",
260
+ });
261
+
262
+ expect(calls[0]?.q).toBe("preto azul");
263
+ });
264
+
265
+ it("is a no-op when matcherCtx.url is missing", async () => {
266
+ const calls: Array<Record<string, unknown>> = [];
267
+ registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
268
+ calls.push({ ...props });
269
+ return null;
270
+ });
271
+
272
+ await resolveValue({ __resolveType: KEY, slug: "abc" }, undefined, {});
273
+
274
+ expect(calls[0]).toEqual({ slug: "abc" });
275
+ expect(calls[0]?.__pageUrl).toBeUndefined();
276
+ });
277
+
278
+ it("warns and skips injection when matcherCtx.url is malformed", async () => {
279
+ const calls: Array<Record<string, unknown>> = [];
280
+ registerCommerceLoader(KEY, async (props: Record<string, unknown>) => {
281
+ calls.push({ ...props });
282
+ return null;
283
+ });
284
+
285
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
286
+ try {
287
+ await expect(
288
+ resolveValue({ __resolveType: KEY, slug: "abc" }, undefined, {
289
+ url: "not a url",
290
+ path: "/",
291
+ }),
292
+ ).resolves.not.toThrow();
293
+
294
+ // Loader still ran with __pageUrl set, but no query params were
295
+ // injected and the warning surfaced the upstream bug.
296
+ expect(calls[0]).toMatchObject({ slug: "abc", __pageUrl: "not a url" });
297
+ expect(Object.keys(calls[0] ?? {}).sort()).toEqual(
298
+ ["__pagePath", "__pageUrl", "slug"].sort(),
299
+ );
300
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
301
+ expect(warnings.some((w) => w.includes("malformed matcherCtx.url"))).toBe(true);
302
+ expect(warnings.some((w) => w.includes(KEY))).toBe(true);
303
+ } finally {
304
+ warnSpy.mockRestore();
305
+ }
306
+ });
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Async rendering: the admin (CMS Lazy ⚡ toggle) is the source of truth
311
+ // ---------------------------------------------------------------------------
312
+ //
313
+ // Regression guard for issue #266: the framework must NOT defer a section by
314
+ // position, and must NOT let code-level flags (`export const eager/neverDefer`)
315
+ // override the editor's ⚡ choice. A section is deferred iff the editor wrapped
316
+ // it in CMS Lazy/Deferred in the admin. The position threshold + code flags are
317
+ // an explicit per-site opt-in that is OFF by default (foldThreshold = Infinity)
318
+ // and never overrides the admin.
319
+
320
+ describe("async rendering config defaults", () => {
321
+ beforeEach(() => {
322
+ // Reset the globalThis-backed config so each assertion is order-independent.
323
+ (globalThis as any).__deco.asyncConfig = null;
324
+ });
325
+
326
+ it("DEFAULT_FOLD_THRESHOLD is Infinity (position-based deferral off)", () => {
327
+ expect(DEFAULT_FOLD_THRESHOLD).toBe(Infinity);
328
+ });
329
+
330
+ it("setAsyncRenderingConfig() defaults to foldThreshold=Infinity, respectCmsLazy=true", () => {
331
+ setAsyncRenderingConfig();
332
+ const cfg = getAsyncRenderingConfig();
333
+ expect(cfg).not.toBeNull();
334
+ expect(cfg!.foldThreshold).toBe(Infinity);
335
+ expect(cfg!.respectCmsLazy).toBe(true);
336
+ expect(cfg!.botAwareSeo).toBe(false); // opt-in — off by default
337
+ });
338
+
339
+ it("preserves an explicit finite foldThreshold (opt-in)", () => {
340
+ setAsyncRenderingConfig({ foldThreshold: 3 });
341
+ expect(getAsyncRenderingConfig()!.foldThreshold).toBe(3);
342
+ });
343
+ });
344
+
345
+ describe("shouldDeferSection — admin is the source of truth", () => {
346
+ const mkCfg = (over: Partial<AsyncRenderingConfig> = {}): AsyncRenderingConfig => ({
347
+ respectCmsLazy: true,
348
+ foldThreshold: Infinity,
349
+ alwaysEager: new Set(),
350
+ botAwareSeo: false,
351
+ ...over,
352
+ });
353
+
354
+ const lazyWrap = (inner: object) => ({
355
+ __resolveType: WELL_KNOWN_TYPES.LAZY,
356
+ section: inner,
357
+ });
358
+
359
+ it("defers a section the editor marked ⚡ (wrapped in CMS Lazy)", () => {
360
+ const section = lazyWrap({ __resolveType: "site/sections/Hero.tsx" });
361
+ expect(shouldDeferSection(section, 0, mkCfg(), false)).toBe(true);
362
+ });
363
+
364
+ it("renders a non-⚡ section eagerly regardless of position (default Infinity)", () => {
365
+ const section = { __resolveType: "site/sections/SeoText.tsx" };
366
+ // Position 5 used to auto-defer with the old foldThreshold=3 — now SSR.
367
+ expect(shouldDeferSection(section, 5, mkCfg(), false)).toBe(false);
368
+ });
369
+
370
+ it("admin ⚡ overrides `export const eager` (code flag ignored)", () => {
371
+ const key = "site/sections/EagerButLazy.tsx";
372
+ registerEagerSections([key]);
373
+ const section = lazyWrap({ __resolveType: key });
374
+ // Even with a finite threshold where the eager flag would otherwise apply,
375
+ // the editor's ⚡ wins → deferred.
376
+ expect(shouldDeferSection(section, 0, mkCfg({ foldThreshold: 3 }), false)).toBe(true);
377
+ });
378
+
379
+ it("admin ⚡ overrides `export const neverDefer` (code flag ignored)", () => {
380
+ const key = "site/sections/NeverDeferButLazy.tsx";
381
+ registerNeverDeferSections([key]);
382
+ const section = lazyWrap({ __resolveType: key });
383
+ expect(shouldDeferSection(section, 0, mkCfg(), false)).toBe(true);
384
+ });
385
+
386
+ it("bots always get SSR, even for ⚡ sections (SEO)", () => {
387
+ const section = lazyWrap({ __resolveType: "site/sections/Hero.tsx" });
388
+ expect(shouldDeferSection(section, 0, mkCfg(), true)).toBe(false);
389
+ });
390
+
391
+ it("opt-in finite foldThreshold defers UNMARKED sections by position", () => {
392
+ const section = { __resolveType: "site/sections/Shelf.tsx" };
393
+ expect(shouldDeferSection(section, 5, mkCfg({ foldThreshold: 3 }), false)).toBe(true);
394
+ expect(shouldDeferSection(section, 1, mkCfg({ foldThreshold: 3 }), false)).toBe(false);
395
+ });
396
+ });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // isEagerRequest — programmatic (non-navigation) fetches render eagerly
400
+ // ---------------------------------------------------------------------------
401
+ //
402
+ // An AJAX `fetch()` (e.g. the PLP "Ver mais"/load-more pagination) reads the
403
+ // static SSR HTML and never runs the client-side deferred-section resolution,
404
+ // so a ⚡ deferred section would be invisible (skeleton only). Such requests —
405
+ // identified by `Sec-Fetch-Dest: empty` — must render eagerly. Top-level browser
406
+ // navigations (`document`) stay deferred; SPA navigations are excluded so
407
+ // page-SEO commerce loaders stay off for humans (#286).
408
+ describe("isEagerRequest — programmatic fetch detection", () => {
409
+ const HUMAN_UA =
410
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
411
+ const reqWith = (dest: string) =>
412
+ new Request("https://store.com/escolar?page=2", {
413
+ headers: { "user-agent": HUMAN_UA, "sec-fetch-dest": dest },
414
+ });
415
+
416
+ it("programmatic fetch (Sec-Fetch-Dest: empty) is eager", () => {
417
+ expect(isEagerRequest({ userAgent: HUMAN_UA, request: reqWith("empty") })).toBe(true);
418
+ });
419
+
420
+ it("top-level navigation (Sec-Fetch-Dest: document) is NOT eager", () => {
421
+ expect(isEagerRequest({ userAgent: HUMAN_UA, request: reqWith("document") })).toBe(false);
422
+ });
423
+
424
+ it("SPA navigation (empty + isClientNavigation) is NOT eager (preserves #286)", () => {
425
+ expect(
426
+ isEagerRequest({
427
+ userAgent: HUMAN_UA,
428
+ request: reqWith("empty"),
429
+ isClientNavigation: true,
430
+ }),
431
+ ).toBe(false);
432
+ });
433
+
434
+ it("falls back to matcherCtx.headers when no Request is present", () => {
435
+ expect(
436
+ isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } }),
437
+ ).toBe(true);
438
+ });
439
+
440
+ it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => {
441
+ expect(isEagerRequest({ userAgent: HUMAN_UA, url: "https://store.com/escolar" })).toBe(false);
442
+ });
443
+ });
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Page SEO block — bot-aware commerce resolution
447
+ // ---------------------------------------------------------------------------
448
+ //
449
+ // The page-level SEO block (e.g. `commerce/sections/Seo/SeoPLPV2.tsx` with
450
+ // `jsonLD: { __resolveType: "PLP Loader" }`) is always eager. For humans the
451
+ // commerce-loader-backed props must be skipped: resolving them blocks SSR on a
452
+ // heavy upstream fetch and serializes the full product payload into HTML that a
453
+ // human request never renders. Bots keep the full resolution for indexing.
454
+ describe("resolvePageSeoBlock — bot-aware commerce SEO", () => {
455
+ const KEY = "site/loaders/__test/plpSeoLoader";
456
+ const HUMAN_UA =
457
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
458
+ const BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
459
+
460
+ const seoBlock = {
461
+ __resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
462
+ title: "Escolar",
463
+ jsonLD: { __resolveType: KEY },
464
+ };
465
+
466
+ const rctx = (userAgent?: string) =>
467
+ ({
468
+ matcherCtx: { userAgent, url: "https://store.com/escolar", path: "/escolar" },
469
+ memo: new Map(),
470
+ depth: 0,
471
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
472
+ }) as any;
473
+
474
+ beforeEach(() => {
475
+ clearCommerceLoaders();
476
+ // Bot-aware SEO is opt-in — enable it for these cases.
477
+ setAsyncRenderingConfig({ botAwareSeo: true });
478
+ });
479
+ afterEach(() => {
480
+ clearCommerceLoaders();
481
+ setAsyncRenderingConfig({ botAwareSeo: false });
482
+ });
483
+
484
+ it("flag OFF (default): humans still get the full SEO — no regression", async () => {
485
+ setAsyncRenderingConfig({ botAwareSeo: false });
486
+ let calls = 0;
487
+ registerCommerceLoader(KEY, async () => {
488
+ calls++;
489
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
490
+ });
491
+
492
+ const res = await resolvePageSeoBlock(seoBlock, rctx(HUMAN_UA));
493
+
494
+ expect(calls).toBe(1); // loader runs for everyone when the flag is off
495
+ expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
496
+ });
497
+
498
+ it("humans: skips the commerce loader and drops the commerce-backed prop", async () => {
499
+ let calls = 0;
500
+ registerCommerceLoader(KEY, async () => {
501
+ calls++;
502
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
503
+ });
504
+
505
+ const res = await resolvePageSeoBlock(seoBlock, rctx(HUMAN_UA));
506
+
507
+ expect(calls).toBe(0); // heavy upstream fetch never runs for humans
508
+ expect(res?.props).toHaveProperty("title", "Escolar"); // literal props kept
509
+ expect(res?.props).not.toHaveProperty("jsonLD"); // no product payload in HTML
510
+ });
511
+
512
+ it("bots: resolves the commerce loader and keeps the JSON-LD payload", async () => {
513
+ let calls = 0;
514
+ registerCommerceLoader(KEY, async () => {
515
+ calls++;
516
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
517
+ });
518
+
519
+ const res = await resolvePageSeoBlock(seoBlock, rctx(BOT_UA));
520
+
521
+ expect(calls).toBe(1);
522
+ expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
523
+ });
524
+
525
+ it("?__deco_ssr=1 override: a human UA gets the full eager SEO (QA/audit)", async () => {
526
+ let calls = 0;
527
+ registerCommerceLoader(KEY, async () => {
528
+ calls++;
529
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
530
+ });
531
+
532
+ const ctx = {
533
+ matcherCtx: {
534
+ userAgent: HUMAN_UA,
535
+ url: "https://store.com/escolar?__deco_ssr=1",
536
+ path: "/escolar",
537
+ },
538
+ memo: new Map(),
539
+ depth: 0,
540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
+ } as any;
542
+ const res = await resolvePageSeoBlock(seoBlock, ctx);
543
+
544
+ expect(calls).toBe(1); // override forces the commerce loader to run
545
+ expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
546
+ });
547
+ });