@decocms/start 0.28.3 → 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 -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 +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
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
extractSeoFromSections,
|
|
37
37
|
resolveDecoPage,
|
|
38
38
|
resolveDeferredSection,
|
|
39
|
+
resolveDeferredSectionFull,
|
|
39
40
|
} from "../cms/resolve";
|
|
40
41
|
import { getSiteSeo } from "../cms/loader";
|
|
41
42
|
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
@@ -122,6 +123,7 @@ async function loadCmsPageInternal(fullPath: string) {
|
|
|
122
123
|
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
123
124
|
cacheProfile,
|
|
124
125
|
pageUrl: urlWithSearch,
|
|
126
|
+
pagePath: basePath,
|
|
125
127
|
seo,
|
|
126
128
|
device,
|
|
127
129
|
};
|
|
@@ -148,9 +150,10 @@ export const loadCmsPage = createServerFn({ method: "GET" })
|
|
|
148
150
|
export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
|
|
149
151
|
const request = getRequest();
|
|
150
152
|
const ua = getRequestHeader("user-agent") ?? "";
|
|
153
|
+
const serverUrl = getRequestUrl();
|
|
151
154
|
const matcherCtx: MatcherContext = {
|
|
152
155
|
userAgent: ua,
|
|
153
|
-
url:
|
|
156
|
+
url: serverUrl.toString(),
|
|
154
157
|
path: "/",
|
|
155
158
|
cookies: getCookies(),
|
|
156
159
|
request,
|
|
@@ -171,6 +174,8 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
171
174
|
...pageData,
|
|
172
175
|
resolvedSections: normalizeUrlsInObject(enrichedSections),
|
|
173
176
|
deferredSections: normalizeUrlsInObject(page.deferredSections),
|
|
177
|
+
pagePath: "/",
|
|
178
|
+
pageUrl: serverUrl.toString(),
|
|
174
179
|
seo,
|
|
175
180
|
device,
|
|
176
181
|
};
|
|
@@ -180,6 +185,11 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
180
185
|
// Deferred section loader — resolves + enriches a single section on demand
|
|
181
186
|
// ---------------------------------------------------------------------------
|
|
182
187
|
|
|
188
|
+
/**
|
|
189
|
+
* @deprecated Prefer TanStack native streaming via `deferredPromises` in the
|
|
190
|
+
* route loader. This POST server function is kept for backward compatibility
|
|
191
|
+
* and as a fallback for SPA navigations.
|
|
192
|
+
*/
|
|
183
193
|
export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
184
194
|
.inputValidator(
|
|
185
195
|
(data: unknown) =>
|
|
@@ -257,6 +267,26 @@ export interface CmsRouteOptions {
|
|
|
257
267
|
ignoreSearchParams?: string[];
|
|
258
268
|
/** Custom pending component shown during SPA navigation. */
|
|
259
269
|
pendingComponent?: () => any;
|
|
270
|
+
/**
|
|
271
|
+
* Delay (ms) before showing the pending component during SPA navigation.
|
|
272
|
+
* If the loader resolves before this threshold, no pending UI is shown.
|
|
273
|
+
* Prevents skeleton flash on fast cache-hit navigations. Default: 200.
|
|
274
|
+
*/
|
|
275
|
+
pendingMs?: number;
|
|
276
|
+
/**
|
|
277
|
+
* Minimum display time (ms) for the pending component once shown.
|
|
278
|
+
* Prevents jarring flash when data arrives shortly after the threshold.
|
|
279
|
+
* Default: 300.
|
|
280
|
+
*/
|
|
281
|
+
pendingMinMs?: number;
|
|
282
|
+
/**
|
|
283
|
+
* SSR mode for this route.
|
|
284
|
+
* - `true` (default): Full SSR — loader + component render on server.
|
|
285
|
+
* - `'data-only'`: Loader runs on server, component renders on client only.
|
|
286
|
+
* Use for interactive-heavy pages (PDP with zoom/variant selector).
|
|
287
|
+
* - `false`: Everything runs on client only.
|
|
288
|
+
*/
|
|
289
|
+
ssr?: boolean | "data-only";
|
|
260
290
|
}
|
|
261
291
|
|
|
262
292
|
type CmsPageLoaderData = {
|
|
@@ -441,6 +471,9 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
441
471
|
defaultDescription,
|
|
442
472
|
ignoreSearchParams = ["skuId"],
|
|
443
473
|
pendingComponent,
|
|
474
|
+
pendingMs = 200,
|
|
475
|
+
pendingMinMs = 300,
|
|
476
|
+
ssr: ssrMode,
|
|
444
477
|
} = options;
|
|
445
478
|
|
|
446
479
|
const ignoreSet = new Set(ignoreSearchParams);
|
|
@@ -467,15 +500,63 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
467
500
|
? "?" + new URLSearchParams(deps.search as Record<string, string>).toString()
|
|
468
501
|
: "";
|
|
469
502
|
const page = await loadCmsPage({ data: basePath + searchStr });
|
|
503
|
+
if (!page) return page;
|
|
470
504
|
|
|
471
|
-
if (!isServer && page
|
|
505
|
+
if (!isServer && page.resolvedSections) {
|
|
472
506
|
const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
|
|
473
507
|
await preloadSectionComponents(keys);
|
|
474
508
|
}
|
|
509
|
+
|
|
510
|
+
// SSR: create unawaited promises for TanStack native streaming.
|
|
511
|
+
// Each deferred section becomes a promise that TanStack streams
|
|
512
|
+
// via SSR chunked transfer — all resolved in the SAME request.
|
|
513
|
+
if (isServer && page.deferredSections?.length) {
|
|
514
|
+
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
515
|
+
for (const ds of page.deferredSections) {
|
|
516
|
+
deferredPromises[`d_${ds.index}`] = loadDeferredSection({
|
|
517
|
+
data: {
|
|
518
|
+
component: ds.component,
|
|
519
|
+
rawProps: ds.rawProps,
|
|
520
|
+
pagePath: page.pagePath ?? basePath,
|
|
521
|
+
pageUrl: page.pageUrl,
|
|
522
|
+
},
|
|
523
|
+
}).catch((e) => {
|
|
524
|
+
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
525
|
+
return null;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return { ...page, deferredPromises };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Client SPA navigation: resolve all deferred sections via server
|
|
532
|
+
// function batch and merge into resolvedSections for immediate render.
|
|
533
|
+
if (!isServer && page.deferredSections?.length) {
|
|
534
|
+
const resolved = await Promise.all(
|
|
535
|
+
page.deferredSections.map((ds: DeferredSection) =>
|
|
536
|
+
loadDeferredSection({
|
|
537
|
+
data: {
|
|
538
|
+
component: ds.component,
|
|
539
|
+
rawProps: ds.rawProps,
|
|
540
|
+
pagePath: page.pagePath ?? basePath,
|
|
541
|
+
pageUrl: page.pageUrl,
|
|
542
|
+
},
|
|
543
|
+
}).catch(() => null),
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
const all = [
|
|
547
|
+
...page.resolvedSections,
|
|
548
|
+
...resolved.filter((s): s is ResolvedSection => s != null),
|
|
549
|
+
].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
550
|
+
return { ...page, resolvedSections: all, deferredSections: [] };
|
|
551
|
+
}
|
|
552
|
+
|
|
475
553
|
return page;
|
|
476
554
|
},
|
|
477
555
|
|
|
478
556
|
...(pendingComponent ? { pendingComponent } : {}),
|
|
557
|
+
pendingMs,
|
|
558
|
+
pendingMinMs,
|
|
559
|
+
...(ssrMode !== undefined ? { ssr: ssrMode } : {}),
|
|
479
560
|
|
|
480
561
|
...routeCacheDefaults("product"),
|
|
481
562
|
|
|
@@ -501,19 +582,68 @@ export function cmsHomeRouteConfig(options: {
|
|
|
501
582
|
/** Site name for OG title composition. Defaults to defaultTitle. */
|
|
502
583
|
siteName?: string;
|
|
503
584
|
pendingComponent?: () => any;
|
|
585
|
+
/** Delay (ms) before showing pending component. Default: 200. */
|
|
586
|
+
pendingMs?: number;
|
|
587
|
+
/** Minimum display time (ms) for pending component. Default: 300. */
|
|
588
|
+
pendingMinMs?: number;
|
|
504
589
|
}) {
|
|
505
|
-
const { defaultTitle, defaultDescription, siteName } = options;
|
|
590
|
+
const { defaultTitle, defaultDescription, siteName, pendingMs = 200, pendingMinMs = 300 } = options;
|
|
506
591
|
|
|
507
592
|
return {
|
|
508
593
|
loader: async () => {
|
|
509
594
|
const page = await loadCmsHomePage();
|
|
510
|
-
if (!
|
|
595
|
+
if (!page) return page;
|
|
596
|
+
|
|
597
|
+
if (!isServer && page.resolvedSections) {
|
|
511
598
|
const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
|
|
512
599
|
await preloadSectionComponents(keys);
|
|
513
600
|
}
|
|
601
|
+
|
|
602
|
+
// SSR: create unawaited promises for TanStack native streaming
|
|
603
|
+
if (isServer && page.deferredSections?.length) {
|
|
604
|
+
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
605
|
+
for (const ds of page.deferredSections) {
|
|
606
|
+
deferredPromises[`d_${ds.index}`] = loadDeferredSection({
|
|
607
|
+
data: {
|
|
608
|
+
component: ds.component,
|
|
609
|
+
rawProps: ds.rawProps,
|
|
610
|
+
pagePath: "/",
|
|
611
|
+
pageUrl: page.pageUrl,
|
|
612
|
+
},
|
|
613
|
+
}).catch((e) => {
|
|
614
|
+
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
615
|
+
return null;
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return { ...page, deferredPromises };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Client SPA navigation: resolve all deferred via server function batch
|
|
622
|
+
if (!isServer && page.deferredSections?.length) {
|
|
623
|
+
const resolved = await Promise.all(
|
|
624
|
+
page.deferredSections.map((ds: DeferredSection) =>
|
|
625
|
+
loadDeferredSection({
|
|
626
|
+
data: {
|
|
627
|
+
component: ds.component,
|
|
628
|
+
rawProps: ds.rawProps,
|
|
629
|
+
pagePath: "/",
|
|
630
|
+
pageUrl: page.pageUrl,
|
|
631
|
+
},
|
|
632
|
+
}).catch(() => null),
|
|
633
|
+
),
|
|
634
|
+
);
|
|
635
|
+
const all = [
|
|
636
|
+
...page.resolvedSections,
|
|
637
|
+
...resolved.filter((s): s is ResolvedSection => s != null),
|
|
638
|
+
].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
639
|
+
return { ...page, resolvedSections: all, deferredSections: [] };
|
|
640
|
+
}
|
|
641
|
+
|
|
514
642
|
return page;
|
|
515
643
|
},
|
|
516
644
|
...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
|
|
645
|
+
pendingMs,
|
|
646
|
+
pendingMinMs,
|
|
517
647
|
...routeCacheDefaults("static"),
|
|
518
648
|
headers: () => cacheHeaders("static"),
|
|
519
649
|
head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
|
-
import type { ResolvedSection } from "../cms/resolve";
|
|
2
|
+
import type { DeferredSection, ResolvedSection } from "../cms/resolve";
|
|
3
3
|
import { DecoPageRenderer } from "../hooks/DecoPageRenderer";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Default CMS page component. Renders all resolved sections.
|
|
7
7
|
* Sites can use this directly or build their own.
|
|
8
8
|
*/
|
|
9
|
-
export function CmsPage({
|
|
9
|
+
export function CmsPage({
|
|
10
|
+
sections,
|
|
11
|
+
deferredSections,
|
|
12
|
+
deferredPromises,
|
|
13
|
+
pagePath,
|
|
14
|
+
pageUrl,
|
|
15
|
+
}: {
|
|
16
|
+
sections: ResolvedSection[];
|
|
17
|
+
deferredSections?: DeferredSection[];
|
|
18
|
+
deferredPromises?: Record<string, Promise<ResolvedSection | null>>;
|
|
19
|
+
pagePath?: string;
|
|
20
|
+
pageUrl?: string;
|
|
21
|
+
}) {
|
|
10
22
|
return (
|
|
11
23
|
<div>
|
|
12
|
-
<DecoPageRenderer
|
|
24
|
+
<DecoPageRenderer
|
|
25
|
+
sections={sections}
|
|
26
|
+
deferredSections={deferredSections}
|
|
27
|
+
deferredPromises={deferredPromises}
|
|
28
|
+
pagePath={pagePath}
|
|
29
|
+
pageUrl={pageUrl}
|
|
30
|
+
/>
|
|
13
31
|
</div>
|
|
14
32
|
);
|
|
15
33
|
}
|
package/src/sdk/index.ts
CHANGED
|
@@ -67,8 +67,9 @@ export {
|
|
|
67
67
|
TABLET_RE,
|
|
68
68
|
useDevice,
|
|
69
69
|
} from "./useDevice";
|
|
70
|
+
export { useHydrated } from "./useHydrated";
|
|
70
71
|
export { useId } from "./useId";
|
|
71
|
-
export { usePartialSection, useScript, useScriptAsDataURI, useSection } from "./useScript";
|
|
72
|
+
export { inlineScript, usePartialSection, useScript, useScriptAsDataURI, useSection } from "./useScript";
|
|
72
73
|
export { createDecoWorkerEntry, type DecoWorkerEntryOptions } from "./workerEntry";
|
|
73
74
|
export { forwardResponseCookies, getRequestCookieHeader } from "./cookiePassthrough";
|
|
74
75
|
export {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectDevice, isMobileUA, useDevice } from "./useDevice";
|
|
3
|
+
|
|
4
|
+
describe("detectDevice", () => {
|
|
5
|
+
it("detects iPhone as mobile", () => {
|
|
6
|
+
expect(
|
|
7
|
+
detectDevice(
|
|
8
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
9
|
+
),
|
|
10
|
+
).toBe("mobile");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("detects Android phone as mobile", () => {
|
|
14
|
+
expect(
|
|
15
|
+
detectDevice(
|
|
16
|
+
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
17
|
+
),
|
|
18
|
+
).toBe("mobile");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("detects iPad as tablet", () => {
|
|
22
|
+
expect(
|
|
23
|
+
detectDevice(
|
|
24
|
+
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
25
|
+
),
|
|
26
|
+
).toBe("tablet");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("detects Android tablet as tablet", () => {
|
|
30
|
+
expect(
|
|
31
|
+
detectDevice(
|
|
32
|
+
"Mozilla/5.0 (Linux; Android 14; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
33
|
+
),
|
|
34
|
+
).toBe("tablet");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("detects Chrome desktop as desktop", () => {
|
|
38
|
+
expect(
|
|
39
|
+
detectDevice(
|
|
40
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
41
|
+
),
|
|
42
|
+
).toBe("desktop");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns desktop for empty UA", () => {
|
|
46
|
+
expect(detectDevice("")).toBe("desktop");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("detects Googlebot as desktop", () => {
|
|
50
|
+
expect(
|
|
51
|
+
detectDevice(
|
|
52
|
+
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
53
|
+
),
|
|
54
|
+
).toBe("desktop");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("detects Googlebot mobile as mobile", () => {
|
|
58
|
+
expect(
|
|
59
|
+
detectDevice(
|
|
60
|
+
"Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1)",
|
|
61
|
+
),
|
|
62
|
+
).toBe("mobile");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isMobileUA", () => {
|
|
67
|
+
it("returns true for mobile UAs", () => {
|
|
68
|
+
expect(isMobileUA("iPhone")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns true for tablet UAs", () => {
|
|
72
|
+
expect(isMobileUA("iPad")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false for desktop UAs", () => {
|
|
76
|
+
expect(
|
|
77
|
+
isMobileUA(
|
|
78
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
79
|
+
),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("useDevice (isomorphic)", () => {
|
|
85
|
+
it("uses navigator.userAgent on client, not viewport width", () => {
|
|
86
|
+
// In jsdom, document exists → useDevice takes the client path.
|
|
87
|
+
// It should use navigator.userAgent (same mechanism as server-side)
|
|
88
|
+
// to ensure consistent values between SSR and hydration.
|
|
89
|
+
// jsdom's navigator.userAgent is a desktop-like string.
|
|
90
|
+
const device = useDevice();
|
|
91
|
+
const expected = detectDevice(navigator.userAgent);
|
|
92
|
+
expect(device).toBe(expected);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns consistent result with detectDevice(navigator.userAgent)", () => {
|
|
96
|
+
// This is the key SSR/hydration consistency check:
|
|
97
|
+
// server calls detectDevice(req.headers["user-agent"])
|
|
98
|
+
// client calls detectDevice(navigator.userAgent)
|
|
99
|
+
// Both use the same detectDevice() function → same result for same UA.
|
|
100
|
+
const clientResult = useDevice();
|
|
101
|
+
const directResult = detectDevice(navigator.userAgent);
|
|
102
|
+
expect(clientResult).toBe(directResult);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/sdk/useDevice.ts
CHANGED
|
@@ -51,15 +51,36 @@ export function detectDevice(userAgent: string): Device {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Get the current device type
|
|
54
|
+
* Get the current device type. Works everywhere:
|
|
55
|
+
* - Server (loader, middleware, server function): reads User-Agent from RequestContext.
|
|
56
|
+
* - Client (component, event handler): uses `window.innerWidth` breakpoints.
|
|
55
57
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* import { useDevice } from "@decocms/start/sdk/useDevice";
|
|
61
|
+
*
|
|
62
|
+
* // In a component:
|
|
63
|
+
* const device = useDevice(); // "mobile" | "tablet" | "desktop"
|
|
64
|
+
*
|
|
65
|
+
* // In a loader:
|
|
66
|
+
* export function loader(props: Props) {
|
|
67
|
+
* const device = useDevice();
|
|
68
|
+
* return { ...props, isMobile: device === "mobile" };
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
58
71
|
*/
|
|
59
72
|
export function useDevice(): Device {
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
73
|
+
// Server: use RequestContext UA header
|
|
74
|
+
if (typeof document === "undefined") {
|
|
75
|
+
const ctx = RequestContext.current;
|
|
76
|
+
if (!ctx) return "desktop";
|
|
77
|
+
const ua = ctx.request.headers.get("user-agent") ?? "";
|
|
78
|
+
return detectDevice(ua);
|
|
79
|
+
}
|
|
80
|
+
// Client: use navigator.userAgent for consistency with server-side UA detection.
|
|
81
|
+
// Using viewport width would produce different results between SSR and
|
|
82
|
+
// hydration (server sees UA, client sees pixels), causing hydration mismatch.
|
|
83
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
|
63
84
|
return detectDevice(ua);
|
|
64
85
|
}
|
|
65
86
|
|
|
@@ -89,3 +110,4 @@ export function checkDesktop(): boolean {
|
|
|
89
110
|
if (!ctx) return true;
|
|
90
111
|
return detectDevice(ctx.request.headers.get("user-agent") ?? "") === "desktop";
|
|
91
112
|
}
|
|
113
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export of TanStack Router's `useHydrated` hook.
|
|
3
|
+
*
|
|
4
|
+
* Returns `false` during SSR and on the first client render (before hydration),
|
|
5
|
+
* then `true` for all subsequent renders. Use this instead of
|
|
6
|
+
* `typeof document === "undefined"` checks for conditional rendering.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { useHydrated } from "@decocms/start/sdk/useHydrated";
|
|
11
|
+
*
|
|
12
|
+
* function CartButton() {
|
|
13
|
+
* const hydrated = useHydrated();
|
|
14
|
+
* if (!hydrated) return <CartSkeleton />;
|
|
15
|
+
* return <InteractiveCart />;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export { useHydrated } from "@tanstack/react-router";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { inlineScript, useScript } from "./useScript";
|
|
3
|
+
|
|
4
|
+
describe("inlineScript", () => {
|
|
5
|
+
it("returns dangerouslySetInnerHTML with the provided string", () => {
|
|
6
|
+
const result = inlineScript('alert("hello")');
|
|
7
|
+
expect(result).toEqual({
|
|
8
|
+
dangerouslySetInnerHTML: { __html: 'alert("hello")' },
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("handles empty string", () => {
|
|
13
|
+
const result = inlineScript("");
|
|
14
|
+
expect(result).toEqual({
|
|
15
|
+
dangerouslySetInnerHTML: { __html: "" },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("handles multiline scripts", () => {
|
|
20
|
+
const js = `
|
|
21
|
+
const el = document.getElementById("btn");
|
|
22
|
+
el.addEventListener("click", () => { console.log("clicked"); });
|
|
23
|
+
`;
|
|
24
|
+
const result = inlineScript(js);
|
|
25
|
+
expect(result.dangerouslySetInnerHTML.__html).toBe(js);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("useScript", () => {
|
|
30
|
+
it("serializes a function into an IIFE string", () => {
|
|
31
|
+
function greet(name: string) {
|
|
32
|
+
console.log(name);
|
|
33
|
+
}
|
|
34
|
+
const result = useScript(greet, "world");
|
|
35
|
+
expect(result).toContain("(");
|
|
36
|
+
expect(result).toContain('"world"');
|
|
37
|
+
expect(result).toContain("console.log");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("serializes multiple arguments", () => {
|
|
41
|
+
function add(a: number, b: number) {
|
|
42
|
+
return a + b;
|
|
43
|
+
}
|
|
44
|
+
const result = useScript(add, 1, 2);
|
|
45
|
+
expect(result).toContain("1,2");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles functions with no arguments", () => {
|
|
49
|
+
function noop() {}
|
|
50
|
+
const result = useScript(noop);
|
|
51
|
+
expect(result).toMatch(/^\(.*\)\(\)$/);
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/sdk/useScript.ts
CHANGED
|
@@ -57,15 +57,42 @@ function minifyJs(code: string): string {
|
|
|
57
57
|
/**
|
|
58
58
|
* Serializes a function and its arguments into a self-executing inline script.
|
|
59
59
|
*
|
|
60
|
+
* @deprecated `fn.toString()` produces different output in SSR vs client Vite
|
|
61
|
+
* builds (React Compiler transforms differ), causing hydration mismatches on
|
|
62
|
+
* `dangerouslySetInnerHTML.__html`. Use {@link inlineScript} with a plain
|
|
63
|
+
* string constant instead.
|
|
64
|
+
*
|
|
60
65
|
* @example
|
|
61
66
|
* ```tsx
|
|
62
|
-
*
|
|
67
|
+
* // BEFORE (causes hydration mismatch):
|
|
68
|
+
* <script dangerouslySetInnerHTML={{ __html: useScript(onLoad, elementId) }} />
|
|
69
|
+
*
|
|
70
|
+
* // AFTER (safe):
|
|
71
|
+
* <script {...inlineScript(`(${MY_SCRIPT_STRING})("${elementId}")`)} />
|
|
63
72
|
* ```
|
|
64
73
|
*/
|
|
65
74
|
export function useScript<T extends (...args: any[]) => void>(
|
|
66
75
|
fn: T,
|
|
67
76
|
...args: Parameters<T>
|
|
68
77
|
): string {
|
|
78
|
+
if (typeof (globalThis as any).__DECO_USE_SCRIPT_WARNED === "undefined") {
|
|
79
|
+
(globalThis as any).__DECO_USE_SCRIPT_WARNED = new Set<string>();
|
|
80
|
+
}
|
|
81
|
+
const warnedSet = (globalThis as any).__DECO_USE_SCRIPT_WARNED as Set<string>;
|
|
82
|
+
const fnName = fn.name || "anonymous";
|
|
83
|
+
if (
|
|
84
|
+
typeof process !== "undefined" &&
|
|
85
|
+
process.env?.NODE_ENV !== "production" &&
|
|
86
|
+
!warnedSet.has(fnName)
|
|
87
|
+
) {
|
|
88
|
+
warnedSet.add(fnName);
|
|
89
|
+
console.warn(
|
|
90
|
+
`[useScript] Using fn.toString() for "${fnName}". ` +
|
|
91
|
+
`This may produce different output in SSR vs client builds, causing hydration mismatch. ` +
|
|
92
|
+
`Consider using inlineScript() with a plain string constant instead.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
const fnStr = fn.toString();
|
|
70
97
|
let minified = cacheGet(fnStr);
|
|
71
98
|
if (minified === undefined) {
|
|
@@ -79,6 +106,8 @@ export function useScript<T extends (...args: any[]) => void>(
|
|
|
79
106
|
|
|
80
107
|
/**
|
|
81
108
|
* Like useScript, but returns a data: URI suitable for `<script src="...">`.
|
|
109
|
+
*
|
|
110
|
+
* @deprecated Same hydration issues as {@link useScript}. Use {@link inlineScript} instead.
|
|
82
111
|
*/
|
|
83
112
|
export function useScriptAsDataURI<T extends (...args: any[]) => void>(
|
|
84
113
|
fn: T,
|
|
@@ -88,6 +117,25 @@ export function useScriptAsDataURI<T extends (...args: any[]) => void>(
|
|
|
88
117
|
return `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
|
|
89
118
|
}
|
|
90
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Returns props for a `<script>` element with safe inline JavaScript.
|
|
122
|
+
* Unlike {@link useScript}, this accepts a plain string — no `fn.toString()`
|
|
123
|
+
* means no SSR/client divergence and no hydration mismatch.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```tsx
|
|
127
|
+
* const SCROLL_SCRIPT = `document.getElementById("btn").addEventListener("click", () => { ... })`;
|
|
128
|
+
* <script {...inlineScript(SCROLL_SCRIPT)} />
|
|
129
|
+
*
|
|
130
|
+
* // With arguments:
|
|
131
|
+
* const INIT_SCRIPT = (id: string) => `document.getElementById("${id}").dataset.ready = "true"`;
|
|
132
|
+
* <script {...inlineScript(INIT_SCRIPT("my-element"))} />
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function inlineScript(js: string) {
|
|
136
|
+
return { dangerouslySetInnerHTML: { __html: js } } as const;
|
|
137
|
+
}
|
|
138
|
+
|
|
91
139
|
/**
|
|
92
140
|
* Stub -- Deco partial sections don't apply in TanStack Start.
|
|
93
141
|
* Returns the provided props as-is.
|