@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
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,13 +185,25 @@ 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) =>
|
|
186
|
-
data as {
|
|
196
|
+
data as {
|
|
197
|
+
component: string;
|
|
198
|
+
rawProps: Record<string, any>;
|
|
199
|
+
pagePath: string;
|
|
200
|
+
pageUrl?: string;
|
|
201
|
+
/** Original position in the page section list — preserved for correct SPA ordering. */
|
|
202
|
+
index?: number;
|
|
203
|
+
},
|
|
187
204
|
)
|
|
188
205
|
.handler(async (ctx) => {
|
|
189
|
-
const { component, rawProps, pagePath, pageUrl } = ctx.data;
|
|
206
|
+
const { component, rawProps, pagePath, pageUrl, index } = ctx.data;
|
|
190
207
|
|
|
191
208
|
const originRequest = getRequest();
|
|
192
209
|
const serverUrl = getRequestUrl().toString();
|
|
@@ -201,6 +218,9 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
201
218
|
const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
|
|
202
219
|
if (!section) return null;
|
|
203
220
|
|
|
221
|
+
// Preserve original index for correct ordering in SPA navigation merge
|
|
222
|
+
if (index !== undefined) section.index = index;
|
|
223
|
+
|
|
204
224
|
const request = new Request(pageUrl || serverUrl, {
|
|
205
225
|
headers: originRequest.headers,
|
|
206
226
|
});
|
|
@@ -257,6 +277,26 @@ export interface CmsRouteOptions {
|
|
|
257
277
|
ignoreSearchParams?: string[];
|
|
258
278
|
/** Custom pending component shown during SPA navigation. */
|
|
259
279
|
pendingComponent?: () => any;
|
|
280
|
+
/**
|
|
281
|
+
* Delay (ms) before showing the pending component during SPA navigation.
|
|
282
|
+
* If the loader resolves before this threshold, no pending UI is shown.
|
|
283
|
+
* Prevents skeleton flash on fast cache-hit navigations. Default: 200.
|
|
284
|
+
*/
|
|
285
|
+
pendingMs?: number;
|
|
286
|
+
/**
|
|
287
|
+
* Minimum display time (ms) for the pending component once shown.
|
|
288
|
+
* Prevents jarring flash when data arrives shortly after the threshold.
|
|
289
|
+
* Default: 300.
|
|
290
|
+
*/
|
|
291
|
+
pendingMinMs?: number;
|
|
292
|
+
/**
|
|
293
|
+
* SSR mode for this route.
|
|
294
|
+
* - `true` (default): Full SSR — loader + component render on server.
|
|
295
|
+
* - `'data-only'`: Loader runs on server, component renders on client only.
|
|
296
|
+
* Use for interactive-heavy pages (PDP with zoom/variant selector).
|
|
297
|
+
* - `false`: Everything runs on client only.
|
|
298
|
+
*/
|
|
299
|
+
ssr?: boolean | "data-only";
|
|
260
300
|
}
|
|
261
301
|
|
|
262
302
|
type CmsPageLoaderData = {
|
|
@@ -441,6 +481,9 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
441
481
|
defaultDescription,
|
|
442
482
|
ignoreSearchParams = ["skuId"],
|
|
443
483
|
pendingComponent,
|
|
484
|
+
pendingMs = 200,
|
|
485
|
+
pendingMinMs = 300,
|
|
486
|
+
ssr: ssrMode,
|
|
444
487
|
} = options;
|
|
445
488
|
|
|
446
489
|
const ignoreSet = new Set(ignoreSearchParams);
|
|
@@ -467,15 +510,80 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
|
|
|
467
510
|
? "?" + new URLSearchParams(deps.search as Record<string, string>).toString()
|
|
468
511
|
: "";
|
|
469
512
|
const page = await loadCmsPage({ data: basePath + searchStr });
|
|
513
|
+
if (!page) return page;
|
|
470
514
|
|
|
471
|
-
if (!isServer && page
|
|
515
|
+
if (!isServer && page.resolvedSections) {
|
|
472
516
|
const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
|
|
473
517
|
await preloadSectionComponents(keys);
|
|
474
518
|
}
|
|
519
|
+
|
|
520
|
+
// SSR: create unawaited promises for TanStack native streaming.
|
|
521
|
+
// Each deferred section becomes a promise that TanStack streams
|
|
522
|
+
// via SSR chunked transfer — all resolved in the SAME request.
|
|
523
|
+
//
|
|
524
|
+
// IMPORTANT: We call resolveDeferredSectionFull directly instead of
|
|
525
|
+
// loadDeferredSection (server function). Server functions serialize
|
|
526
|
+
// their return via JSON-RPC — TanStack only streams promises that
|
|
527
|
+
// are directly in the loader return, not serialized server fn results.
|
|
528
|
+
if (isServer && page.deferredSections?.length) {
|
|
529
|
+
const originRequest = getRequest();
|
|
530
|
+
const serverUrl = getRequestUrl();
|
|
531
|
+
const matcherCtx: MatcherContext = {
|
|
532
|
+
userAgent: getRequestHeader("user-agent") ?? "",
|
|
533
|
+
url: page.pageUrl ?? serverUrl.toString(),
|
|
534
|
+
path: page.pagePath ?? basePath,
|
|
535
|
+
cookies: getCookies(),
|
|
536
|
+
request: originRequest,
|
|
537
|
+
};
|
|
538
|
+
const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
|
|
539
|
+
headers: originRequest.headers,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
543
|
+
for (const ds of page.deferredSections) {
|
|
544
|
+
deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
|
|
545
|
+
ds,
|
|
546
|
+
page.pagePath ?? basePath,
|
|
547
|
+
deferredRequest,
|
|
548
|
+
matcherCtx,
|
|
549
|
+
).catch((e) => {
|
|
550
|
+
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
551
|
+
return null;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return { ...page, deferredPromises };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Client SPA navigation: resolve all deferred sections via server
|
|
558
|
+
// function batch and merge into resolvedSections for immediate render.
|
|
559
|
+
if (!isServer && page.deferredSections?.length) {
|
|
560
|
+
const resolved = await Promise.all(
|
|
561
|
+
page.deferredSections.map((ds: DeferredSection) =>
|
|
562
|
+
loadDeferredSection({
|
|
563
|
+
data: {
|
|
564
|
+
component: ds.component,
|
|
565
|
+
rawProps: ds.rawProps,
|
|
566
|
+
pagePath: page.pagePath ?? basePath,
|
|
567
|
+
pageUrl: page.pageUrl,
|
|
568
|
+
index: ds.index,
|
|
569
|
+
},
|
|
570
|
+
}).catch(() => null),
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
const all = [
|
|
574
|
+
...page.resolvedSections,
|
|
575
|
+
...resolved.filter((s): s is ResolvedSection => s != null),
|
|
576
|
+
].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
577
|
+
return { ...page, resolvedSections: all, deferredSections: [] };
|
|
578
|
+
}
|
|
579
|
+
|
|
475
580
|
return page;
|
|
476
581
|
},
|
|
477
582
|
|
|
478
583
|
...(pendingComponent ? { pendingComponent } : {}),
|
|
584
|
+
pendingMs,
|
|
585
|
+
pendingMinMs,
|
|
586
|
+
...(ssrMode !== undefined ? { ssr: ssrMode } : {}),
|
|
479
587
|
|
|
480
588
|
...routeCacheDefaults("product"),
|
|
481
589
|
|
|
@@ -501,19 +609,77 @@ export function cmsHomeRouteConfig(options: {
|
|
|
501
609
|
/** Site name for OG title composition. Defaults to defaultTitle. */
|
|
502
610
|
siteName?: string;
|
|
503
611
|
pendingComponent?: () => any;
|
|
612
|
+
/** Delay (ms) before showing pending component. Default: 200. */
|
|
613
|
+
pendingMs?: number;
|
|
614
|
+
/** Minimum display time (ms) for pending component. Default: 300. */
|
|
615
|
+
pendingMinMs?: number;
|
|
504
616
|
}) {
|
|
505
|
-
const { defaultTitle, defaultDescription, siteName } = options;
|
|
617
|
+
const { defaultTitle, defaultDescription, siteName, pendingMs = 200, pendingMinMs = 300 } = options;
|
|
506
618
|
|
|
507
619
|
return {
|
|
508
620
|
loader: async () => {
|
|
509
621
|
const page = await loadCmsHomePage();
|
|
510
|
-
if (!
|
|
622
|
+
if (!page) return page;
|
|
623
|
+
|
|
624
|
+
if (!isServer && page.resolvedSections) {
|
|
511
625
|
const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
|
|
512
626
|
await preloadSectionComponents(keys);
|
|
513
627
|
}
|
|
628
|
+
|
|
629
|
+
// SSR: create unawaited promises for TanStack native streaming
|
|
630
|
+
if (isServer && page.deferredSections?.length) {
|
|
631
|
+
const originRequest = getRequest();
|
|
632
|
+
const serverUrl = getRequestUrl();
|
|
633
|
+
const matcherCtx: MatcherContext = {
|
|
634
|
+
userAgent: getRequestHeader("user-agent") ?? "",
|
|
635
|
+
url: page.pageUrl ?? serverUrl.toString(),
|
|
636
|
+
path: "/",
|
|
637
|
+
cookies: getCookies(),
|
|
638
|
+
request: originRequest,
|
|
639
|
+
};
|
|
640
|
+
const deferredRequest = new Request(page.pageUrl ?? serverUrl.toString(), {
|
|
641
|
+
headers: originRequest.headers,
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const deferredPromises: Record<string, Promise<ResolvedSection | null>> = {};
|
|
645
|
+
for (const ds of page.deferredSections) {
|
|
646
|
+
deferredPromises[`d_${ds.index}`] = resolveDeferredSectionFull(
|
|
647
|
+
ds, "/", deferredRequest, matcherCtx,
|
|
648
|
+
).catch((e) => {
|
|
649
|
+
console.error(`[CMS] Deferred section "${ds.component}" failed:`, e);
|
|
650
|
+
return null;
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
return { ...page, deferredPromises };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Client SPA navigation: resolve all deferred via server function batch
|
|
657
|
+
if (!isServer && page.deferredSections?.length) {
|
|
658
|
+
const resolved = await Promise.all(
|
|
659
|
+
page.deferredSections.map((ds: DeferredSection) =>
|
|
660
|
+
loadDeferredSection({
|
|
661
|
+
data: {
|
|
662
|
+
component: ds.component,
|
|
663
|
+
rawProps: ds.rawProps,
|
|
664
|
+
pagePath: "/",
|
|
665
|
+
pageUrl: page.pageUrl,
|
|
666
|
+
index: ds.index,
|
|
667
|
+
},
|
|
668
|
+
}).catch(() => null),
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
const all = [
|
|
672
|
+
...page.resolvedSections,
|
|
673
|
+
...resolved.filter((s): s is ResolvedSection => s != null),
|
|
674
|
+
].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
675
|
+
return { ...page, resolvedSections: all, deferredSections: [] };
|
|
676
|
+
}
|
|
677
|
+
|
|
514
678
|
return page;
|
|
515
679
|
},
|
|
516
680
|
...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
|
|
681
|
+
pendingMs,
|
|
682
|
+
pendingMinMs,
|
|
517
683
|
...routeCacheDefaults("static"),
|
|
518
684
|
headers: () => cacheHeaders("static"),
|
|
519
685
|
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.
|