@decocms/start 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cms/sectionLoaders.test.ts +142 -13
- package/src/cms/sectionLoaders.ts +167 -35
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ResolvedSection } from "./resolve";
|
|
2
3
|
import {
|
|
3
4
|
registerCacheableSections,
|
|
4
5
|
registerLayoutSections,
|
|
5
6
|
registerSectionLoader,
|
|
6
7
|
runSingleSectionLoader,
|
|
7
8
|
} from "./sectionLoaders";
|
|
8
|
-
import type { ResolvedSection } from "./resolve";
|
|
9
9
|
|
|
10
10
|
const G = globalThis as any;
|
|
11
11
|
|
|
@@ -15,10 +15,7 @@ beforeEach(() => {
|
|
|
15
15
|
G.__deco.cacheableSections.clear();
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
const makeSection = (
|
|
19
|
-
component: string,
|
|
20
|
-
props: Record<string, unknown> = {},
|
|
21
|
-
): ResolvedSection => ({
|
|
18
|
+
const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
|
|
22
19
|
component,
|
|
23
20
|
props,
|
|
24
21
|
key: component,
|
|
@@ -75,10 +72,7 @@ describe("runSingleSectionLoader — page context injection", () => {
|
|
|
75
72
|
|
|
76
73
|
it("returns section unchanged when no loader is registered", async () => {
|
|
77
74
|
const section = makeSection("site/sections/NoLoader.tsx", { foo: 1 });
|
|
78
|
-
const result = await runSingleSectionLoader(
|
|
79
|
-
section,
|
|
80
|
-
new Request("https://store.com/"),
|
|
81
|
-
);
|
|
75
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
82
76
|
expect(result).toBe(section);
|
|
83
77
|
});
|
|
84
78
|
});
|
|
@@ -135,10 +129,145 @@ describe("runSingleSectionLoader — error handling", () => {
|
|
|
135
129
|
registerSectionLoader("site/sections/Boom.tsx", loader);
|
|
136
130
|
|
|
137
131
|
const section = makeSection("site/sections/Boom.tsx", { x: 1 });
|
|
138
|
-
const result = await runSingleSectionLoader(
|
|
139
|
-
section,
|
|
140
|
-
new Request("https://store.com/"),
|
|
141
|
-
);
|
|
132
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
142
133
|
expect(result).toEqual(section);
|
|
143
134
|
});
|
|
144
135
|
});
|
|
136
|
+
|
|
137
|
+
describe("runSingleSectionLoader — nested section recursion", () => {
|
|
138
|
+
it("runs the loader of a nested section in props", async () => {
|
|
139
|
+
const childLoader = vi.fn(async (props: Record<string, unknown>) => ({
|
|
140
|
+
...props,
|
|
141
|
+
enriched: true,
|
|
142
|
+
}));
|
|
143
|
+
registerSectionLoader("site/sections/CategoryBanner.tsx", childLoader);
|
|
144
|
+
|
|
145
|
+
// Parent has no own loader, only a nested section in props
|
|
146
|
+
const parent = makeSection("site/sections/BackgroundWrapper.tsx", {
|
|
147
|
+
child: {
|
|
148
|
+
Component: "site/sections/CategoryBanner.tsx",
|
|
149
|
+
props: { matcher: "/foo" },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/foo"));
|
|
154
|
+
|
|
155
|
+
expect(childLoader).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(result.props).toEqual({
|
|
157
|
+
child: {
|
|
158
|
+
Component: "site/sections/CategoryBanner.tsx",
|
|
159
|
+
props: {
|
|
160
|
+
matcher: "/foo",
|
|
161
|
+
enriched: true,
|
|
162
|
+
// page context is injected for nested sections too
|
|
163
|
+
__pageUrl: "https://store.com/foo",
|
|
164
|
+
__pagePath: "/foo",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("runs nested loaders in arrays (e.g. sections: Section[])", async () => {
|
|
171
|
+
const banner = vi.fn(async (props: any) => ({ ...props, ranBanner: true }));
|
|
172
|
+
const shelf = vi.fn(async (props: any) => ({ ...props, ranShelf: true }));
|
|
173
|
+
registerSectionLoader("site/sections/Banner.tsx", banner);
|
|
174
|
+
registerSectionLoader("site/sections/Shelf.tsx", shelf);
|
|
175
|
+
|
|
176
|
+
const parent = makeSection("site/sections/Wrapper.tsx", {
|
|
177
|
+
sections: [
|
|
178
|
+
{ Component: "site/sections/Banner.tsx", props: { id: 1 } },
|
|
179
|
+
{ Component: "site/sections/Shelf.tsx", props: { id: 2 } },
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
184
|
+
|
|
185
|
+
expect(banner).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(shelf).toHaveBeenCalledTimes(1);
|
|
187
|
+
const sections = (result.props as any).sections;
|
|
188
|
+
expect(sections[0].props).toMatchObject({ id: 1, ranBanner: true });
|
|
189
|
+
expect(sections[1].props).toMatchObject({ id: 2, ranShelf: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns same props reference when no nested sections (zero-alloc leaf path)", async () => {
|
|
193
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
194
|
+
registerSectionLoader("site/sections/Leaf.tsx", loader);
|
|
195
|
+
|
|
196
|
+
const props = { foo: "bar" };
|
|
197
|
+
const section = makeSection("site/sections/Leaf.tsx", props);
|
|
198
|
+
|
|
199
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
200
|
+
|
|
201
|
+
// Loader returned the SAME props ref → enrichNestedSections must also
|
|
202
|
+
// return the same ref → no { ...result, props } wrapping happens
|
|
203
|
+
expect(result.props).toMatchObject({
|
|
204
|
+
foo: "bar",
|
|
205
|
+
__pageUrl: "https://store.com/",
|
|
206
|
+
__pagePath: "/",
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("recurses into deeply nested sections (wrapper inside wrapper)", async () => {
|
|
211
|
+
const inner = vi.fn(async (props: any) => ({ ...props, deep: true }));
|
|
212
|
+
registerSectionLoader("site/sections/Inner.tsx", inner);
|
|
213
|
+
|
|
214
|
+
const parent = makeSection("site/sections/Outer.tsx", {
|
|
215
|
+
child: {
|
|
216
|
+
Component: "site/sections/MidWrapper.tsx",
|
|
217
|
+
props: {
|
|
218
|
+
grandchild: {
|
|
219
|
+
Component: "site/sections/Inner.tsx",
|
|
220
|
+
props: { tag: "deep" },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
227
|
+
|
|
228
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
229
|
+
const grandchild = (result.props as any).child.props.grandchild;
|
|
230
|
+
expect(grandchild.props).toMatchObject({ tag: "deep", deep: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("ignores nested objects that do not look like sections", async () => {
|
|
234
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => props);
|
|
235
|
+
registerSectionLoader("site/sections/Leaf.tsx", loader);
|
|
236
|
+
|
|
237
|
+
const section = makeSection("site/sections/Leaf.tsx", {
|
|
238
|
+
// Plain config object, not a section. Has `Component: string` but
|
|
239
|
+
// missing the `props` field — must NOT be treated as a nested section.
|
|
240
|
+
config: { Component: "ButtonStyle", color: "red" },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = await runSingleSectionLoader(section, new Request("https://store.com/"));
|
|
244
|
+
|
|
245
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
246
|
+
expect((result.props as any).config).toEqual({
|
|
247
|
+
Component: "ButtonStyle",
|
|
248
|
+
color: "red",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("runs nested loaders even when parent has no own loader", async () => {
|
|
253
|
+
const childLoader = vi.fn(async (props: any) => ({ ...props, ran: true }));
|
|
254
|
+
registerSectionLoader("site/sections/Child.tsx", childLoader);
|
|
255
|
+
|
|
256
|
+
// Parent has no entry in registry — but it has a nested section in props
|
|
257
|
+
// (typical of a pure layout container that just renders children).
|
|
258
|
+
const parent = makeSection("site/sections/UnregisteredLayout.tsx", {
|
|
259
|
+
child: {
|
|
260
|
+
Component: "site/sections/Child.tsx",
|
|
261
|
+
props: { foo: "bar" },
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await runSingleSectionLoader(parent, new Request("https://store.com/"));
|
|
266
|
+
|
|
267
|
+
expect(childLoader).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect((result.props as any).child.props).toMatchObject({
|
|
269
|
+
foo: "bar",
|
|
270
|
+
ran: true,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -34,7 +34,9 @@ interface CacheableSectionConfig {
|
|
|
34
34
|
maxAge: number;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export type CacheableSectionInput =
|
|
37
|
+
export type CacheableSectionInput =
|
|
38
|
+
| CacheableSectionConfig
|
|
39
|
+
| import("../sdk/cacheHeaders").CacheProfileName;
|
|
38
40
|
|
|
39
41
|
function resolveSectionCacheConfig(input: CacheableSectionInput): CacheableSectionConfig {
|
|
40
42
|
if (typeof input === "string") {
|
|
@@ -255,10 +257,7 @@ export async function runSectionLoaders(
|
|
|
255
257
|
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
256
258
|
for (const s of sections) {
|
|
257
259
|
const key = s.component.toLowerCase();
|
|
258
|
-
if (
|
|
259
|
-
(key.includes("header") || key.includes("footer")) &&
|
|
260
|
-
!layoutSections.has(s.component)
|
|
261
|
-
) {
|
|
260
|
+
if ((key.includes("header") || key.includes("footer")) && !layoutSections.has(s.component)) {
|
|
262
261
|
console.warn(
|
|
263
262
|
`[SectionLoaders] "${s.component}" looks like a layout section but is not in registerLayoutSections(). ` +
|
|
264
263
|
`Add it to registerLayoutSections() in setup.ts for consistent caching across navigations.`,
|
|
@@ -318,47 +317,180 @@ function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
|
|
|
318
317
|
* 1. Layout sections (Header/Footer) — 5min TTL + in-flight dedup
|
|
319
318
|
* 2. Cacheable sections (ProductShelf, FAQ) — SWR with configurable maxAge
|
|
320
319
|
* 3. Regular sections — no cache, always fresh
|
|
320
|
+
*
|
|
321
|
+
* After the section's own loader runs, recursively runs loaders for any
|
|
322
|
+
* nested sections found in its resolved props (e.g. wrapper sections with
|
|
323
|
+
* a `sections: Section[]` prop). This eliminates the need for sites to
|
|
324
|
+
* manually walk + invoke `runSingleSectionLoader` on children.
|
|
321
325
|
*/
|
|
322
326
|
export async function runSingleSectionLoader(
|
|
323
327
|
section: ResolvedSection,
|
|
324
328
|
request: Request,
|
|
325
329
|
): Promise<ResolvedSection> {
|
|
326
330
|
const loader = loaderRegistry.get(section.component);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
331
|
+
|
|
332
|
+
let result: ResolvedSection;
|
|
333
|
+
|
|
334
|
+
if (!loader) {
|
|
335
|
+
// No own-loader, but the section may still contain nested sections in
|
|
336
|
+
// its props (CMS-resolved children) that need their loaders run.
|
|
337
|
+
result = section;
|
|
338
|
+
} else {
|
|
339
|
+
// Wrap the loader so __pageUrl/__pagePath are injected at the call site.
|
|
340
|
+
// Cache keys (component name for layout, component+propsHash for cacheable)
|
|
341
|
+
// are computed from the *original* section.props — keeping cache entries
|
|
342
|
+
// URL-agnostic and shared across pages.
|
|
343
|
+
const wrapped = withPageContext(loader);
|
|
344
|
+
|
|
345
|
+
if (layoutSections.has(section.component)) {
|
|
346
|
+
try {
|
|
347
|
+
result = await resolveLayoutSection(section, wrapped, request);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
|
|
350
|
+
result = section;
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const cacheConfig = cacheableSections.get(section.component);
|
|
354
|
+
if (cacheConfig) {
|
|
355
|
+
try {
|
|
356
|
+
result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
|
|
359
|
+
result = section;
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
try {
|
|
363
|
+
const enrichedProps = await wrapped(section.props as Record<string, unknown>, request);
|
|
364
|
+
result = { ...section, props: enrichedProps };
|
|
365
|
+
} catch (error) {
|
|
366
|
+
console.error(`[SectionLoader] Error in "${section.component}":`, error);
|
|
367
|
+
result = section;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
341
370
|
}
|
|
342
371
|
}
|
|
343
372
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
373
|
+
// Recurse into nested sections AFTER the parent's loader/cache lookup so
|
|
374
|
+
// child sections keep their own cache TTL independent from the parent's.
|
|
375
|
+
// For layout/cacheable parents, this means a 5-min layout cache hit still
|
|
376
|
+
// re-evaluates child sections (whose own caches are usually shorter, e.g.
|
|
377
|
+
// ProductShelf 60s). For leaf sections, `enrichNestedSections` returns
|
|
378
|
+
// the same reference (no allocation, no extra work).
|
|
379
|
+
const props = result.props as Record<string, unknown> | undefined;
|
|
380
|
+
if (props && typeof props === "object") {
|
|
381
|
+
const enrichedProps = await enrichNestedSections(props, request);
|
|
382
|
+
if (enrichedProps !== props) {
|
|
383
|
+
return { ...result, props: enrichedProps };
|
|
351
384
|
}
|
|
352
385
|
}
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
353
388
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Nested section loader support
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Type guard: matches the shape produced by `normalizeNestedSections` in
|
|
395
|
+
* resolve.ts — `{ Component: string, props: object }`. This is how the CMS
|
|
396
|
+
* resolver represents nested sections (children of wrapper sections).
|
|
397
|
+
*
|
|
398
|
+
* Note: the `Component` key uses capital C to match the runtime renderer's
|
|
399
|
+
* convention (mirrors deco-cx/deco's Fresh API). Not to be confused with
|
|
400
|
+
* the lowercase `component` on `ResolvedSection`.
|
|
401
|
+
*/
|
|
402
|
+
function isNestedSection(
|
|
403
|
+
value: unknown,
|
|
404
|
+
): value is { Component: string; props: Record<string, unknown> } {
|
|
405
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
406
|
+
const obj = value as Record<string, unknown>;
|
|
407
|
+
return (
|
|
408
|
+
typeof obj.Component === "string" &&
|
|
409
|
+
obj.props != null &&
|
|
410
|
+
typeof obj.props === "object" &&
|
|
411
|
+
!Array.isArray(obj.props)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Walk a props object and run section loaders for any nested sections.
|
|
417
|
+
* Handles direct child sections AND arrays of sections (e.g.
|
|
418
|
+
* `sections: Section[]`, `slides: Slide[]`).
|
|
419
|
+
*
|
|
420
|
+
* Returns the same reference if nothing changed — so leaf sections (the
|
|
421
|
+
* vast majority) incur zero allocation overhead.
|
|
422
|
+
*
|
|
423
|
+
* Concurrency: all nested loader calls run in parallel via Promise.all.
|
|
424
|
+
*/
|
|
425
|
+
async function enrichNestedSections(
|
|
426
|
+
props: Record<string, unknown>,
|
|
427
|
+
request: Request,
|
|
428
|
+
): Promise<Record<string, unknown>> {
|
|
429
|
+
type Pending = {
|
|
430
|
+
key: string;
|
|
431
|
+
index?: number;
|
|
432
|
+
promise: Promise<ResolvedSection>;
|
|
433
|
+
};
|
|
434
|
+
const pending: Pending[] = [];
|
|
435
|
+
|
|
436
|
+
for (const [key, value] of Object.entries(props)) {
|
|
437
|
+
if (isNestedSection(value)) {
|
|
438
|
+
pending.push({
|
|
439
|
+
key,
|
|
440
|
+
promise: runSingleSectionLoader(
|
|
441
|
+
{
|
|
442
|
+
component: value.Component,
|
|
443
|
+
props: value.props,
|
|
444
|
+
key: value.Component,
|
|
445
|
+
} as ResolvedSection,
|
|
446
|
+
request,
|
|
447
|
+
),
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (Array.isArray(value)) {
|
|
453
|
+
for (let i = 0; i < value.length; i++) {
|
|
454
|
+
const item = value[i];
|
|
455
|
+
if (isNestedSection(item)) {
|
|
456
|
+
pending.push({
|
|
457
|
+
key,
|
|
458
|
+
index: i,
|
|
459
|
+
promise: runSingleSectionLoader(
|
|
460
|
+
{
|
|
461
|
+
component: item.Component,
|
|
462
|
+
props: item.props,
|
|
463
|
+
key: item.Component,
|
|
464
|
+
} as ResolvedSection,
|
|
465
|
+
request,
|
|
466
|
+
),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
363
471
|
}
|
|
472
|
+
|
|
473
|
+
if (pending.length === 0) return props;
|
|
474
|
+
|
|
475
|
+
const results = await Promise.all(pending.map((p) => p.promise));
|
|
476
|
+
const updated: Record<string, unknown> = { ...props };
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < pending.length; i++) {
|
|
479
|
+
const { key, index } = pending[i];
|
|
480
|
+
const enriched = results[i];
|
|
481
|
+
const nestedValue = { Component: enriched.component, props: enriched.props };
|
|
482
|
+
|
|
483
|
+
if (index != null) {
|
|
484
|
+
// Array item — clone the array on first mutation for this key
|
|
485
|
+
const current = updated[key];
|
|
486
|
+
if (current === props[key]) {
|
|
487
|
+
updated[key] = [...(current as unknown[])];
|
|
488
|
+
}
|
|
489
|
+
(updated[key] as unknown[])[index] = nestedValue;
|
|
490
|
+
} else {
|
|
491
|
+
updated[key] = nestedValue;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return updated;
|
|
364
496
|
}
|