@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -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 "{component}".
|
|
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
|
+
}
|