@decocms/start 0.29.0 → 0.29.2
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
CHANGED
|
@@ -239,6 +239,22 @@ export async function runSectionLoaders(
|
|
|
239
239
|
sections: ResolvedSection[],
|
|
240
240
|
request: Request,
|
|
241
241
|
): Promise<ResolvedSection[]> {
|
|
242
|
+
// Dev warning: detect likely layout sections not registered via registerLayoutSections.
|
|
243
|
+
// Without registration, Header/Footer won't be cached across navigations.
|
|
244
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
245
|
+
for (const s of sections) {
|
|
246
|
+
const key = s.component.toLowerCase();
|
|
247
|
+
if (
|
|
248
|
+
(key.includes("header") || key.includes("footer")) &&
|
|
249
|
+
!layoutSections.has(s.component)
|
|
250
|
+
) {
|
|
251
|
+
console.warn(
|
|
252
|
+
`[SectionLoaders] "${s.component}" looks like a layout section but is not in registerLayoutSections(). ` +
|
|
253
|
+
`Add it to registerLayoutSections() in setup.ts for consistent caching across navigations.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
242
258
|
return Promise.all(sections.map((section) => runSingleSectionLoader(section, request)));
|
|
243
259
|
}
|
|
244
260
|
|
|
@@ -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)) {
|
|
@@ -27,6 +27,25 @@ describe("validateDeferredSectionInput", () => {
|
|
|
27
27
|
expect(result.pageUrl).toBe("https://store.com/home?ref=nav");
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
it("accepts input with index for SPA navigation ordering", () => {
|
|
31
|
+
const result = validateDeferredSectionInput({
|
|
32
|
+
component: "site/sections/Shelf.tsx",
|
|
33
|
+
rawProps: {},
|
|
34
|
+
pagePath: "/",
|
|
35
|
+
index: 5,
|
|
36
|
+
});
|
|
37
|
+
expect(result.index).toBe(5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("index is undefined when not provided", () => {
|
|
41
|
+
const result = validateDeferredSectionInput({
|
|
42
|
+
component: "site/sections/Shelf.tsx",
|
|
43
|
+
rawProps: {},
|
|
44
|
+
pagePath: "/",
|
|
45
|
+
});
|
|
46
|
+
expect(result.index).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
30
49
|
it("throws on null input", () => {
|
|
31
50
|
expect(() => validateDeferredSectionInput(null)).toThrow("Expected an object");
|
|
32
51
|
});
|
|
@@ -85,6 +104,17 @@ describe("validateDeferredSectionInput", () => {
|
|
|
85
104
|
}),
|
|
86
105
|
).toThrow("pageUrl");
|
|
87
106
|
});
|
|
107
|
+
|
|
108
|
+
it("throws when index is not a number", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
validateDeferredSectionInput({
|
|
111
|
+
component: "X",
|
|
112
|
+
rawProps: {},
|
|
113
|
+
pagePath: "/",
|
|
114
|
+
index: "three" as any,
|
|
115
|
+
}),
|
|
116
|
+
).toThrow("index");
|
|
117
|
+
});
|
|
88
118
|
});
|
|
89
119
|
|
|
90
120
|
describe("createSectionValidator", () => {
|
|
@@ -21,6 +21,8 @@ export interface DeferredSectionInput {
|
|
|
21
21
|
rawProps: Record<string, unknown>;
|
|
22
22
|
pagePath: string;
|
|
23
23
|
pageUrl?: string;
|
|
24
|
+
/** Original position in the page section list — for correct ordering after resolution. */
|
|
25
|
+
index?: number;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -57,11 +59,18 @@ export function validateDeferredSectionInput(data: unknown): DeferredSectionInpu
|
|
|
57
59
|
);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
if (obj.index !== undefined && typeof obj.index !== "number") {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"[validateDeferredSectionInput] Invalid 'index' field (expected number or undefined)",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
return {
|
|
61
69
|
component: obj.component as string,
|
|
62
70
|
rawProps: obj.rawProps as Record<string, unknown>,
|
|
63
71
|
pagePath: obj.pagePath as string,
|
|
64
72
|
pageUrl: obj.pageUrl as string | undefined,
|
|
73
|
+
index: obj.index as number | undefined,
|
|
65
74
|
};
|
|
66
75
|
}
|
|
67
76
|
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -193,10 +193,17 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
193
193
|
export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
194
194
|
.inputValidator(
|
|
195
195
|
(data: unknown) =>
|
|
196
|
-
data as {
|
|
196
|
+
data as {
|
|
197
|
+
component: string;
|
|
198
|
+
rawProps: Record<string, any>;
|
|
199
|
+
pagePath: string;
|
|
200
|
+
pageUrl?: string;
|
|
201
|
+
/** Original position in the page section list — preserved for correct SPA ordering. */
|
|
202
|
+
index?: number;
|
|
203
|
+
},
|
|
197
204
|
)
|
|
198
205
|
.handler(async (ctx) => {
|
|
199
|
-
const { component, rawProps, pagePath, pageUrl } = ctx.data;
|
|
206
|
+
const { component, rawProps, pagePath, pageUrl, index } = ctx.data;
|
|
200
207
|
|
|
201
208
|
const originRequest = getRequest();
|
|
202
209
|
const serverUrl = getRequestUrl().toString();
|
|
@@ -211,6 +218,9 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
211
218
|
const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
|
|
212
219
|
if (!section) return null;
|
|
213
220
|
|
|
221
|
+
// Preserve original index for correct ordering in SPA navigation merge
|
|
222
|
+
if (index !== undefined) section.index = index;
|
|
223
|
+
|
|
214
224
|
const request = new Request(pageUrl || serverUrl, {
|
|
215
225
|
headers: originRequest.headers,
|
|
216
226
|
});
|
|
@@ -510,17 +520,33 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
510
520
|
// SSR: create unawaited promises for TanStack native streaming.
|
|
511
521
|
// Each deferred section becomes a promise that TanStack streams
|
|
512
522
|
// via SSR chunked transfer — all resolved in the SAME request.
|
|
523
|
+
//
|
|
524
|
+
// IMPORTANT: We call resolveDeferredSectionFull directly instead of
|
|
525
|
+
// loadDeferredSection (server function). Server functions serialize
|
|
526
|
+
// their return via JSON-RPC — TanStack only streams promises that
|
|
527
|
+
// are directly in the loader return, not serialized server fn results.
|
|
513
528
|
if (isServer && page.deferredSections?.length) {
|
|
529
|
+
const originRequest = getRequest();
|
|
530
|
+
const serverUrl = getRequestUrl();
|
|
531
|
+
const matcherCtx: MatcherContext = {
|
|
532
|
+
userAgent: getRequestHeader("user-agent") ?? "",
|
|
533
|
+
url: page.pageUrl ?? serverUrl.toString(),
|
|
534
|
+
path: page.pagePath ?? basePath,
|
|
535
|
+
cookies: getCookies(),
|
|
536
|
+
request: originRequest,
|
|
537
|
+
};
|
|
538
|
+
const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
|
|
539
|
+
headers: originRequest.headers,
|
|
540
|
+
});
|
|
541
|
+
|
|
514
542
|
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
515
543
|
for (const ds of page.deferredSections) {
|
|
516
|
-
deferredPromises[`d_${ds.index}`] =
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
},
|
|
523
|
-
}).catch((e) => {
|
|
544
|
+
deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
|
|
545
|
+
ds,
|
|
546
|
+
page.pagePath ?? basePath,
|
|
547
|
+
deferredRequest,
|
|
548
|
+
matcherCtx,
|
|
549
|
+
).catch((e) => {
|
|
524
550
|
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
525
551
|
return null;
|
|
526
552
|
});
|
|
@@ -539,6 +565,7 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
539
565
|
rawProps: ds.rawProps,
|
|
540
566
|
pagePath: page.pagePath ?? basePath,
|
|
541
567
|
pageUrl: page.pageUrl,
|
|
568
|
+
index: ds.index,
|
|
542
569
|
},
|
|
543
570
|
}).catch(() => null),
|
|
544
571
|
),
|
|
@@ -601,16 +628,24 @@ export function cmsHomeRouteConfig(options: {
|
|
|
601
628
|
|
|
602
629
|
// SSR: create unawaited promises for TanStack native streaming
|
|
603
630
|
if (isServer && page.deferredSections?.length) {
|
|
631
|
+
const originRequest = getRequest();
|
|
632
|
+
const serverUrl = getRequestUrl();
|
|
633
|
+
const matcherCtx: MatcherContext = {
|
|
634
|
+
userAgent: getRequestHeader("user-agent") ?? "",
|
|
635
|
+
url: page.pageUrl ?? serverUrl.toString(),
|
|
636
|
+
path: "/",
|
|
637
|
+
cookies: getCookies(),
|
|
638
|
+
request: originRequest,
|
|
639
|
+
};
|
|
640
|
+
const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
|
|
641
|
+
headers: originRequest.headers,
|
|
642
|
+
});
|
|
643
|
+
|
|
604
644
|
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
605
645
|
for (const ds of page.deferredSections) {
|
|
606
|
-
deferredPromises[`d_${ds.index}`] =
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
rawProps: ds.rawProps,
|
|
610
|
-
pagePath: "/",
|
|
611
|
-
pageUrl: page.pageUrl,
|
|
612
|
-
},
|
|
613
|
-
}).catch((e) => {
|
|
646
|
+
deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
|
|
647
|
+
ds, "/", deferredRequest, matcherCtx,
|
|
648
|
+
).catch((e) => {
|
|
614
649
|
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
615
650
|
return null;
|
|
616
651
|
});
|
|
@@ -628,6 +663,7 @@ export function cmsHomeRouteConfig(options: {
|
|
|
628
663
|
rawProps: ds.rawProps,
|
|
629
664
|
pagePath: "/",
|
|
630
665
|
pageUrl: page.pageUrl,
|
|
666
|
+
index: ds.index,
|
|
631
667
|
},
|
|
632
668
|
}).catch(() => null),
|
|
633
669
|
),
|