@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.
- 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/cms/sectionLoaders.ts +16 -0
- package/src/hooks/DecoPageRenderer.tsx +125 -14
- 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 +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/routes/cmsRoute.ts +172 -6
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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,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
|
+
}
|