@decocms/start 0.29.1 → 0.29.3
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/resolve.test.ts +102 -1
- package/src/cms/resolve.ts +15 -1
- package/src/hooks/DecoPageRenderer.tsx +71 -41
package/package.json
CHANGED
package/src/cms/resolve.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ vi.mock("./registry", () => ({
|
|
|
19
19
|
getSection: vi.fn(),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
-
import { resolveDeferredSectionFull } from "./resolve";
|
|
22
|
+
import { resolveDeferredSectionFull, resolveSectionsList } from "./resolve";
|
|
23
23
|
import { runSingleSectionLoader } from "./sectionLoaders";
|
|
24
24
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
25
25
|
import type { DeferredSection } from "./resolve";
|
|
@@ -65,3 +65,104 @@ describe("resolveDeferredSectionFull", () => {
|
|
|
65
65
|
expect(result).toEqual(input); // mock passes through
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// resolveSectionsList — page-level variant wrapper support
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe("resolveSectionsList", () => {
|
|
74
|
+
const makeRctx = (matcherCtx = {}) => ({
|
|
75
|
+
routeParams: {},
|
|
76
|
+
matcherCtx,
|
|
77
|
+
memo: new Map(),
|
|
78
|
+
depth: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns array as-is when value is already an array", async () => {
|
|
82
|
+
const sections = [{ __resolveType: "section-A" }, { __resolveType: "section-B" }];
|
|
83
|
+
const result = await resolveSectionsList(sections, makeRctx());
|
|
84
|
+
expect(result).toEqual(sections);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns empty array for null/undefined/non-object", async () => {
|
|
88
|
+
expect(await resolveSectionsList(null, makeRctx())).toEqual([]);
|
|
89
|
+
expect(await resolveSectionsList(undefined, makeRctx())).toEqual([]);
|
|
90
|
+
expect(await resolveSectionsList("string", makeRctx())).toEqual([]);
|
|
91
|
+
expect(await resolveSectionsList(42, makeRctx())).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("resolves page-level variant wrapper without __resolveType", async () => {
|
|
95
|
+
// Simulates CMS admin wrapping all sections in a device variant
|
|
96
|
+
// Rule has no __resolveType → evaluateMatcher returns true (match-all)
|
|
97
|
+
const sectionsArray = [
|
|
98
|
+
{ __resolveType: "Header - 01" },
|
|
99
|
+
{ __resolveType: "site/sections/Account/PersonalData.tsx" },
|
|
100
|
+
{ __resolveType: "Footer - 01" },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const variantWrapper = {
|
|
104
|
+
variants: [
|
|
105
|
+
{
|
|
106
|
+
rule: { mobile: true, tablet: true, desktop: true },
|
|
107
|
+
value: sectionsArray,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = await resolveSectionsList(variantWrapper, makeRctx());
|
|
113
|
+
expect(result).toEqual(sectionsArray);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns empty when no variant matches in page-level wrapper", async () => {
|
|
117
|
+
// All variants have __resolveType in rule → evaluateMatcher returns false
|
|
118
|
+
// (unregistered matcher defaults to false)
|
|
119
|
+
const variantWrapper = {
|
|
120
|
+
variants: [
|
|
121
|
+
{
|
|
122
|
+
rule: { __resolveType: "website/matchers/device.ts", mobile: true },
|
|
123
|
+
value: [{ __resolveType: "MobileOnly" }],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const result = await resolveSectionsList(variantWrapper, makeRctx());
|
|
129
|
+
expect(result).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("picks first matching variant in page-level wrapper", async () => {
|
|
133
|
+
const desktopSections = [{ __resolveType: "DesktopLayout" }];
|
|
134
|
+
const mobileSections = [{ __resolveType: "MobileLayout" }];
|
|
135
|
+
|
|
136
|
+
const variantWrapper = {
|
|
137
|
+
variants: [
|
|
138
|
+
{
|
|
139
|
+
// No __resolveType → evaluateMatcher returns true (first match wins)
|
|
140
|
+
rule: { desktop: true },
|
|
141
|
+
value: desktopSections,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
rule: { mobile: true },
|
|
145
|
+
value: mobileSections,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = await resolveSectionsList(variantWrapper, makeRctx());
|
|
151
|
+
expect(result).toEqual(desktopSections);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns empty for object without __resolveType and without variants", async () => {
|
|
155
|
+
const result = await resolveSectionsList({ someKey: "value" }, makeRctx());
|
|
156
|
+
expect(result).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("respects max depth limit (20)", async () => {
|
|
160
|
+
// Build 21 levels of nested variant wrappers to exceed MAX_RESOLVE_DEPTH=20
|
|
161
|
+
let wrapper: any = [{ __resolveType: "deep" }];
|
|
162
|
+
for (let i = 0; i < 21; i++) {
|
|
163
|
+
wrapper = { variants: [{ rule: {}, value: wrapper }] };
|
|
164
|
+
}
|
|
165
|
+
const result = await resolveSectionsList(wrapper, makeRctx());
|
|
166
|
+
expect(result).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1015,7 +1015,7 @@ function resolveSectionShallow(
|
|
|
1015
1015
|
* to get the raw section array without resolving each individual section.
|
|
1016
1016
|
* This allows the eager/deferred split to happen before section resolution.
|
|
1017
1017
|
*/
|
|
1018
|
-
async function resolveSectionsList(
|
|
1018
|
+
export async function resolveSectionsList(
|
|
1019
1019
|
value: unknown,
|
|
1020
1020
|
rctx: ResolveContext,
|
|
1021
1021
|
depth = 0,
|
|
@@ -1026,6 +1026,20 @@ async function resolveSectionsList(
|
|
|
1026
1026
|
|
|
1027
1027
|
const obj = value as Record<string, unknown>;
|
|
1028
1028
|
const rt = obj.__resolveType as string | undefined;
|
|
1029
|
+
|
|
1030
|
+
// Page-level variant wrapper without __resolveType
|
|
1031
|
+
// (CMS admin wraps ALL sections in a variant object with { variants: [...] })
|
|
1032
|
+
if (!rt && Array.isArray(obj.variants)) {
|
|
1033
|
+
const variants = obj.variants as Array<{ value: unknown; rule?: unknown }>;
|
|
1034
|
+
for (const variant of variants) {
|
|
1035
|
+
const rule = variant.rule as Record<string, unknown> | undefined;
|
|
1036
|
+
if (evaluateMatcher(rule, rctx.matcherCtx)) {
|
|
1037
|
+
return resolveSectionsList(variant.value, rctx, depth + 1);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return [];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1029
1043
|
if (!rt) return [];
|
|
1030
1044
|
|
|
1031
1045
|
// Multivariate flags — evaluate matchers and recurse into matched variant
|
|
@@ -172,21 +172,34 @@ export function SectionRenderer({ section }: { section: Section | null | undefin
|
|
|
172
172
|
return <Comp {...(section.props ?? {})} />;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
// Sync path: render directly if available — avoids React.lazy SSR streaming issue
|
|
176
|
+
const options = getSectionOptions(section.Component);
|
|
177
|
+
const isClientOnly = options?.clientOnly === true;
|
|
178
|
+
const SyncComp = getSyncComponent(section.Component);
|
|
179
|
+
if (SyncComp && !isClientOnly) {
|
|
180
|
+
return createElement(SyncComp, section.props ?? {});
|
|
181
|
+
}
|
|
182
|
+
|
|
175
183
|
const Lazy = getLazyComponent(section.Component);
|
|
176
184
|
if (!Lazy) {
|
|
177
185
|
console.warn(`[SectionRenderer] No component registered for: ${section.Component}`);
|
|
178
186
|
return null;
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
// Use the section's registered loadingFallback (if available) instead of
|
|
182
|
-
// the generic NestedSectionFallback. This lets parent sections (e.g.
|
|
183
|
-
// NotFoundChallenge) show a meaningful skeleton for nested children
|
|
184
|
-
// (e.g. MountedPDP) while the lazy chunk loads.
|
|
185
|
-
const options = getSectionOptions(section.Component);
|
|
186
189
|
const fallback = options?.loadingFallback
|
|
187
190
|
? createElement(options.loadingFallback, section.props ?? {})
|
|
188
191
|
: <NestedSectionFallback />;
|
|
189
192
|
|
|
193
|
+
if (isClientOnly) {
|
|
194
|
+
return (
|
|
195
|
+
<ClientOnly fallback={fallback}>
|
|
196
|
+
<Suspense fallback={null}>
|
|
197
|
+
<Lazy {...(section.props ?? {})} />
|
|
198
|
+
</Suspense>
|
|
199
|
+
</ClientOnly>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
190
203
|
return (
|
|
191
204
|
<Suspense fallback={fallback}>
|
|
192
205
|
<Lazy {...(section.props ?? {})} />
|
|
@@ -501,20 +514,39 @@ export function DecoPageRenderer({
|
|
|
501
514
|
<Await promise={promise}>
|
|
502
515
|
{(resolved) => {
|
|
503
516
|
if (!resolved) return null;
|
|
504
|
-
const LazyComponent = getLazyComponent(resolved.component);
|
|
505
|
-
if (!LazyComponent) return null;
|
|
506
517
|
const resolvedOptions = getSectionOptions(resolved.component);
|
|
507
518
|
const isClientOnly = resolvedOptions?.clientOnly === true;
|
|
519
|
+
const SyncComp = getSyncComponent(resolved.component);
|
|
508
520
|
const sectionId = resolved.key
|
|
509
521
|
.replace(/\//g, "-")
|
|
510
522
|
.replace(/\.tsx$/, "")
|
|
511
523
|
.replace(/^site-sections-/, "");
|
|
512
524
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
525
|
+
let inner: ReactNode;
|
|
526
|
+
|
|
527
|
+
if (SyncComp && !isClientOnly) {
|
|
528
|
+
// Sync path: direct render, no lazy/Suspense.
|
|
529
|
+
inner = createElement(SyncComp, resolved.props);
|
|
530
|
+
} else {
|
|
531
|
+
const LazyComponent = getLazyComponent(resolved.component);
|
|
532
|
+
if (!LazyComponent) return null;
|
|
533
|
+
|
|
534
|
+
const fallbackEl = resolvedOptions?.loadingFallback
|
|
535
|
+
? createElement(resolvedOptions.loadingFallback, resolved.props)
|
|
536
|
+
: null;
|
|
537
|
+
|
|
538
|
+
inner = isClientOnly ? (
|
|
539
|
+
<ClientOnly fallback={fallbackEl}>
|
|
540
|
+
<Suspense fallback={null}>
|
|
541
|
+
<LazyComponent {...resolved.props} />
|
|
542
|
+
</Suspense>
|
|
543
|
+
</ClientOnly>
|
|
544
|
+
) : (
|
|
545
|
+
<Suspense fallback={null}>
|
|
546
|
+
<LazyComponent {...resolved.props} />
|
|
547
|
+
</Suspense>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
518
550
|
|
|
519
551
|
return (
|
|
520
552
|
<section
|
|
@@ -522,11 +554,7 @@ export function DecoPageRenderer({
|
|
|
522
554
|
data-manifest-key={resolved.key}
|
|
523
555
|
style={{ animation: "decoFadeIn 0.3s ease-out" }}
|
|
524
556
|
>
|
|
525
|
-
{
|
|
526
|
-
<ClientOnly fallback={null}>{inner}</ClientOnly>
|
|
527
|
-
) : (
|
|
528
|
-
inner
|
|
529
|
-
)}
|
|
557
|
+
{inner}
|
|
530
558
|
</section>
|
|
531
559
|
);
|
|
532
560
|
}}
|
|
@@ -565,34 +593,36 @@ export function DecoPageRenderer({
|
|
|
565
593
|
.replace(/\.tsx$/, "")
|
|
566
594
|
.replace(/^site-sections-/, "");
|
|
567
595
|
|
|
568
|
-
// Unified render path: always use React.lazy + Suspense.
|
|
569
|
-
// For sync-registered components, getLazyComponent wraps them in a
|
|
570
|
-
// pre-fulfilled lazy (via syncThenable) so React renders them
|
|
571
|
-
// synchronously — same behavior as the old sync path, but with an
|
|
572
|
-
// identical tree structure on both server and client (always has
|
|
573
|
-
// <Suspense>). This prevents hydration mismatches when sites remove
|
|
574
|
-
// registerSectionsSync.
|
|
575
|
-
const LazyComponent = getLazyComponent(section.component);
|
|
576
|
-
if (!LazyComponent) return null;
|
|
577
|
-
|
|
578
|
-
// ClientOnly sections: render only on client, no SSR, no hydration mismatch.
|
|
579
|
-
// Used for analytics scripts, GTM, third-party widgets.
|
|
580
596
|
const isClientOnly = options?.clientOnly === true;
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
597
|
+
const SyncComponent = getSyncComponent(section.component);
|
|
598
|
+
|
|
599
|
+
let content: ReactNode;
|
|
600
|
+
|
|
601
|
+
if (SyncComponent && !isClientOnly) {
|
|
602
|
+
// Sync path: render directly — no Suspense, no lazy.
|
|
603
|
+
// React SSR streaming ignores syncThenable's pre-fulfilled status
|
|
604
|
+
// and creates empty <template> placeholders. Direct createElement avoids this.
|
|
605
|
+
content = createElement(SyncComponent, section.props);
|
|
606
|
+
} else {
|
|
607
|
+
const LazyComponent = getLazyComponent(section.component);
|
|
608
|
+
if (!LazyComponent) return null;
|
|
609
|
+
|
|
610
|
+
const fallbackEl = options?.loadingFallback
|
|
611
|
+
? createElement(options.loadingFallback, section.props)
|
|
612
|
+
: null;
|
|
613
|
+
|
|
614
|
+
content = isClientOnly ? (
|
|
615
|
+
<ClientOnly fallback={fallbackEl}>
|
|
616
|
+
<Suspense fallback={null}>
|
|
617
|
+
<LazyComponent {...section.props} />
|
|
618
|
+
</Suspense>
|
|
619
|
+
</ClientOnly>
|
|
620
|
+
) : (
|
|
587
621
|
<Suspense fallback={null}>
|
|
588
622
|
<LazyComponent {...section.props} />
|
|
589
623
|
</Suspense>
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
<Suspense fallback={null}>
|
|
593
|
-
<LazyComponent {...section.props} />
|
|
594
|
-
</Suspense>
|
|
595
|
-
);
|
|
624
|
+
);
|
|
625
|
+
}
|
|
596
626
|
|
|
597
627
|
// Dev warning: eager section not sync-registered may blank during hydration
|
|
598
628
|
if (isDev && !isClientOnly && !getSyncComponent(section.component)) {
|