@decocms/start 0.28.2 → 0.29.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.
@@ -8,6 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  } from "react";
11
+ import { Await, ClientOnly } from "@tanstack/react-router";
11
12
  import type { SectionOptions } from "../cms/registry";
12
13
  import {
13
14
  getResolvedComponent,
@@ -375,6 +376,26 @@ function DeferredSectionWrapper({
375
376
  );
376
377
  }
377
378
 
379
+ // ---------------------------------------------------------------------------
380
+ // DeferredSectionSkeleton — resolves the best fallback for a deferred section
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function DeferredSectionSkeleton({
384
+ deferred,
385
+ fallback,
386
+ }: {
387
+ deferred: DeferredSection;
388
+ fallback?: ReactNode;
389
+ }) {
390
+ const options = getSectionOptions(deferred.component);
391
+ if (options?.loadingFallback) {
392
+ return createElement(options.loadingFallback, deferred.rawProps);
393
+ }
394
+ if (fallback) return <>{fallback}</>;
395
+ if (isDev) return <DevMissingFallbackWarning component={deferred.component} />;
396
+ return <DefaultSectionFallback />;
397
+ }
398
+
378
399
  // ---------------------------------------------------------------------------
379
400
  // Merge helper — combines eager and deferred sections in original order
380
401
  // ---------------------------------------------------------------------------
@@ -414,33 +435,22 @@ function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[])
414
435
  // DecoPageRenderer — renders top-level resolved sections from a CMS page
415
436
  // ---------------------------------------------------------------------------
416
437
 
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
438
 
436
439
  interface Props {
437
440
  sections: ResolvedSection[];
438
441
  deferredSections?: DeferredSection[];
442
+ /**
443
+ * Unawaited promises for deferred sections, keyed by `d_<index>`.
444
+ * Created by the route loader for TanStack native SSR streaming.
445
+ * When provided, takes precedence over `loadDeferredSectionFn`.
446
+ */
447
+ deferredPromises?: Record<string, Promise<ResolvedSection | null>>;
439
448
  pagePath?: string;
440
449
  /** Original page URL (with query params) — forwarded to deferred section loaders. */
441
450
  pageUrl?: string;
442
451
  loadingFallback?: ReactNode;
443
452
  errorFallback?: ReactNode;
453
+ /** @deprecated Use deferredPromises instead (TanStack native streaming). */
444
454
  loadDeferredSectionFn?: (data: {
445
455
  component: string;
446
456
  rawProps: Record<string, unknown>;
@@ -452,6 +462,7 @@ interface Props {
452
462
  export function DecoPageRenderer({
453
463
  sections,
454
464
  deferredSections,
465
+ deferredPromises,
455
466
  pagePath = "/",
456
467
  pageUrl,
457
468
  loadingFallback,
@@ -466,20 +477,80 @@ export function DecoPageRenderer({
466
477
  {hasDeferred && <FadeInStyle />}
467
478
  {items.map((item, index) => {
468
479
  if (item.type === "deferred") {
469
- if (!loadDeferredSectionFn) {
470
- return null;
480
+ const promiseKey = `d_${item.deferred.index}`;
481
+ const promise = deferredPromises?.[promiseKey];
482
+
483
+ // TanStack native streaming path — uses <Await> for SSR-streamed data
484
+ if (promise) {
485
+ const deferredSectionId = item.deferred.key
486
+ .replace(/\//g, "-")
487
+ .replace(/\.tsx$/, "")
488
+ .replace(/^site-sections-/, "");
489
+
490
+ return (
491
+ <SectionErrorBoundary
492
+ key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
493
+ sectionKey={item.deferred.key}
494
+ fallback={errorFallback}
495
+ >
496
+ <Suspense fallback={
497
+ <section id={deferredSectionId} data-manifest-key={item.deferred.key} data-deferred="true">
498
+ <DeferredSectionSkeleton deferred={item.deferred} fallback={loadingFallback} />
499
+ </section>
500
+ }>
501
+ <Await promise={promise}>
502
+ {(resolved) => {
503
+ if (!resolved) return null;
504
+ const LazyComponent = getLazyComponent(resolved.component);
505
+ if (!LazyComponent) return null;
506
+ const resolvedOptions = getSectionOptions(resolved.component);
507
+ const isClientOnly = resolvedOptions?.clientOnly === true;
508
+ const sectionId = resolved.key
509
+ .replace(/\//g, "-")
510
+ .replace(/\.tsx$/, "")
511
+ .replace(/^site-sections-/, "");
512
+
513
+ const inner = (
514
+ <Suspense fallback={null}>
515
+ <LazyComponent {...resolved.props} />
516
+ </Suspense>
517
+ );
518
+
519
+ return (
520
+ <section
521
+ id={sectionId}
522
+ data-manifest-key={resolved.key}
523
+ style={{ animation: "decoFadeIn 0.3s ease-out" }}
524
+ >
525
+ {isClientOnly ? (
526
+ <ClientOnly fallback={null}>{inner}</ClientOnly>
527
+ ) : (
528
+ inner
529
+ )}
530
+ </section>
531
+ );
532
+ }}
533
+ </Await>
534
+ </Suspense>
535
+ </SectionErrorBoundary>
536
+ );
471
537
  }
472
- return (
473
- <DeferredSectionWrapper
474
- key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
475
- deferred={item.deferred}
476
- pagePath={pagePath}
477
- pageUrl={pageUrl}
478
- loadingFallback={loadingFallback}
479
- errorFallback={errorFallback}
480
- loadFn={loadDeferredSectionFn}
481
- />
482
- );
538
+
539
+ // Fallback: legacy POST-based IntersectionObserver path
540
+ if (loadDeferredSectionFn) {
541
+ return (
542
+ <DeferredSectionWrapper
543
+ key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
544
+ deferred={item.deferred}
545
+ pagePath={pagePath}
546
+ pageUrl={pageUrl}
547
+ loadingFallback={loadingFallback}
548
+ errorFallback={errorFallback}
549
+ loadFn={loadDeferredSectionFn}
550
+ />
551
+ );
552
+ }
553
+ return null;
483
554
  }
484
555
 
485
556
  const { section } = item;
@@ -504,17 +575,37 @@ export function DecoPageRenderer({
504
575
  const LazyComponent = getLazyComponent(section.component);
505
576
  if (!LazyComponent) return null;
506
577
 
507
- const isAboveFold = item.originalIndex < 3;
508
- const content = (
578
+ // ClientOnly sections: render only on client, no SSR, no hydration mismatch.
579
+ // Used for analytics scripts, GTM, third-party widgets.
580
+ const isClientOnly = options?.clientOnly === true;
581
+ const fallbackEl = options?.loadingFallback
582
+ ? createElement(options.loadingFallback, section.props)
583
+ : null;
584
+
585
+ const content = isClientOnly ? (
586
+ <ClientOnly fallback={fallbackEl}>
587
+ <Suspense fallback={null}>
588
+ <LazyComponent {...section.props} />
589
+ </Suspense>
590
+ </ClientOnly>
591
+ ) : (
509
592
  <Suspense fallback={null}>
510
593
  <LazyComponent {...section.props} />
511
594
  </Suspense>
512
595
  );
513
596
 
597
+ // Dev warning: eager section not sync-registered may blank during hydration
598
+ if (isDev && !isClientOnly && !getSyncComponent(section.component)) {
599
+ console.warn(
600
+ `[DecoPageRenderer] Eager section "${section.component}" is not in registerSectionsSync(). ` +
601
+ `This may cause blank content during hydration. Add it to registerSectionsSync() in setup.ts.`,
602
+ );
603
+ }
604
+
514
605
  return (
515
606
  <section key={`${section.key}-${index}`} id={sectionId} data-manifest-key={section.key}>
516
607
  <SectionErrorBoundary sectionKey={section.key} fallback={errFallback}>
517
- {isAboveFold ? content : <IdleHydrationBoundary>{content}</IdleHydrationBoundary>}
608
+ {content}
518
609
  </SectionErrorBoundary>
519
610
  </section>
520
611
  );
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildHydrationContext } from "./hydrationContext";
3
+
4
+ function makeRequest(headers: Record<string, string> = {}): Request {
5
+ return new Request("https://store.com/", { headers });
6
+ }
7
+
8
+ describe("buildHydrationContext", () => {
9
+ it("extracts locale and timezone from cookies", () => {
10
+ const req = makeRequest({ cookie: "locale=pt-BR; tz=America/Sao_Paulo" });
11
+ const ctx = buildHydrationContext(req);
12
+ expect(ctx.locale).toBe("pt-BR");
13
+ expect(ctx.timeZone).toBe("America/Sao_Paulo");
14
+ });
15
+
16
+ it("falls back to Accept-Language when no locale cookie", () => {
17
+ const req = makeRequest({ "accept-language": "pt-BR,pt;q=0.9,en;q=0.8" });
18
+ const ctx = buildHydrationContext(req);
19
+ expect(ctx.locale).toBe("pt-BR");
20
+ });
21
+
22
+ it("defaults to en-US and UTC when no headers or cookies", () => {
23
+ const req = makeRequest();
24
+ const ctx = buildHydrationContext(req);
25
+ expect(ctx.locale).toBe("en-US");
26
+ expect(ctx.timeZone).toBe("UTC");
27
+ });
28
+
29
+ it("extracts country from cf-ipcountry header", () => {
30
+ const req = makeRequest({ "cf-ipcountry": "BR" });
31
+ const ctx = buildHydrationContext(req);
32
+ expect(ctx.country).toBe("BR");
33
+ });
34
+
35
+ it("extracts country from cookie when no cf header", () => {
36
+ const req = makeRequest({ cookie: "country=US" });
37
+ const ctx = buildHydrationContext(req);
38
+ expect(ctx.country).toBe("US");
39
+ });
40
+
41
+ it("country is undefined when not available", () => {
42
+ const req = makeRequest();
43
+ const ctx = buildHydrationContext(req);
44
+ expect(ctx.country).toBeUndefined();
45
+ });
46
+
47
+ it("cf-ipcountry takes precedence over cookie", () => {
48
+ const req = makeRequest({
49
+ "cf-ipcountry": "BR",
50
+ cookie: "country=US",
51
+ });
52
+ const ctx = buildHydrationContext(req);
53
+ expect(ctx.country).toBe("BR");
54
+ });
55
+
56
+ it("handles cookies with = in values", () => {
57
+ const req = makeRequest({ cookie: "locale=en-US; token=abc=def=ghi" });
58
+ const ctx = buildHydrationContext(req);
59
+ expect(ctx.locale).toBe("en-US");
60
+ });
61
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Hydration context utilities for consistent SSR/client rendering.
3
+ *
4
+ * Provides patterns for extracting locale, timezone, and other
5
+ * request-specific data that must be consistent between server
6
+ * and client to avoid hydration mismatches.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * // In your storefront's middleware.ts:
11
+ * import { createMiddleware } from "@tanstack/react-start";
12
+ * import { buildHydrationContext } from "@decocms/start/middleware/hydrationContext";
13
+ *
14
+ * export const hydrationMiddleware = createMiddleware().server(async ({ request, next }) => {
15
+ * const hydrationCtx = buildHydrationContext(request);
16
+ * return next({ context: { hydration: hydrationCtx } });
17
+ * });
18
+ * ```
19
+ *
20
+ * Then in components:
21
+ * ```tsx
22
+ * // Use the cookie-based values for deterministic SSR rendering
23
+ * const locale = hydrationCtx.locale; // same on server and client
24
+ * ```
25
+ */
26
+
27
+ export interface HydrationContext {
28
+ /** Locale from cookie or Accept-Language header. */
29
+ locale: string;
30
+ /** Timezone from cookie. Falls back to "UTC" for deterministic SSR. */
31
+ timeZone: string;
32
+ /** Country code from Cloudflare headers or cookie. */
33
+ country?: string;
34
+ }
35
+
36
+ /**
37
+ * Build hydration context from a request.
38
+ *
39
+ * Values are extracted from cookies first (set by the client on first visit),
40
+ * then from headers. Cookie-based values are deterministic because the same
41
+ * cookie is sent on both SSR and client-side navigations.
42
+ *
43
+ * Recommended: set these cookies on first client render:
44
+ * ```tsx
45
+ * useEffect(() => {
46
+ * const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
47
+ * document.cookie = `tz=${tz}; path=/; max-age=31536000; SameSite=Lax`;
48
+ * document.cookie = `locale=${navigator.language}; path=/; max-age=31536000; SameSite=Lax`;
49
+ * }, []);
50
+ * ```
51
+ */
52
+ export function buildHydrationContext(request: Request): HydrationContext {
53
+ const cookies = parseCookies(request.headers.get("cookie") ?? "");
54
+
55
+ // Locale: cookie → Accept-Language → fallback
56
+ const locale =
57
+ cookies.locale ||
58
+ request.headers.get("accept-language")?.split(",")[0]?.split(";")[0]?.trim() ||
59
+ "en-US";
60
+
61
+ // Timezone: cookie → UTC fallback (never guess — causes hydration mismatch)
62
+ const timeZone = cookies.tz || "UTC";
63
+
64
+ // Country: Cloudflare header → cookie
65
+ const country =
66
+ request.headers.get("cf-ipcountry") || cookies.country || undefined;
67
+
68
+ return { locale, timeZone, country };
69
+ }
70
+
71
+ function parseCookies(cookieHeader: string): Record<string, string> {
72
+ const cookies: Record<string, string> = {};
73
+ for (const part of cookieHeader.split(";")) {
74
+ const [key, ...rest] = part.split("=");
75
+ const k = key?.trim();
76
+ if (k) cookies[k] = rest.join("=").trim();
77
+ }
78
+ return cookies;
79
+ }
@@ -55,6 +55,13 @@ export {
55
55
  withTracing,
56
56
  } from "./observability";
57
57
 
58
+ export { buildHydrationContext, type HydrationContext } from "./hydrationContext";
59
+ export {
60
+ createSectionValidator,
61
+ type DeferredSectionInput,
62
+ validateDeferredSectionInput,
63
+ } from "./validateSection";
64
+
58
65
  /**
59
66
  * Appends Server-Timing header to a response from the accumulated timings.
60
67
  */
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createSectionValidator,
4
+ validateDeferredSectionInput,
5
+ } from "./validateSection";
6
+
7
+ describe("validateDeferredSectionInput", () => {
8
+ it("accepts valid input", () => {
9
+ const result = validateDeferredSectionInput({
10
+ component: "site/sections/ProductShelf.tsx",
11
+ rawProps: { title: "Best Sellers" },
12
+ pagePath: "/",
13
+ });
14
+ expect(result.component).toBe("site/sections/ProductShelf.tsx");
15
+ expect(result.rawProps).toEqual({ title: "Best Sellers" });
16
+ expect(result.pagePath).toBe("/");
17
+ expect(result.pageUrl).toBeUndefined();
18
+ });
19
+
20
+ it("accepts input with optional pageUrl", () => {
21
+ const result = validateDeferredSectionInput({
22
+ component: "site/sections/Hero.tsx",
23
+ rawProps: {},
24
+ pagePath: "/home",
25
+ pageUrl: "https://store.com/home?ref=nav",
26
+ });
27
+ expect(result.pageUrl).toBe("https://store.com/home?ref=nav");
28
+ });
29
+
30
+ it("throws on null input", () => {
31
+ expect(() => validateDeferredSectionInput(null)).toThrow("Expected an object");
32
+ });
33
+
34
+ it("throws on undefined input", () => {
35
+ expect(() => validateDeferredSectionInput(undefined)).toThrow("Expected an object");
36
+ });
37
+
38
+ it("throws on string input", () => {
39
+ expect(() => validateDeferredSectionInput("nope")).toThrow("Expected an object");
40
+ });
41
+
42
+ it("throws when component is missing", () => {
43
+ expect(() =>
44
+ validateDeferredSectionInput({ rawProps: {}, pagePath: "/" }),
45
+ ).toThrow("component");
46
+ });
47
+
48
+ it("throws when component is not a string", () => {
49
+ expect(() =>
50
+ validateDeferredSectionInput({ component: 123, rawProps: {}, pagePath: "/" }),
51
+ ).toThrow("component");
52
+ });
53
+
54
+ it("throws when rawProps is missing", () => {
55
+ expect(() =>
56
+ validateDeferredSectionInput({ component: "X", pagePath: "/" }),
57
+ ).toThrow("rawProps");
58
+ });
59
+
60
+ it("throws when rawProps is an array", () => {
61
+ expect(() =>
62
+ validateDeferredSectionInput({ component: "X", rawProps: [1, 2], pagePath: "/" }),
63
+ ).toThrow("rawProps");
64
+ });
65
+
66
+ it("throws when rawProps is null", () => {
67
+ expect(() =>
68
+ validateDeferredSectionInput({ component: "X", rawProps: null, pagePath: "/" }),
69
+ ).toThrow("rawProps");
70
+ });
71
+
72
+ it("throws when pagePath is missing", () => {
73
+ expect(() =>
74
+ validateDeferredSectionInput({ component: "X", rawProps: {} }),
75
+ ).toThrow("pagePath");
76
+ });
77
+
78
+ it("throws when pageUrl is not a string", () => {
79
+ expect(() =>
80
+ validateDeferredSectionInput({
81
+ component: "X",
82
+ rawProps: {},
83
+ pagePath: "/",
84
+ pageUrl: 42,
85
+ }),
86
+ ).toThrow("pageUrl");
87
+ });
88
+ });
89
+
90
+ describe("createSectionValidator", () => {
91
+ it("passes when all required fields present", () => {
92
+ const validate = createSectionValidator(["title", "maxItems"]);
93
+ const result = validate({ title: "Shelf", maxItems: 8 });
94
+ expect(result).toEqual({ title: "Shelf", maxItems: 8 });
95
+ });
96
+
97
+ it("throws when a required field is missing", () => {
98
+ const validate = createSectionValidator(["title", "maxItems"]);
99
+ expect(() => validate({ title: "Shelf" })).toThrow("maxItems");
100
+ });
101
+
102
+ it("throws on null input", () => {
103
+ const validate = createSectionValidator(["title"]);
104
+ expect(() => validate(null)).toThrow("Expected an object");
105
+ });
106
+
107
+ it("allows extra fields", () => {
108
+ const validate = createSectionValidator(["title"]);
109
+ const result = validate({ title: "Hi", extra: true });
110
+ expect(result).toEqual({ title: "Hi", extra: true });
111
+ });
112
+
113
+ it("empty required fields always passes for objects", () => {
114
+ const validate = createSectionValidator([]);
115
+ expect(validate({})).toEqual({});
116
+ });
117
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Server function input validation for section-related server functions.
3
+ *
4
+ * Validates that `loadDeferredSection` and similar server functions receive
5
+ * well-formed input before reaching section loaders. Prevents malformed
6
+ * requests from causing obscure runtime errors.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { validateDeferredSectionInput } from "@decocms/start/middleware/validateSection";
11
+ *
12
+ * // In your storefront's middleware chain:
13
+ * export const loadSection = createServerFn({ method: "POST" })
14
+ * .inputValidator(validateDeferredSectionInput)
15
+ * .handler(async (ctx) => { ... });
16
+ * ```
17
+ */
18
+
19
+ export interface DeferredSectionInput {
20
+ component: string;
21
+ rawProps: Record<string, unknown>;
22
+ pagePath: string;
23
+ pageUrl?: string;
24
+ }
25
+
26
+ /**
27
+ * Validates input for deferred section loading server functions.
28
+ * Throws descriptive errors for malformed requests.
29
+ */
30
+ export function validateDeferredSectionInput(data: unknown): DeferredSectionInput {
31
+ if (!data || typeof data !== "object") {
32
+ throw new Error("[validateDeferredSectionInput] Expected an object, got " + typeof data);
33
+ }
34
+ const obj = data as Record<string, unknown>;
35
+
36
+ if (!obj.component || typeof obj.component !== "string") {
37
+ throw new Error(
38
+ "[validateDeferredSectionInput] Missing or invalid 'component' field (expected string)",
39
+ );
40
+ }
41
+
42
+ if (!obj.rawProps || typeof obj.rawProps !== "object" || Array.isArray(obj.rawProps)) {
43
+ throw new Error(
44
+ "[validateDeferredSectionInput] Missing or invalid 'rawProps' field (expected object)",
45
+ );
46
+ }
47
+
48
+ if (!obj.pagePath || typeof obj.pagePath !== "string") {
49
+ throw new Error(
50
+ "[validateDeferredSectionInput] Missing or invalid 'pagePath' field (expected string)",
51
+ );
52
+ }
53
+
54
+ if (obj.pageUrl !== undefined && typeof obj.pageUrl !== "string") {
55
+ throw new Error(
56
+ "[validateDeferredSectionInput] Invalid 'pageUrl' field (expected string or undefined)",
57
+ );
58
+ }
59
+
60
+ return {
61
+ component: obj.component as string,
62
+ rawProps: obj.rawProps as Record<string, unknown>,
63
+ pagePath: obj.pagePath as string,
64
+ pageUrl: obj.pageUrl as string | undefined,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Generic section props validator factory.
70
+ * Creates a validator function that checks required fields exist.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * const validate = createSectionValidator(["title", "maxItems"]);
75
+ * const props = validate(rawInput); // throws if missing title or maxItems
76
+ * ```
77
+ */
78
+ export function createSectionValidator(requiredFields: string[]) {
79
+ return (data: unknown): Record<string, unknown> => {
80
+ if (!data || typeof data !== "object") {
81
+ throw new Error("[SectionValidator] Expected an object");
82
+ }
83
+ const obj = data as Record<string, unknown>;
84
+ for (const field of requiredFields) {
85
+ if (obj[field] === undefined) {
86
+ throw new Error(`[SectionValidator] Missing required field: "${field}"`);
87
+ }
88
+ }
89
+ return obj;
90
+ };
91
+ }