@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.
- package/docs/hydration-and-ssr-migration.md +616 -0
- package/docs/next-steps-tanstack-native.md +333 -0
- package/package.json +8 -2
- package/src/cms/index.ts +1 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +7 -0
- package/src/cms/resolve.test.ts +67 -0
- package/src/cms/resolve.ts +29 -1
- package/src/hooks/DecoPageRenderer.tsx +125 -34
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/validateSection.test.ts +117 -0
- package/src/middleware/validateSection.ts +91 -0
- package/src/routes/cmsRoute.ts +134 -4
- package/src/routes/components.tsx +21 -3
- package/src/sdk/index.ts +2 -1
- package/src/sdk/useDevice.test.ts +104 -0
- package/src/sdk/useDevice.ts +28 -6
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/useScript.test.ts +53 -0
- package/src/sdk/useScript.ts +49 -1
- package/vitest.config.ts +9 -0
|
@@ -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
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|
package/src/middleware/index.ts
CHANGED
|
@@ -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
|
+
}
|