@decocms/start 0.28.1 → 0.28.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -42,15 +42,20 @@ function syncThenable(mod: {
42
42
 
43
43
  function getLazyComponent(key: string) {
44
44
  if (!lazyCache.has(key)) {
45
+ // If sync-registered, wrap in a pre-fulfilled lazy so React.lazy
46
+ // resolves synchronously — same tree structure as lazy-only path.
47
+ const sync = getSyncComponent(key);
48
+ if (sync) {
49
+ lazyCache.set(key, lazy(() => syncThenable({ default: sync })));
50
+ return lazyCache.get(key)!;
51
+ }
52
+
45
53
  const registry = getSectionRegistry();
46
54
  const loader = registry[key];
47
55
  if (!loader) return null;
48
56
  lazyCache.set(
49
57
  key,
50
58
  lazy(() => {
51
- // If already resolved (from preloadSectionComponents on server,
52
- // or from route loader on client SPA), return a sync thenable.
53
- // React reads thenable.status/value synchronously — no Suspense.
54
59
  const resolved = getResolvedComponent(key);
55
60
  if (resolved) {
56
61
  return syncThenable({ default: resolved });
@@ -409,6 +414,25 @@ function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[])
409
414
  // DecoPageRenderer — renders top-level resolved sections from a CMS page
410
415
  // ---------------------------------------------------------------------------
411
416
 
417
+ // ---------------------------------------------------------------------------
418
+ // IdleHydrationBoundary — delays hydration of below-fold sections until idle
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function IdleHydrationBoundary({ children }: { children: ReactNode }) {
422
+ const [ready, setReady] = useState(typeof document === "undefined"); // SSR: always ready
423
+ useEffect(() => {
424
+ if (typeof requestIdleCallback === "function") {
425
+ const id = requestIdleCallback(() => setReady(true));
426
+ return () => cancelIdleCallback(id);
427
+ }
428
+ // Fallback for browsers without requestIdleCallback
429
+ const id = setTimeout(() => setReady(true), 50);
430
+ return () => clearTimeout(id);
431
+ }, []);
432
+ // Before ready, return null — React preserves SSR HTML via Suspense fallback={null}
433
+ return ready ? <>{children}</> : null;
434
+ }
435
+
412
436
  interface Props {
413
437
  sections: ResolvedSection[];
414
438
  deferredSections?: DeferredSection[];
@@ -470,32 +494,27 @@ export function DecoPageRenderer({
470
494
  .replace(/\.tsx$/, "")
471
495
  .replace(/^site-sections-/, "");
472
496
 
473
- // Only use sync path for sections explicitly registered via registerSectionsSync.
474
- // DO NOT fallback to getResolvedComponent: that is populated server-side by
475
- // preloadSectionComponents but NOT on the client, causing hydration mismatches
476
- // (server renders <ul>, client renders <Suspense> for the same component).
477
- const SyncComp = getSyncComponent(section.component);
478
- if (SyncComp) {
479
- return (
480
- <section key={`${section.key}-${index}`} id={sectionId} data-manifest-key={section.key}>
481
- <SectionErrorBoundary sectionKey={section.key} fallback={errFallback}>
482
- <SyncComp {...section.props} />
483
- </SectionErrorBoundary>
484
- </section>
485
- );
486
- }
487
-
488
- // Fallback: React.lazy with syncThenable for pre-loaded modules.
489
- // fallback={null} preserves server HTML during hydration.
497
+ // Unified render path: always use React.lazy + Suspense.
498
+ // For sync-registered components, getLazyComponent wraps them in a
499
+ // pre-fulfilled lazy (via syncThenable) so React renders them
500
+ // synchronously same behavior as the old sync path, but with an
501
+ // identical tree structure on both server and client (always has
502
+ // <Suspense>). This prevents hydration mismatches when sites remove
503
+ // registerSectionsSync.
490
504
  const LazyComponent = getLazyComponent(section.component);
491
505
  if (!LazyComponent) return null;
492
506
 
507
+ const isAboveFold = item.originalIndex < 3;
508
+ const content = (
509
+ <Suspense fallback={null}>
510
+ <LazyComponent {...section.props} />
511
+ </Suspense>
512
+ );
513
+
493
514
  return (
494
515
  <section key={`${section.key}-${index}`} id={sectionId} data-manifest-key={section.key}>
495
516
  <SectionErrorBoundary sectionKey={section.key} fallback={errFallback}>
496
- <Suspense fallback={null}>
497
- <LazyComponent {...section.props} />
498
- </Suspense>
517
+ {isAboveFold ? content : <IdleHydrationBoundary>{content}</IdleHydrationBoundary>}
499
518
  </SectionErrorBoundary>
500
519
  </section>
501
520
  );
@@ -50,6 +50,19 @@ import { type Device, detectDevice } from "../sdk/useDevice";
50
50
 
51
51
  const isServer = typeof document === "undefined";
52
52
 
53
+ // Section chunk manifest — maps section keys to chunk filenames.
54
+ // Generated by decoVitePlugin's generateBundle hook. Used to emit
55
+ // <link rel="modulepreload"> hints for eager sections.
56
+ let sectionChunkMap: Record<string, string> = {};
57
+
58
+ /**
59
+ * Set the section chunk manifest. Called by the site's worker entry
60
+ * or setup after loading the manifest.
61
+ */
62
+ export function setSectionChunkMap(map: Record<string, string>): void {
63
+ sectionChunkMap = map;
64
+ }
65
+
53
66
  // ---------------------------------------------------------------------------
54
67
  // Server function — loads a CMS page, runs section loaders, detects cache
55
68
  // ---------------------------------------------------------------------------
@@ -251,6 +264,7 @@ type CmsPageLoaderData = {
251
264
  cacheProfile?: CacheProfile;
252
265
  seo?: PageSeo;
253
266
  device?: Device;
267
+ resolvedSections?: Array<{ component: string }>;
254
268
  } | null;
255
269
 
256
270
  // ---------------------------------------------------------------------------
@@ -386,6 +400,16 @@ function buildHead(
386
400
  links.push({ rel: "canonical", href: canonical });
387
401
  }
388
402
 
403
+ // Modulepreload hints for eager section chunks
404
+ if (loaderData?.resolvedSections) {
405
+ for (const s of loaderData.resolvedSections) {
406
+ const chunkFile = sectionChunkMap[s.component];
407
+ if (chunkFile) {
408
+ links.push({ rel: "modulepreload", href: `/${chunkFile}` });
409
+ }
410
+ }
411
+ }
412
+
389
413
  // JSON-LD structured data — rendered as <script type="application/ld+json"> in <head>
390
414
  const scripts: Array<{ type: string; children: string }> = [];
391
415
  if (seo?.jsonLDs?.length) {
@@ -11,6 +11,7 @@ export {
11
11
  loadCmsHomePage,
12
12
  loadCmsPage,
13
13
  loadDeferredSection,
14
+ setSectionChunkMap,
14
15
  } from "./cmsRoute";
15
16
  export { CmsPage, NotFoundPage } from "./components";
16
17
  export type { PageSeo } from "../cms/resolve";
@@ -88,6 +88,27 @@ export function decoVitePlugin() {
88
88
  env.optimizeDeps.esbuildOptions.jsxImportSource = "react";
89
89
  }
90
90
  },
91
+
92
+ generateBundle(_, bundle) {
93
+ // Build a mapping from section key to chunk filename.
94
+ // Sites use this to emit <link rel="modulepreload"> for eager sections.
95
+ const map = {};
96
+ for (const [fileName, chunk] of Object.entries(bundle)) {
97
+ if (chunk.type === "chunk" && chunk.facadeModuleId) {
98
+ const match = chunk.facadeModuleId.match(/\/(sections\/.+\.tsx)$/);
99
+ if (match) {
100
+ map["site/" + match[1]] = fileName;
101
+ }
102
+ }
103
+ }
104
+ if (Object.keys(map).length > 0) {
105
+ this.emitFile({
106
+ type: "asset",
107
+ fileName: "section-chunks.json",
108
+ source: JSON.stringify(map),
109
+ });
110
+ }
111
+ },
91
112
  };
92
113
 
93
114
  return plugin;