@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.
- package/package.json +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- 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
|
+
});
|