@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.
@@ -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: getRequestUrl().toString(),
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 { component: string; rawProps: Record<string, any>; pagePath: string; pageUrl?: string },
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?.resolvedSections) {
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 (!isServer && page?.resolvedSections) {
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({ sections }: { sections: ResolvedSection[] }) {
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 sections={sections} />
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
+ });
@@ -51,15 +51,36 @@ export function detectDevice(userAgent: string): Device {
51
51
  }
52
52
 
53
53
  /**
54
- * Get the current device type via RequestContext.
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
- * Must be called within a `RequestContext.run()` scope (i.e., during
57
- * request handling). Falls back to "desktop" outside request scope.
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
- const ctx = RequestContext.current;
61
- if (!ctx) return "desktop";
62
- const ua = ctx.request.headers.get("user-agent") ?? "";
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
+ });
@@ -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
- * <script dangerouslySetInnerHTML={{ __html: useScript(onLoad, elementId, config) }} />
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.
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ include: ["src/**/*.test.{ts,tsx}"],
7
+ globals: true,
8
+ },
9
+ });