@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.
@@ -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,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?.resolvedSections) {
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 (!isServer && page?.resolvedSections) {
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({ 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
+ });