@decocms/tanstack 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.
@@ -0,0 +1,685 @@
1
+ import {
2
+ type ComponentType,
3
+ createElement,
4
+ lazy,
5
+ type ReactNode,
6
+ Suspense,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { Await, ClientOnly } from "@tanstack/react-router";
12
+ import type { SectionOptions } from "@decocms/blocks/cms";
13
+ import {
14
+ getResolvedComponent,
15
+ getSectionOptions,
16
+ getSectionRegistry,
17
+ getSyncComponent,
18
+ preloadSectionModule,
19
+ setResolvedComponent,
20
+ } from "@decocms/blocks/cms";
21
+ import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
22
+ import { type Device, DeviceProvider } from "@decocms/blocks/sdk/useDevice";
23
+
24
+ import { SectionErrorBoundary } from "@decocms/blocks/hooks";
25
+
26
+ type LazyComponent = ReturnType<typeof lazy>;
27
+
28
+ const lazyCache = new Map<string, LazyComponent>();
29
+
30
+ /**
31
+ * Create a React.lazy-compatible thenable that is already fulfilled.
32
+ * React internally checks `thenable.status === "fulfilled"` and reads
33
+ * `thenable.value` synchronously — no Suspense activation, no microtask.
34
+ */
35
+ function syncThenable(mod: {
36
+ default: ComponentType<any>;
37
+ }): Promise<{ default: ComponentType<any> }> {
38
+ const t = Promise.resolve(mod);
39
+ // React uses these internal properties to detect sync-resolved thenables
40
+ (t as any).status = "fulfilled";
41
+ (t as any).value = mod;
42
+ return t;
43
+ }
44
+
45
+ function getLazyComponent(key: string) {
46
+ if (!lazyCache.has(key)) {
47
+ // If sync-registered, wrap in a pre-fulfilled lazy so React.lazy
48
+ // resolves synchronously — same tree structure as lazy-only path.
49
+ const sync = getSyncComponent(key);
50
+ if (sync) {
51
+ lazyCache.set(key, lazy(() => syncThenable({ default: sync })));
52
+ return lazyCache.get(key)!;
53
+ }
54
+
55
+ const registry = getSectionRegistry();
56
+ const loader = registry[key];
57
+ if (!loader) return null;
58
+ lazyCache.set(
59
+ key,
60
+ lazy(() => {
61
+ const resolved = getResolvedComponent(key);
62
+ if (resolved) {
63
+ return syncThenable({ default: resolved });
64
+ }
65
+
66
+ return (loader as () => Promise<{ default: ComponentType<any> }>)().then((mod) => {
67
+ if (!mod?.default) {
68
+ console.error(`[DecoSection] "${key}" has no default export`, Object.keys(mod ?? {}));
69
+ return { default: () => null } as { default: ComponentType<any> };
70
+ }
71
+ setResolvedComponent(key, mod.default);
72
+ return mod as { default: ComponentType<any> };
73
+ });
74
+ }),
75
+ );
76
+ }
77
+ return lazyCache.get(key)!;
78
+ }
79
+
80
+ function DefaultSectionFallback() {
81
+ return <div className="w-full h-48 bg-base-200 animate-pulse rounded" />;
82
+ }
83
+
84
+ function NestedSectionFallback() {
85
+ return <div className="w-full h-24 bg-base-200 animate-pulse rounded" />;
86
+ }
87
+
88
+ import { isDevMode } from "@decocms/blocks/sdk/env";
89
+
90
+ const isDev = isDevMode();
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Deferred section data cache — persists resolved section props across SPA
94
+ // navigations so navigating back to a page doesn't re-fetch already-loaded
95
+ // sections. TTL is aligned with cmsRouteConfig staleTime (5 min prod / 5s dev).
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const DEFERRED_CACHE_TTL = isDev ? 5_000 : 5 * 60 * 1000;
99
+
100
+ interface DeferredCacheEntry {
101
+ section: ResolvedSection;
102
+ ts: number;
103
+ }
104
+
105
+ const deferredSectionCache = new Map<string, DeferredCacheEntry>();
106
+
107
+ function getCachedDeferredSection(stableKey: string): ResolvedSection | null {
108
+ const entry = deferredSectionCache.get(stableKey);
109
+ if (!entry) return null;
110
+ if (Date.now() - entry.ts > DEFERRED_CACHE_TTL) {
111
+ deferredSectionCache.delete(stableKey);
112
+ return null;
113
+ }
114
+ return entry.section;
115
+ }
116
+
117
+
118
+ const DEFERRED_FADE_CSS = `@keyframes decoFadeIn{from{opacity:0}to{opacity:1}}`;
119
+
120
+ function FadeInStyle() {
121
+ return <style dangerouslySetInnerHTML={{ __html: DEFERRED_FADE_CSS }} />;
122
+ }
123
+
124
+ function DevMissingFallbackWarning({ component }: { component: string }) {
125
+ if (!isDev) return null;
126
+ return (
127
+ <div
128
+ style={{
129
+ position: "relative",
130
+ border: "2px dashed #e53e3e",
131
+ borderRadius: 8,
132
+ padding: 8,
133
+ margin: "4px 0",
134
+ }}
135
+ >
136
+ <div
137
+ style={{
138
+ fontSize: 11,
139
+ color: "#e53e3e",
140
+ fontFamily: "monospace",
141
+ marginBottom: 4,
142
+ }}
143
+ >
144
+ [AsyncRender] Missing LoadingFallback for &quot;{component}&quot;.
145
+ <br />
146
+ Export a LoadingFallback from your section for better UX.
147
+ <br />
148
+ See: https://deco.cx/docs/async-rendering
149
+ </div>
150
+ <DefaultSectionFallback />
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Section type — same shape as deco-cx/deco (Fresh): { Component, props }
157
+ // ---------------------------------------------------------------------------
158
+
159
+ interface Section {
160
+ Component: string | ComponentType<any>;
161
+ props: Record<string, unknown>;
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // SectionRenderer — renders a single nested section
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export function SectionRenderer({ section }: { section: Section | null | undefined }) {
169
+ if (!section?.Component) return null;
170
+
171
+ if (typeof section.Component === "function") {
172
+ const Comp = section.Component;
173
+ return <Comp {...(section.props ?? {})} />;
174
+ }
175
+
176
+ // Sync path: render directly if available — avoids React.lazy SSR streaming issue
177
+ const options = getSectionOptions(section.Component);
178
+ const isClientOnly = options?.clientOnly === true;
179
+ const SyncComp = getSyncComponent(section.Component);
180
+ if (SyncComp && !isClientOnly) {
181
+ return createElement(SyncComp, section.props ?? {});
182
+ }
183
+
184
+ const Lazy = getLazyComponent(section.Component);
185
+ if (!Lazy) {
186
+ console.warn(`[SectionRenderer] No component registered for: ${section.Component}`);
187
+ return null;
188
+ }
189
+
190
+ const fallback = options?.loadingFallback
191
+ ? createElement(options.loadingFallback, section.props ?? {})
192
+ : <NestedSectionFallback />;
193
+
194
+ if (isClientOnly) {
195
+ return (
196
+ <ClientOnly fallback={fallback}>
197
+ <Suspense fallback={null}>
198
+ <Lazy {...(section.props ?? {})} />
199
+ </Suspense>
200
+ </ClientOnly>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <Suspense fallback={fallback}>
206
+ <Lazy {...(section.props ?? {})} />
207
+ </Suspense>
208
+ );
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // SectionList — renders an array of nested sections
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export function SectionList({ sections }: { sections: Section[] | null | undefined }) {
216
+ if (!sections?.length) return null;
217
+ return (
218
+ <>
219
+ {sections.map((section, i) => {
220
+ const key = typeof section.Component === "string" ? section.Component : `nested-${i}`;
221
+ return <SectionRenderer key={key} section={section} />;
222
+ })}
223
+ </>
224
+ );
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // DeferredSectionWrapper — loads a section when it scrolls into view
229
+ // ---------------------------------------------------------------------------
230
+
231
+ interface DeferredSectionWrapperProps {
232
+ deferred: DeferredSection;
233
+ pagePath: string;
234
+ pageUrl?: string;
235
+ loadingFallback?: ReactNode;
236
+ errorFallback?: ReactNode;
237
+ loadFn: (data: {
238
+ component: string;
239
+ rawProps?: Record<string, unknown>;
240
+ pagePath: string;
241
+ pageUrl?: string;
242
+ index?: number;
243
+ }) => Promise<ResolvedSection | null>;
244
+ }
245
+
246
+ function DeferredSectionWrapper({
247
+ deferred,
248
+ pagePath,
249
+ pageUrl,
250
+ loadingFallback,
251
+ errorFallback,
252
+ loadFn,
253
+ }: DeferredSectionWrapperProps) {
254
+ const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${deferred.propsHash ?? ""}`;
255
+ const [section, setSection] = useState<ResolvedSection | null>(() =>
256
+ typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
257
+ );
258
+ const [error, setError] = useState<Error | null>(null);
259
+ const [loadedOptions, setLoadedOptions] = useState<SectionOptions | undefined>(() =>
260
+ getSectionOptions(deferred.component),
261
+ );
262
+ const isSSR = typeof document === "undefined";
263
+ // Allow SSR to render the loadingFallback when registered sync via
264
+ // registerSection(). Previous `isSSR ? false` always returned null,
265
+ // hiding the skeleton from the HTML stream.
266
+ const [optionsReady, setOptionsReady] = useState(() =>
267
+ !!getSectionOptions(deferred.component),
268
+ );
269
+ const ref = useRef<HTMLDivElement>(null);
270
+ const triggered = useRef(false);
271
+ const prevKeyRef = useRef(stableKey);
272
+
273
+ if (prevKeyRef.current !== stableKey) {
274
+ prevKeyRef.current = stableKey;
275
+ triggered.current = false;
276
+ const cached = getCachedDeferredSection(stableKey);
277
+ if (section !== cached) setSection(cached);
278
+ if (error) setError(null);
279
+ }
280
+
281
+ useEffect(() => {
282
+ if (optionsReady) return;
283
+ preloadSectionModule(deferred.component).then((opts) => {
284
+ if (opts) setLoadedOptions(opts);
285
+ setOptionsReady(true);
286
+ });
287
+ }, [deferred.component, optionsReady]);
288
+
289
+ const hasCustomFallback = !!loadedOptions?.loadingFallback;
290
+ const skeleton = !optionsReady
291
+ ? null
292
+ : hasCustomFallback
293
+ ? createElement(loadedOptions!.loadingFallback!, deferred.rawProps)
294
+ : (loadingFallback ??
295
+ (isDev ? (
296
+ <DevMissingFallbackWarning component={deferred.component} />
297
+ ) : (
298
+ <DefaultSectionFallback />
299
+ )));
300
+
301
+ useEffect(() => {
302
+ if (triggered.current || section) return;
303
+
304
+ const el = ref.current;
305
+ if (!el) return;
306
+
307
+ if (typeof IntersectionObserver === "undefined") {
308
+ triggered.current = true;
309
+ const key0 = stableKey;
310
+ loadFn({
311
+ component: deferred.component,
312
+ pagePath,
313
+ pageUrl,
314
+ index: deferred.index,
315
+ })
316
+ .then((result) => {
317
+ if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
318
+ setSection(result);
319
+ })
320
+ .catch((e) => setError(e));
321
+ return;
322
+ }
323
+
324
+ const observer = new IntersectionObserver(
325
+ ([entry]) => {
326
+ if (entry?.isIntersecting && !triggered.current) {
327
+ triggered.current = true;
328
+ observer.disconnect();
329
+ const key1 = stableKey;
330
+ loadFn({
331
+ component: deferred.component,
332
+ pagePath,
333
+ pageUrl,
334
+ index: deferred.index,
335
+ })
336
+ .then((result) => {
337
+ if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
338
+ setSection(result);
339
+ })
340
+ .catch((e) => setError(e));
341
+ }
342
+ },
343
+ { rootMargin: "300px" },
344
+ );
345
+
346
+ observer.observe(el);
347
+ return () => observer.disconnect();
348
+ }, [deferred.component, deferred.index, deferred.propsHash, pagePath, pageUrl, section, loadFn]);
349
+
350
+ if (error) {
351
+ const errFallback = loadedOptions?.errorFallback
352
+ ? createElement(loadedOptions.errorFallback, { error })
353
+ : errorFallback;
354
+ return <>{errFallback ?? null}</>;
355
+ }
356
+
357
+ if (section) {
358
+ const sectionId = section.key
359
+ .replace(/\//g, "-")
360
+ .replace(/\.tsx$/, "")
361
+ .replace(/^site-sections-/, "");
362
+
363
+ // Sync path: render directly without Suspense. React 19 SSR streaming
364
+ // omits the <!--$--> markers when a Suspense boundary resolves sync,
365
+ // which then triggers a hydration mismatch (minified error #418).
366
+ // See also the matching bifurcation in the main render path and the
367
+ // Await-resolved path elsewhere in this file.
368
+ const SyncComp = getSyncComponent(section.component);
369
+ if (SyncComp) {
370
+ return (
371
+ <section
372
+ id={sectionId}
373
+ data-manifest-key={section.key}
374
+ style={{ animation: "decoFadeIn 0.3s ease-out" }}
375
+ >
376
+ <SectionErrorBoundary sectionKey={section.key} fallback={errorFallback}>
377
+ {createElement(SyncComp, section.props)}
378
+ </SectionErrorBoundary>
379
+ </section>
380
+ );
381
+ }
382
+
383
+ const LazyComponent = getLazyComponent(section.component);
384
+ if (!LazyComponent) return null;
385
+
386
+ return (
387
+ <section
388
+ id={sectionId}
389
+ data-manifest-key={section.key}
390
+ style={{ animation: "decoFadeIn 0.3s ease-out" }}
391
+ >
392
+ <SectionErrorBoundary sectionKey={section.key} fallback={errorFallback}>
393
+ <Suspense fallback={skeleton}>
394
+ <LazyComponent {...section.props} />
395
+ </Suspense>
396
+ </SectionErrorBoundary>
397
+ </section>
398
+ );
399
+ }
400
+
401
+ const sectionId = deferred.key
402
+ .replace(/\//g, "-")
403
+ .replace(/\.tsx$/, "")
404
+ .replace(/^site-sections-/, "");
405
+
406
+ return (
407
+ <section ref={ref} id={sectionId} data-manifest-key={deferred.key} data-deferred="true">
408
+ {skeleton}
409
+ </section>
410
+ );
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // DeferredSectionSkeleton — resolves the best fallback for a deferred section
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function DeferredSectionSkeleton({
418
+ deferred,
419
+ fallback,
420
+ }: {
421
+ deferred: DeferredSection;
422
+ fallback?: ReactNode;
423
+ }) {
424
+ const options = getSectionOptions(deferred.component);
425
+ if (options?.loadingFallback) {
426
+ // rawProps are no longer serialized to the client — pass empty object.
427
+ // LoadingFallback components should be pure layout skeletons.
428
+ return createElement(options.loadingFallback, deferred.rawProps ?? {});
429
+ }
430
+ if (fallback) return <>{fallback}</>;
431
+ if (isDev) return <DevMissingFallbackWarning component={deferred.component} />;
432
+ return <DefaultSectionFallback />;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Merge helper — combines eager and deferred sections in original order
437
+ // ---------------------------------------------------------------------------
438
+
439
+ type PageItem =
440
+ | { type: "eager"; section: ResolvedSection; originalIndex: number }
441
+ | { type: "deferred"; deferred: DeferredSection };
442
+
443
+ function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
444
+ if (!resolved?.length && !deferred?.length) return [];
445
+ const safeResolved = resolved ?? [];
446
+ const safeDeferred = deferred ?? [];
447
+
448
+ if (!safeDeferred.length) {
449
+ return safeResolved.map((s, i) => ({ type: "eager", section: s, originalIndex: i }));
450
+ }
451
+
452
+ // Use the `index` property stamped by resolveDecoPage to sort all
453
+ // sections (eager + deferred) back into their original CMS order.
454
+ const items: (PageItem & { _sort: number })[] = [];
455
+
456
+ for (let i = 0; i < safeResolved.length; i++) {
457
+ const s = safeResolved[i];
458
+ items.push({ type: "eager", section: s, originalIndex: i, _sort: s.index ?? i });
459
+ }
460
+
461
+ for (const d of safeDeferred) {
462
+ items.push({ type: "deferred", deferred: d, _sort: d.index } as PageItem & { _sort: number });
463
+ }
464
+
465
+ items.sort((a, b) => a._sort - b._sort);
466
+
467
+ return items;
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // DecoPageRenderer — renders top-level resolved sections from a CMS page
472
+ // ---------------------------------------------------------------------------
473
+
474
+
475
+ interface Props {
476
+ sections: ResolvedSection[];
477
+ deferredSections?: DeferredSection[];
478
+ /**
479
+ * Unawaited promises for deferred sections, keyed by `d_<index>`.
480
+ * Created by the route loader for TanStack native SSR streaming.
481
+ * When provided, takes precedence over `loadDeferredSectionFn`.
482
+ */
483
+ deferredPromises?: Record<string, Promise<ResolvedSection | null>>;
484
+ pagePath?: string;
485
+ /** Original page URL (with query params) — forwarded to deferred section loaders. */
486
+ pageUrl?: string;
487
+ /**
488
+ * Server-resolved device, serialized into the page loader data. When set, it
489
+ * seeds `DeviceProvider` so client hydration reads the exact value the server
490
+ * used — making `useDevice()` hydration-stable. Omit to fall back to runtime
491
+ * resolution (backward compatible).
492
+ */
493
+ device?: Device;
494
+ loadingFallback?: ReactNode;
495
+ errorFallback?: ReactNode;
496
+ /** @deprecated Use deferredPromises instead (TanStack native streaming). */
497
+ loadDeferredSectionFn?: (data: {
498
+ component: string;
499
+ rawProps?: Record<string, unknown>;
500
+ pagePath: string;
501
+ pageUrl?: string;
502
+ index?: number;
503
+ }) => Promise<ResolvedSection | null>;
504
+ }
505
+
506
+ export function DecoPageRenderer({
507
+ sections,
508
+ deferredSections,
509
+ deferredPromises,
510
+ pagePath = "/",
511
+ pageUrl,
512
+ device,
513
+ loadingFallback,
514
+ errorFallback,
515
+ loadDeferredSectionFn,
516
+ }: Props) {
517
+ const items = mergeSections(sections ?? [], deferredSections ?? []);
518
+ const hasDeferred = deferredSections && deferredSections.length > 0;
519
+
520
+ // Seed DeviceProvider with the server-resolved `device` from the page loader
521
+ // (serialized into the hydration payload). Both the SSR render and client
522
+ // hydration then read the SAME value, instead of the client re-resolving via
523
+ // navigator.userAgent and descendants re-reading AsyncLocalStorage — which is
524
+ // lost across streaming-SSR Suspense boundaries and silently falls back to
525
+ // "desktop", causing React #418 hydration mismatches (#278, #231). When
526
+ // `device` is absent (custom roots), DeviceProvider falls back to runtime
527
+ // resolution.
528
+ return (
529
+ <DeviceProvider value={device}>
530
+ {hasDeferred && <FadeInStyle />}
531
+ {items.map((item, index) => {
532
+ if (item.type === "deferred") {
533
+ const promiseKey = `d_${item.deferred.index}`;
534
+ const promise = deferredPromises?.[promiseKey];
535
+
536
+ // TanStack native streaming path — uses <Await> for SSR-streamed data
537
+ if (promise) {
538
+ const deferredSectionId = item.deferred.key
539
+ .replace(/\//g, "-")
540
+ .replace(/\.tsx$/, "")
541
+ .replace(/^site-sections-/, "");
542
+
543
+ return (
544
+ <SectionErrorBoundary
545
+ key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
546
+ sectionKey={item.deferred.key}
547
+ fallback={errorFallback}
548
+ >
549
+ <Suspense fallback={
550
+ <section id={deferredSectionId} data-manifest-key={item.deferred.key} data-deferred="true">
551
+ <DeferredSectionSkeleton deferred={item.deferred} fallback={loadingFallback} />
552
+ </section>
553
+ }>
554
+ <Await promise={promise}>
555
+ {(resolved) => {
556
+ if (!resolved) return null;
557
+ const resolvedOptions = getSectionOptions(resolved.component);
558
+ const isClientOnly = resolvedOptions?.clientOnly === true;
559
+ const SyncComp = getSyncComponent(resolved.component);
560
+ const sectionId = resolved.key
561
+ .replace(/\//g, "-")
562
+ .replace(/\.tsx$/, "")
563
+ .replace(/^site-sections-/, "");
564
+
565
+ let inner: ReactNode;
566
+
567
+ if (SyncComp && !isClientOnly) {
568
+ // Sync path: direct render, no lazy/Suspense.
569
+ inner = createElement(SyncComp, resolved.props);
570
+ } else {
571
+ const LazyComponent = getLazyComponent(resolved.component);
572
+ if (!LazyComponent) return null;
573
+
574
+ const fallbackEl = resolvedOptions?.loadingFallback
575
+ ? createElement(resolvedOptions.loadingFallback, resolved.props)
576
+ : null;
577
+
578
+ inner = isClientOnly ? (
579
+ <ClientOnly fallback={fallbackEl}>
580
+ <Suspense fallback={null}>
581
+ <LazyComponent {...resolved.props} />
582
+ </Suspense>
583
+ </ClientOnly>
584
+ ) : (
585
+ <Suspense fallback={null}>
586
+ <LazyComponent {...resolved.props} />
587
+ </Suspense>
588
+ );
589
+ }
590
+
591
+ return (
592
+ <section
593
+ id={sectionId}
594
+ data-manifest-key={resolved.key}
595
+ style={{ animation: "decoFadeIn 0.3s ease-out" }}
596
+ >
597
+ {inner}
598
+ </section>
599
+ );
600
+ }}
601
+ </Await>
602
+ </Suspense>
603
+ </SectionErrorBoundary>
604
+ );
605
+ }
606
+
607
+ // Fallback: legacy POST-based IntersectionObserver path
608
+ if (loadDeferredSectionFn) {
609
+ return (
610
+ <DeferredSectionWrapper
611
+ key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
612
+ deferred={item.deferred}
613
+ pagePath={pagePath}
614
+ pageUrl={pageUrl}
615
+ loadingFallback={loadingFallback}
616
+ errorFallback={errorFallback}
617
+ loadFn={loadDeferredSectionFn}
618
+ />
619
+ );
620
+ }
621
+ return null;
622
+ }
623
+
624
+ const { section } = item;
625
+
626
+ const options = getSectionOptions(section.component);
627
+ const errFallback = options?.errorFallback
628
+ ? createElement(options.errorFallback, { error: new Error("") })
629
+ : errorFallback;
630
+
631
+ const sectionId = section.key
632
+ .replace(/\//g, "-")
633
+ .replace(/\.tsx$/, "")
634
+ .replace(/^site-sections-/, "");
635
+
636
+ const isClientOnly = options?.clientOnly === true;
637
+ const SyncComponent = getSyncComponent(section.component);
638
+
639
+ let content: ReactNode;
640
+
641
+ if (SyncComponent && !isClientOnly) {
642
+ // Sync path: render directly — no Suspense, no lazy.
643
+ // React SSR streaming ignores syncThenable's pre-fulfilled status
644
+ // and creates empty <template> placeholders. Direct createElement avoids this.
645
+ content = createElement(SyncComponent, section.props);
646
+ } else {
647
+ const LazyComponent = getLazyComponent(section.component);
648
+ if (!LazyComponent) return null;
649
+
650
+ const fallbackEl = options?.loadingFallback
651
+ ? createElement(options.loadingFallback, section.props)
652
+ : null;
653
+
654
+ content = isClientOnly ? (
655
+ <ClientOnly fallback={fallbackEl}>
656
+ <Suspense fallback={null}>
657
+ <LazyComponent {...section.props} />
658
+ </Suspense>
659
+ </ClientOnly>
660
+ ) : (
661
+ <Suspense fallback={null}>
662
+ <LazyComponent {...section.props} />
663
+ </Suspense>
664
+ );
665
+ }
666
+
667
+ // Dev warning: eager section not sync-registered may blank during hydration
668
+ if (isDev && !isClientOnly && !getSyncComponent(section.component)) {
669
+ console.warn(
670
+ `[DecoPageRenderer] Eager section "${section.component}" is not in registerSectionsSync(). ` +
671
+ `This may cause blank content during hydration. Add it to registerSectionsSync() in setup.ts.`,
672
+ );
673
+ }
674
+
675
+ return (
676
+ <section key={`${section.key}-${index}`} id={sectionId} data-manifest-key={section.key}>
677
+ <SectionErrorBoundary sectionKey={section.key} fallback={errFallback}>
678
+ {content}
679
+ </SectionErrorBoundary>
680
+ </section>
681
+ );
682
+ })}
683
+ </DeviceProvider>
684
+ );
685
+ }