@decocms/start 0.28.3 → 0.29.1

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
  // ---------------------------------------------------------------------------
@@ -418,11 +439,18 @@ function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[])
418
439
  interface Props {
419
440
  sections: ResolvedSection[];
420
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>>;
421
448
  pagePath?: string;
422
449
  /** Original page URL (with query params) — forwarded to deferred section loaders. */
423
450
  pageUrl?: string;
424
451
  loadingFallback?: ReactNode;
425
452
  errorFallback?: ReactNode;
453
+ /** @deprecated Use deferredPromises instead (TanStack native streaming). */
426
454
  loadDeferredSectionFn?: (data: {
427
455
  component: string;
428
456
  rawProps: Record<string, unknown>;
@@ -434,6 +462,7 @@ interface Props {
434
462
  export function DecoPageRenderer({
435
463
  sections,
436
464
  deferredSections,
465
+ deferredPromises,
437
466
  pagePath = "/",
438
467
  pageUrl,
439
468
  loadingFallback,
@@ -448,20 +477,80 @@ export function DecoPageRenderer({
448
477
  {hasDeferred && <FadeInStyle />}
449
478
  {items.map((item, index) => {
450
479
  if (item.type === "deferred") {
451
- if (!loadDeferredSectionFn) {
452
- 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
+ );
453
537
  }
454
- return (
455
- <DeferredSectionWrapper
456
- key={`deferred-${pagePath}-${item.deferred.key}-${item.deferred.index}`}
457
- deferred={item.deferred}
458
- pagePath={pagePath}
459
- pageUrl={pageUrl}
460
- loadingFallback={loadingFallback}
461
- errorFallback={errorFallback}
462
- loadFn={loadDeferredSectionFn}
463
- />
464
- );
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;
465
554
  }
466
555
 
467
556
  const { section } = item;
@@ -485,12 +574,34 @@ export function DecoPageRenderer({
485
574
  // registerSectionsSync.
486
575
  const LazyComponent = getLazyComponent(section.component);
487
576
  if (!LazyComponent) return null;
488
- const content = (
577
+
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
+ ) : (
489
592
  <Suspense fallback={null}>
490
593
  <LazyComponent {...section.props} />
491
594
  </Suspense>
492
595
  );
493
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
+
494
605
  return (
495
606
  <section key={`${section.key}-${index}`} id={sectionId} data-manifest-key={section.key}>
496
607
  <SectionErrorBoundary sectionKey={section.key} fallback={errFallback}>
@@ -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,147 @@
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("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
+
49
+ it("throws on null input", () => {
50
+ expect(() => validateDeferredSectionInput(null)).toThrow("Expected an object");
51
+ });
52
+
53
+ it("throws on undefined input", () => {
54
+ expect(() => validateDeferredSectionInput(undefined)).toThrow("Expected an object");
55
+ });
56
+
57
+ it("throws on string input", () => {
58
+ expect(() => validateDeferredSectionInput("nope")).toThrow("Expected an object");
59
+ });
60
+
61
+ it("throws when component is missing", () => {
62
+ expect(() =>
63
+ validateDeferredSectionInput({ rawProps: {}, pagePath: "/" }),
64
+ ).toThrow("component");
65
+ });
66
+
67
+ it("throws when component is not a string", () => {
68
+ expect(() =>
69
+ validateDeferredSectionInput({ component: 123, rawProps: {}, pagePath: "/" }),
70
+ ).toThrow("component");
71
+ });
72
+
73
+ it("throws when rawProps is missing", () => {
74
+ expect(() =>
75
+ validateDeferredSectionInput({ component: "X", pagePath: "/" }),
76
+ ).toThrow("rawProps");
77
+ });
78
+
79
+ it("throws when rawProps is an array", () => {
80
+ expect(() =>
81
+ validateDeferredSectionInput({ component: "X", rawProps: [1, 2], pagePath: "/" }),
82
+ ).toThrow("rawProps");
83
+ });
84
+
85
+ it("throws when rawProps is null", () => {
86
+ expect(() =>
87
+ validateDeferredSectionInput({ component: "X", rawProps: null, pagePath: "/" }),
88
+ ).toThrow("rawProps");
89
+ });
90
+
91
+ it("throws when pagePath is missing", () => {
92
+ expect(() =>
93
+ validateDeferredSectionInput({ component: "X", rawProps: {} }),
94
+ ).toThrow("pagePath");
95
+ });
96
+
97
+ it("throws when pageUrl is not a string", () => {
98
+ expect(() =>
99
+ validateDeferredSectionInput({
100
+ component: "X",
101
+ rawProps: {},
102
+ pagePath: "/",
103
+ pageUrl: 42,
104
+ }),
105
+ ).toThrow("pageUrl");
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
+ });
118
+ });
119
+
120
+ describe("createSectionValidator", () => {
121
+ it("passes when all required fields present", () => {
122
+ const validate = createSectionValidator(["title", "maxItems"]);
123
+ const result = validate({ title: "Shelf", maxItems: 8 });
124
+ expect(result).toEqual({ title: "Shelf", maxItems: 8 });
125
+ });
126
+
127
+ it("throws when a required field is missing", () => {
128
+ const validate = createSectionValidator(["title", "maxItems"]);
129
+ expect(() => validate({ title: "Shelf" })).toThrow("maxItems");
130
+ });
131
+
132
+ it("throws on null input", () => {
133
+ const validate = createSectionValidator(["title"]);
134
+ expect(() => validate(null)).toThrow("Expected an object");
135
+ });
136
+
137
+ it("allows extra fields", () => {
138
+ const validate = createSectionValidator(["title"]);
139
+ const result = validate({ title: "Hi", extra: true });
140
+ expect(result).toEqual({ title: "Hi", extra: true });
141
+ });
142
+
143
+ it("empty required fields always passes for objects", () => {
144
+ const validate = createSectionValidator([]);
145
+ expect(validate({})).toEqual({});
146
+ });
147
+ });
@@ -0,0 +1,100 @@
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
+ /** Original position in the page section list — for correct ordering after resolution. */
25
+ index?: number;
26
+ }
27
+
28
+ /**
29
+ * Validates input for deferred section loading server functions.
30
+ * Throws descriptive errors for malformed requests.
31
+ */
32
+ export function validateDeferredSectionInput(data: unknown): DeferredSectionInput {
33
+ if (!data || typeof data !== "object") {
34
+ throw new Error("[validateDeferredSectionInput] Expected an object, got " + typeof data);
35
+ }
36
+ const obj = data as Record<string, unknown>;
37
+
38
+ if (!obj.component || typeof obj.component !== "string") {
39
+ throw new Error(
40
+ "[validateDeferredSectionInput] Missing or invalid 'component' field (expected string)",
41
+ );
42
+ }
43
+
44
+ if (!obj.rawProps || typeof obj.rawProps !== "object" || Array.isArray(obj.rawProps)) {
45
+ throw new Error(
46
+ "[validateDeferredSectionInput] Missing or invalid 'rawProps' field (expected object)",
47
+ );
48
+ }
49
+
50
+ if (!obj.pagePath || typeof obj.pagePath !== "string") {
51
+ throw new Error(
52
+ "[validateDeferredSectionInput] Missing or invalid 'pagePath' field (expected string)",
53
+ );
54
+ }
55
+
56
+ if (obj.pageUrl !== undefined && typeof obj.pageUrl !== "string") {
57
+ throw new Error(
58
+ "[validateDeferredSectionInput] Invalid 'pageUrl' field (expected string or undefined)",
59
+ );
60
+ }
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
+
68
+ return {
69
+ component: obj.component as string,
70
+ rawProps: obj.rawProps as Record<string, unknown>,
71
+ pagePath: obj.pagePath as string,
72
+ pageUrl: obj.pageUrl as string | undefined,
73
+ index: obj.index as number | undefined,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Generic section props validator factory.
79
+ * Creates a validator function that checks required fields exist.
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * const validate = createSectionValidator(["title", "maxItems"]);
84
+ * const props = validate(rawInput); // throws if missing title or maxItems
85
+ * ```
86
+ */
87
+ export function createSectionValidator(requiredFields: string[]) {
88
+ return (data: unknown): Record<string, unknown> => {
89
+ if (!data || typeof data !== "object") {
90
+ throw new Error("[SectionValidator] Expected an object");
91
+ }
92
+ const obj = data as Record<string, unknown>;
93
+ for (const field of requiredFields) {
94
+ if (obj[field] === undefined) {
95
+ throw new Error(`[SectionValidator] Missing required field: "${field}"`);
96
+ }
97
+ }
98
+ return obj;
99
+ };
100
+ }