@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,409 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { configureTracer, type Span } from "../sdk/observability";
|
|
3
|
+
import type { ResolvedSection } from "./resolve";
|
|
4
|
+
import {
|
|
5
|
+
isLayoutSection,
|
|
6
|
+
registerCacheableSections,
|
|
7
|
+
registerLayoutSections,
|
|
8
|
+
registerSectionLoader,
|
|
9
|
+
registerSectionLoaders,
|
|
10
|
+
runSectionLoaders,
|
|
11
|
+
runSingleSectionLoader,
|
|
12
|
+
unregisterLayoutSections,
|
|
13
|
+
} from "./sectionLoaders";
|
|
14
|
+
import { compose, withDevice, withMobile, withSearchParam } from "./sectionMixins";
|
|
15
|
+
|
|
16
|
+
const G = globalThis as any;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
G.__deco.sectionLoaderRegistry.clear();
|
|
20
|
+
G.__deco.layoutSections.clear();
|
|
21
|
+
G.__deco.cacheableSections.clear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
|
|
25
|
+
component,
|
|
26
|
+
props,
|
|
27
|
+
key: component,
|
|
28
|
+
index: 0,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("runSingleSectionLoader — page context injection", () => {
|
|
32
|
+
it("injects __pageUrl and __pagePath into loader props", async () => {
|
|
33
|
+
// Two-arg signature so loader.mock.calls[0] types as [props, req].
|
|
34
|
+
const loader = vi.fn(async (props: Record<string, unknown>, _req: Request) => props);
|
|
35
|
+
registerSectionLoader("site/sections/SearchBanner.tsx", loader);
|
|
36
|
+
|
|
37
|
+
const section = makeSection("site/sections/SearchBanner.tsx", { foo: "bar" });
|
|
38
|
+
const request = new Request("https://store.com/lingerie?q=preto");
|
|
39
|
+
|
|
40
|
+
await runSingleSectionLoader(section, request);
|
|
41
|
+
|
|
42
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
43
|
+
const [calledProps, calledReq] = loader.mock.calls[0];
|
|
44
|
+
expect(calledProps).toMatchObject({
|
|
45
|
+
foo: "bar",
|
|
46
|
+
__pageUrl: "https://store.com/lingerie?q=preto",
|
|
47
|
+
__pagePath: "/lingerie",
|
|
48
|
+
});
|
|
49
|
+
expect(calledReq).toBe(request);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("preserves existing __pageUrl / __pagePath from props (site workaround compat)", async () => {
|
|
53
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
54
|
+
registerSectionLoader("site/sections/Custom.tsx", loader);
|
|
55
|
+
|
|
56
|
+
const section = makeSection("site/sections/Custom.tsx", {
|
|
57
|
+
__pageUrl: "https://override.example/page",
|
|
58
|
+
__pagePath: "/override",
|
|
59
|
+
});
|
|
60
|
+
const request = new Request("https://store.com/real-path");
|
|
61
|
+
|
|
62
|
+
await runSingleSectionLoader(section, request);
|
|
63
|
+
|
|
64
|
+
const [calledProps] = loader.mock.calls[0];
|
|
65
|
+
expect(calledProps.__pageUrl).toBe("https://override.example/page");
|
|
66
|
+
expect(calledProps.__pagePath).toBe("/override");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not throw when request.url is invalid", async () => {
|
|
70
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
71
|
+
registerSectionLoader("site/sections/X.tsx", loader);
|
|
72
|
+
|
|
73
|
+
const section = makeSection("site/sections/X.tsx", { foo: 1 });
|
|
74
|
+
const badReq = { url: "not a url" } as unknown as Request;
|
|
75
|
+
|
|
76
|
+
await expect(runSingleSectionLoader(section, badReq)).resolves.toBeDefined();
|
|
77
|
+
expect(loader).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns section unchanged when no loader is registered", async () => {
|
|
81
|
+
const section = makeSection("site/sections/NoLoader.tsx", { foo: 1 });
|
|
82
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
83
|
+
expect(result).toBe(section);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("runSingleSectionLoader — cache keying", () => {
|
|
88
|
+
it("cacheable sections share a cache entry across different page URLs", async () => {
|
|
89
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => ({
|
|
90
|
+
...props,
|
|
91
|
+
enriched: true,
|
|
92
|
+
}));
|
|
93
|
+
registerSectionLoader("site/sections/Shelf.tsx", loader);
|
|
94
|
+
registerCacheableSections({ "site/sections/Shelf.tsx": { maxAge: 60_000 } });
|
|
95
|
+
|
|
96
|
+
const section = makeSection("site/sections/Shelf.tsx", { title: "Best" });
|
|
97
|
+
|
|
98
|
+
await runSingleSectionLoader(section, new Request("https://store.com/page-a"));
|
|
99
|
+
await runSingleSectionLoader(section, new Request("https://store.com/page-b"));
|
|
100
|
+
await runSingleSectionLoader(section, new Request("https://store.com/page-c"));
|
|
101
|
+
|
|
102
|
+
// Without URL-agnostic cache keys, this would be 3.
|
|
103
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("layout sections cache by component name and reuse across pages", async () => {
|
|
107
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
108
|
+
registerSectionLoader("site/sections/Header.tsx", loader);
|
|
109
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
110
|
+
|
|
111
|
+
const section = makeSection("site/sections/Header.tsx", { variant: "default" });
|
|
112
|
+
|
|
113
|
+
await runSingleSectionLoader(section, new Request("https://store.com/a"));
|
|
114
|
+
await runSingleSectionLoader(section, new Request("https://store.com/b"));
|
|
115
|
+
|
|
116
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("regular (non-cached) sections re-run on every request", async () => {
|
|
120
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
121
|
+
registerSectionLoader("site/sections/Reg.tsx", loader);
|
|
122
|
+
|
|
123
|
+
const section = makeSection("site/sections/Reg.tsx", {});
|
|
124
|
+
await runSingleSectionLoader(section, new Request("https://store.com/a"));
|
|
125
|
+
await runSingleSectionLoader(section, new Request("https://store.com/b"));
|
|
126
|
+
|
|
127
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Layout cache opt-out (#206)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe("unregisterLayoutSections", () => {
|
|
136
|
+
it("drops a key from the layout set so the loader re-runs per request", async () => {
|
|
137
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
138
|
+
registerSectionLoader("site/sections/Header.tsx", loader);
|
|
139
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
140
|
+
expect(isLayoutSection("site/sections/Header.tsx")).toBe(true);
|
|
141
|
+
|
|
142
|
+
unregisterLayoutSections(["site/sections/Header.tsx"]);
|
|
143
|
+
expect(isLayoutSection("site/sections/Header.tsx")).toBe(false);
|
|
144
|
+
|
|
145
|
+
const section = makeSection("site/sections/Header.tsx", {});
|
|
146
|
+
await runSingleSectionLoader(section, new Request("https://store.com/a"));
|
|
147
|
+
await runSingleSectionLoader(section, new Request("https://store.com/b"));
|
|
148
|
+
|
|
149
|
+
// No layout caching after unregister — loader runs per request.
|
|
150
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is a no-op for keys that were never registered", () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
unregisterLayoutSections(["site/sections/Never.tsx"]),
|
|
156
|
+
).not.toThrow();
|
|
157
|
+
expect(isLayoutSection("site/sections/Never.tsx")).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Dev warning: layout + request-dependent loader → cache contamination (#206)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe("registerSectionLoaders — request-dependent + layout warning (#206)", () => {
|
|
166
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
167
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
process.env.NODE_ENV = "development";
|
|
171
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
176
|
+
warnSpy.mockRestore();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("warns when a request-dependent loader is registered for a layout section", () => {
|
|
180
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
181
|
+
registerSectionLoaders({
|
|
182
|
+
"site/sections/Header.tsx": withDevice(),
|
|
183
|
+
});
|
|
184
|
+
const calls = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
185
|
+
expect(calls.some((m) => m.includes("Header.tsx"))).toBe(true);
|
|
186
|
+
expect(calls.some((m) => m.includes("layout section"))).toBe(true);
|
|
187
|
+
expect(calls.some((m) => m.includes("unregisterLayoutSections"))).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("warns when a composed loader contains any request-dependent mixin", () => {
|
|
191
|
+
registerLayoutSections(["site/sections/Footer.tsx"]);
|
|
192
|
+
registerSectionLoaders({
|
|
193
|
+
"site/sections/Footer.tsx": compose(
|
|
194
|
+
withSearchParam(),
|
|
195
|
+
async (props) => props,
|
|
196
|
+
),
|
|
197
|
+
});
|
|
198
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does NOT warn for a layout section whose loader is request-independent", () => {
|
|
202
|
+
registerLayoutSections(["site/sections/Theme.tsx"]);
|
|
203
|
+
registerSectionLoaders({
|
|
204
|
+
"site/sections/Theme.tsx": async (props) => props,
|
|
205
|
+
});
|
|
206
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does NOT warn after unregisterLayoutSections drops the key", () => {
|
|
210
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
211
|
+
unregisterLayoutSections(["site/sections/Header.tsx"]);
|
|
212
|
+
registerSectionLoaders({
|
|
213
|
+
"site/sections/Header.tsx": withDevice(),
|
|
214
|
+
});
|
|
215
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does NOT warn in production", () => {
|
|
219
|
+
process.env.NODE_ENV = "production";
|
|
220
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
221
|
+
registerSectionLoaders({
|
|
222
|
+
"site/sections/Header.tsx": withMobile(),
|
|
223
|
+
});
|
|
224
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("runSingleSectionLoader — error handling", () => {
|
|
229
|
+
it("falls back to original section when loader throws", async () => {
|
|
230
|
+
const loader = vi.fn(async () => {
|
|
231
|
+
throw new Error("boom");
|
|
232
|
+
});
|
|
233
|
+
registerSectionLoader("site/sections/Boom.tsx", loader);
|
|
234
|
+
|
|
235
|
+
const section = makeSection("site/sections/Boom.tsx", { x: 1 });
|
|
236
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
237
|
+
expect(result).toEqual(section);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("runSingleSectionLoader — nested section recursion", () => {
|
|
242
|
+
it("runs the loader of a nested section in props", async () => {
|
|
243
|
+
const childLoader = vi.fn(async (props: Record<string, unknown>) => ({
|
|
244
|
+
...props,
|
|
245
|
+
enriched: true,
|
|
246
|
+
}));
|
|
247
|
+
registerSectionLoader("site/sections/CategoryBanner.tsx", childLoader);
|
|
248
|
+
|
|
249
|
+
// Parent has no own loader, only a nested section in props
|
|
250
|
+
const parent = makeSection("site/sections/BackgroundWrapper.tsx", {
|
|
251
|
+
child: {
|
|
252
|
+
Component: "site/sections/CategoryBanner.tsx",
|
|
253
|
+
props: { matcher: "/foo" },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/foo"));
|
|
258
|
+
|
|
259
|
+
expect(childLoader).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(result.props).toEqual({
|
|
261
|
+
child: {
|
|
262
|
+
Component: "site/sections/CategoryBanner.tsx",
|
|
263
|
+
props: {
|
|
264
|
+
matcher: "/foo",
|
|
265
|
+
enriched: true,
|
|
266
|
+
// page context is injected for nested sections too
|
|
267
|
+
__pageUrl: "https://store.com/foo",
|
|
268
|
+
__pagePath: "/foo",
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("runs nested loaders in arrays (e.g. sections: Section[])", async () => {
|
|
275
|
+
const banner = vi.fn(async (props: any) => ({ ...props, ranBanner: true }));
|
|
276
|
+
const shelf = vi.fn(async (props: any) => ({ ...props, ranShelf: true }));
|
|
277
|
+
registerSectionLoader("site/sections/Banner.tsx", banner);
|
|
278
|
+
registerSectionLoader("site/sections/Shelf.tsx", shelf);
|
|
279
|
+
|
|
280
|
+
const parent = makeSection("site/sections/Wrapper.tsx", {
|
|
281
|
+
sections: [
|
|
282
|
+
{ Component: "site/sections/Banner.tsx", props: { id: 1 } },
|
|
283
|
+
{ Component: "site/sections/Shelf.tsx", props: { id: 2 } },
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
288
|
+
|
|
289
|
+
expect(banner).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(shelf).toHaveBeenCalledTimes(1);
|
|
291
|
+
const sections = (result.props as any).sections;
|
|
292
|
+
expect(sections[0].props).toMatchObject({ id: 1, ranBanner: true });
|
|
293
|
+
expect(sections[1].props).toMatchObject({ id: 2, ranShelf: true });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("returns same props reference when no nested sections (zero-alloc leaf path)", async () => {
|
|
297
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
298
|
+
registerSectionLoader("site/sections/Leaf.tsx", loader);
|
|
299
|
+
|
|
300
|
+
const props = { foo: "bar" };
|
|
301
|
+
const section = makeSection("site/sections/Leaf.tsx", props);
|
|
302
|
+
|
|
303
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
304
|
+
|
|
305
|
+
// Loader returned the SAME props ref → enrichNestedSections must also
|
|
306
|
+
// return the same ref → no { ...result, props } wrapping happens
|
|
307
|
+
expect(result.props).toMatchObject({
|
|
308
|
+
foo: "bar",
|
|
309
|
+
__pageUrl: "https://store.com/",
|
|
310
|
+
__pagePath: "/",
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("recurses into deeply nested sections (wrapper inside wrapper)", async () => {
|
|
315
|
+
const inner = vi.fn(async (props: any) => ({ ...props, deep: true }));
|
|
316
|
+
registerSectionLoader("site/sections/Inner.tsx", inner);
|
|
317
|
+
|
|
318
|
+
const parent = makeSection("site/sections/Outer.tsx", {
|
|
319
|
+
child: {
|
|
320
|
+
Component: "site/sections/MidWrapper.tsx",
|
|
321
|
+
props: {
|
|
322
|
+
grandchild: {
|
|
323
|
+
Component: "site/sections/Inner.tsx",
|
|
324
|
+
props: { tag: "deep" },
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
331
|
+
|
|
332
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
333
|
+
const grandchild = (result.props as any).child.props.grandchild;
|
|
334
|
+
expect(grandchild.props).toMatchObject({ tag: "deep", deep: true });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("ignores nested objects that do not look like sections", async () => {
|
|
338
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
339
|
+
registerSectionLoader("site/sections/Leaf.tsx", loader);
|
|
340
|
+
|
|
341
|
+
const section = makeSection("site/sections/Leaf.tsx", {
|
|
342
|
+
// Plain config object, not a section. Has `Component: string` but
|
|
343
|
+
// missing the `props` field — must NOT be treated as a nested section.
|
|
344
|
+
config: { Component: "ButtonStyle", color: "red" },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
348
|
+
|
|
349
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
350
|
+
expect((result.props as any).config).toEqual({
|
|
351
|
+
Component: "ButtonStyle",
|
|
352
|
+
color: "red",
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("runs nested loaders even when parent has no own loader", async () => {
|
|
357
|
+
const childLoader = vi.fn(async (props: any) => ({ ...props, ran: true }));
|
|
358
|
+
registerSectionLoader("site/sections/Child.tsx", childLoader);
|
|
359
|
+
|
|
360
|
+
// Parent has no entry in registry — but it has a nested section in props
|
|
361
|
+
// (typical of a pure layout container that just renders children).
|
|
362
|
+
const parent = makeSection("site/sections/UnregisteredLayout.tsx", {
|
|
363
|
+
child: {
|
|
364
|
+
Component: "site/sections/Child.tsx",
|
|
365
|
+
props: { foo: "bar" },
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
370
|
+
|
|
371
|
+
expect(childLoader).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect((result.props as any).child.props).toMatchObject({
|
|
373
|
+
foo: "bar",
|
|
374
|
+
ran: true,
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("runSectionLoaders — batch span", () => {
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
configureTracer({ startSpan: () => ({ end: () => {} }) });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("emits one deco.section.loaders.batch parent with section.count", async () => {
|
|
385
|
+
const spans: Array<{ name: string; attrs?: Record<string, unknown> }> = [];
|
|
386
|
+
configureTracer({
|
|
387
|
+
startSpan: (name, attrs) => {
|
|
388
|
+
spans.push({ name, attrs });
|
|
389
|
+
const s: Span = { end: () => {} };
|
|
390
|
+
return s;
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
registerSectionLoader("a", async (p) => p);
|
|
395
|
+
registerSectionLoader("b", async (p) => p);
|
|
396
|
+
|
|
397
|
+
await runSectionLoaders(
|
|
398
|
+
[makeSection("a"), makeSection("b"), makeSection("c-no-loader")],
|
|
399
|
+
new Request("https://site/"),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const batch = spans.find((s) => s.name === "deco.section.loaders.batch");
|
|
403
|
+
expect(batch).toBeDefined();
|
|
404
|
+
expect(batch?.attrs).toMatchObject({ "section.count": 3 });
|
|
405
|
+
|
|
406
|
+
const perSection = spans.filter((s) => s.name === "deco.section.loader");
|
|
407
|
+
expect(perSection).toHaveLength(3);
|
|
408
|
+
});
|
|
409
|
+
});
|