@decocms/tanstack 7.0.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.
@@ -0,0 +1,111 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { HeadContent, Scripts, ScriptOnce } from "@tanstack/react-router";
3
+ import { LiveControls } from "@decocms/blocks/hooks";
4
+ import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
5
+ import { NavigationProgress } from "./NavigationProgress";
6
+ import { StableOutlet } from "./StableOutlet";
7
+
8
+ declare global {
9
+ interface Window {
10
+ __deco_ready?: boolean;
11
+ }
12
+ }
13
+
14
+ function buildDecoEventsBootstrap(account?: string): string {
15
+ const accountJson = JSON.stringify(account ?? "");
16
+ return `
17
+ window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
18
+ window.DECO = window.DECO || {};
19
+ window.DECO.events = window.DECO.events || {
20
+ _q: [],
21
+ _subs: [],
22
+ dispatch: function(e) {
23
+ this._q.push(e);
24
+ for (var i = 0; i < this._subs.length; i++) {
25
+ try { this._subs[i](e); } catch(err) { console.error('[DECO.events]', err); }
26
+ }
27
+ },
28
+ subscribe: function(fn) {
29
+ this._subs.push(fn);
30
+ for (var i = 0; i < this._q.length; i++) {
31
+ try { fn(this._q[i]); } catch(err) {}
32
+ }
33
+ }
34
+ };
35
+ window.dataLayer = window.dataLayer || [];
36
+ `;
37
+ }
38
+
39
+ export interface DecoRootLayoutProps {
40
+ /** Language attribute for the <html> tag. Default: "en" */
41
+ lang?: string;
42
+ /** DaisyUI data-theme attribute. Default: "light" */
43
+ dataTheme?: string;
44
+ /** Site name for LiveControls (admin iframe communication). Required. */
45
+ siteName: string;
46
+ /** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
47
+ account?: string;
48
+ /** CSS class for <body>. Default: "bg-base-200 text-base-content" */
49
+ bodyClassName?: string;
50
+ /** Delay in ms before firing deco:ready event. Default: 500 */
51
+ decoReadyDelay?: number;
52
+ /**
53
+ * Extra content rendered inside <body> after the main outlet
54
+ * (e.g. Toast, custom analytics components).
55
+ */
56
+ children?: ReactNode;
57
+ }
58
+
59
+ /**
60
+ * Standard Deco root layout component for use in __root.tsx.
61
+ *
62
+ * Provides:
63
+ * - NavigationProgress (loading bar during SPA nav)
64
+ * - StableOutlet (height-preserved content area)
65
+ * - DECO.events bootstrap (via ScriptOnce — runs before hydration, once)
66
+ * - LiveControls for admin
67
+ * - Analytics script (via ScriptOnce)
68
+ * - deco:ready hydration signal
69
+ *
70
+ * QueryClientProvider should be configured via createDecoRouter's `Wrap` option
71
+ * (per TanStack docs — non-DOM providers go on the router, not in components).
72
+ *
73
+ * Sites that need full control should compose from the individual exported
74
+ * pieces (NavigationProgress, StableOutlet, etc.) instead.
75
+ */
76
+ export function DecoRootLayout({
77
+ lang = "en",
78
+ dataTheme = "light",
79
+ siteName,
80
+ account,
81
+ bodyClassName = "bg-base-200 text-base-content",
82
+ decoReadyDelay = 500,
83
+ children,
84
+ }: DecoRootLayoutProps) {
85
+ useEffect(() => {
86
+ const id = setTimeout(() => {
87
+ window.__deco_ready = true;
88
+ document.dispatchEvent(new Event("deco:ready"));
89
+ }, decoReadyDelay);
90
+ return () => clearTimeout(id);
91
+ }, [decoReadyDelay]);
92
+
93
+ return (
94
+ <html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
95
+ <head>
96
+ <HeadContent />
97
+ </head>
98
+ <body className={bodyClassName} suppressHydrationWarning>
99
+ <ScriptOnce children={buildDecoEventsBootstrap(account)} />
100
+ <NavigationProgress />
101
+ <main>
102
+ <StableOutlet />
103
+ </main>
104
+ {children}
105
+ <LiveControls site={siteName} />
106
+ <ScriptOnce children={ANALYTICS_SCRIPT} />
107
+ <Scripts />
108
+ </body>
109
+ </html>
110
+ );
111
+ }
@@ -0,0 +1,21 @@
1
+ import { useRouterState } from "@tanstack/react-router";
2
+
3
+ const PROGRESS_CSS = `
4
+ @keyframes progressSlide { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
5
+ .nav-progress-bar { animation: progressSlide 1s ease-in-out infinite; }
6
+ `;
7
+
8
+ /**
9
+ * Top-of-page loading bar that appears during SPA navigation.
10
+ * Uses the router's isLoading state — no extra dependencies.
11
+ */
12
+ export function NavigationProgress() {
13
+ const isLoading = useRouterState({ select: (s) => s.isLoading });
14
+ if (!isLoading) return null;
15
+ return (
16
+ <div className="fixed top-0 left-0 right-0 z-[9999] h-1 bg-brand-primary-500/20 overflow-hidden">
17
+ <style dangerouslySetInnerHTML={{ __html: PROGRESS_CSS }} />
18
+ <div className="nav-progress-bar h-full w-1/3 bg-brand-primary-500 rounded-full" />
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ createMemoryHistory,
3
+ createRootRoute,
4
+ createRouter,
5
+ RouterContextProvider,
6
+ } from "@tanstack/react-router";
7
+ import type { ReactNode } from "react";
8
+
9
+ const rootRoute = createRootRoute();
10
+
11
+ const previewRouter = createRouter({
12
+ // TanStack Router's RootRoute/Route generic inference rejects bare rootRoute
13
+ // at the type level (works at runtime). `as any` avoids leaking the mismatch
14
+ // into consumer sites that typecheck framework source via npm link.
15
+ routeTree: rootRoute as any,
16
+ history: createMemoryHistory({ initialEntries: ["/"] }),
17
+ });
18
+
19
+ /**
20
+ * Default preview wrapper for admin iframe rendering.
21
+ * Provides a TanStack Router context with memory history so components
22
+ * that depend on router hooks (Link, useNavigate, etc.) work in previews.
23
+ */
24
+ export default function PreviewProviders({ children }: { children: ReactNode }) {
25
+ return <RouterContextProvider router={previewRouter as any}>{children}</RouterContextProvider>;
26
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Outlet, useRouterState } from "@tanstack/react-router";
3
+
4
+ /**
5
+ * Preserves the content area height during SPA navigation so the
6
+ * footer doesn't jump up while the new page is loading.
7
+ */
8
+ export function StableOutlet() {
9
+ const isLoading = useRouterState({ select: (s) => s.isLoading });
10
+ const ref = useRef<HTMLDivElement>(null);
11
+ // State (not a ref) so clearing the height when navigation completes
12
+ // triggers a re-render that removes the min-height. With a ref the reset
13
+ // never re-rendered, so the previous (taller) page's height stayed pinned
14
+ // — leaving a large gap below the footer on tall→short navigation (#279).
15
+ const [savedHeight, setSavedHeight] = useState<number | undefined>(undefined);
16
+
17
+ useEffect(() => {
18
+ if (isLoading && ref.current) {
19
+ setSavedHeight(ref.current.offsetHeight);
20
+ } else if (!isLoading) {
21
+ setSavedHeight(undefined);
22
+ }
23
+ }, [isLoading]);
24
+
25
+ return (
26
+ <div ref={ref} style={savedHeight ? { minHeight: savedHeight } : undefined}>
27
+ <Outlet />
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ DecoPageRenderer,
3
+ SectionList,
4
+ SectionRenderer,
5
+ } from "./DecoPageRenderer";
6
+ export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
7
+ export { NavigationProgress } from "./NavigationProgress";
8
+ export { StableOutlet } from "./StableOutlet";
9
+ export { default as PreviewProviders } from "./PreviewProviders";
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export {
2
+ cmsHomeRouteConfig,
3
+ cmsRouteConfig,
4
+ CmsPage,
5
+ decoInvokeRoute,
6
+ decoMetaRoute,
7
+ decoRenderRoute,
8
+ loadCmsHomePage,
9
+ loadCmsPage,
10
+ loadDeferredSection,
11
+ NotFoundPage,
12
+ withSiteGlobals,
13
+ } from "./routes";
14
+ export {
15
+ DecoPageRenderer,
16
+ DecoRootLayout,
17
+ NavigationProgress,
18
+ PreviewProviders,
19
+ SectionList,
20
+ SectionRenderer,
21
+ StableOutlet,
22
+ } from "./hooks";
23
+ export { createDecoWorkerEntry } from "./sdk/workerEntry";
24
+ export { setupTanstackFastDeploy } from "./setupFastDeploy";
25
+ export {
26
+ createDecoRouter,
27
+ decoParseSearch,
28
+ decoStringifySearch,
29
+ } from "./sdk/router";
30
+ export type { CreateDecoRouterOptions } from "./sdk/router";
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Admin Route Helpers
3
+ *
4
+ * Pre-built server handler configs for the Deco admin protocol routes.
5
+ * Sites spread these into their `createFileRoute` definitions to avoid
6
+ * repeating the same CORS + handler boilerplate.
7
+ *
8
+ * @example Site's `src/routes/deco/meta.ts`:
9
+ * ```ts
10
+ * import { createFileRoute } from "@tanstack/react-router";
11
+ * import { decoMetaRoute } from "@decocms/start/routes";
12
+ *
13
+ * export const Route = createFileRoute("/deco/meta")(decoMetaRoute);
14
+ * ```
15
+ */
16
+ import { corsHeaders, handleInvoke, handleMeta, handleRender } from "@decocms/blocks-admin";
17
+ import { withTracing } from "@decocms/blocks/sdk/observability";
18
+
19
+ function invokeAttrs(request: Request): Record<string, string | boolean> {
20
+ const url = new URL(request.url);
21
+ const invokeKey = url.pathname.split("/deco/invoke/")[1] ?? "";
22
+ return {
23
+ "invoke.key": invokeKey || "(batch)",
24
+ "invoke.batch": invokeKey === "",
25
+ };
26
+ }
27
+
28
+ function renderAttrs(request: Request): Record<string, string> {
29
+ const url = new URL(request.url);
30
+ const pathComponent = url.pathname.split("/deco/render/")[1] ?? "";
31
+ return { "cms.component": pathComponent || "(page)" };
32
+ }
33
+
34
+ type HandlerFn = (ctx: { request: Request }) => Promise<Response> | Response;
35
+
36
+ function withCors(handler: HandlerFn): HandlerFn {
37
+ return async (ctx) => {
38
+ const response = await handler(ctx);
39
+ const headers = new Headers(response.headers);
40
+ for (const [k, v] of Object.entries(corsHeaders(ctx.request))) {
41
+ headers.set(k, v);
42
+ }
43
+ return new Response(response.body, {
44
+ status: response.status,
45
+ headers,
46
+ });
47
+ };
48
+ }
49
+
50
+ function optionsHandler(ctx: { request: Request }): Response {
51
+ return new Response(null, {
52
+ status: 204,
53
+ headers: corsHeaders(ctx.request),
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Route config for `/deco/meta` — serves JSON Schema + manifest.
59
+ * Spread into `createFileRoute("/deco/meta")({...})`.
60
+ */
61
+ export const decoMetaRoute = {
62
+ server: {
63
+ handlers: {
64
+ GET: withCors(({ request }) =>
65
+ withTracing("deco.admin.meta", async () => handleMeta(request)),
66
+ ),
67
+ OPTIONS: optionsHandler,
68
+ },
69
+ },
70
+ };
71
+
72
+ /**
73
+ * Route config for `/deco/render` — section/page preview in iframe.
74
+ * Spread into `createFileRoute("/deco/render")({...})`.
75
+ */
76
+ export const decoRenderRoute = {
77
+ server: {
78
+ handlers: {
79
+ GET: withCors(({ request }) =>
80
+ withTracing(
81
+ "deco.admin.render",
82
+ () => Promise.resolve(handleRender(request)),
83
+ renderAttrs(request),
84
+ ),
85
+ ),
86
+ POST: withCors(({ request }) =>
87
+ withTracing(
88
+ "deco.admin.render",
89
+ () => Promise.resolve(handleRender(request)),
90
+ renderAttrs(request),
91
+ ),
92
+ ),
93
+ OPTIONS: optionsHandler,
94
+ },
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Route config for `/deco/invoke/$` — loader/action execution.
100
+ * Spread into `createFileRoute("/deco/invoke/$")({...})`.
101
+ */
102
+ export const decoInvokeRoute = {
103
+ server: {
104
+ handlers: {
105
+ GET: withCors(({ request }) =>
106
+ withTracing("deco.admin.invoke", () => handleInvoke(request), invokeAttrs(request)),
107
+ ),
108
+ POST: withCors(({ request }) =>
109
+ withTracing("deco.admin.invoke", () => handleInvoke(request), invokeAttrs(request)),
110
+ ),
111
+ OPTIONS: optionsHandler,
112
+ },
113
+ },
114
+ };