@decocms/tanstack 8.0.0 → 8.1.0-next.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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -1,19 +1,28 @@
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";
1
+ import { LiveControls, Stats } from "@decocms/blocks/hooks";
2
+ import { ANALYTICS_SCRIPT, SPECULATION_DEV_WARN_SCRIPT } from "@decocms/blocks/sdk/analytics";
3
+ import { isDevMode } from "@decocms/blocks/sdk/env";
4
+ import { getRequestNonce } from "@decocms/blocks/sdk/nonce";
5
+ import { HeadContent, ScriptOnce, Scripts, useRouterState } from "@tanstack/react-router";
6
+ import { type ReactNode, useEffect } from "react";
7
+ import {
8
+ buildSpeculationRules,
9
+ getSpeculationRules,
10
+ type SpeculationRulesConfig,
11
+ } from "../sdk/speculationRules";
12
+ import { CdnSegmentMarker } from "./CdnSegmentMarker";
13
+ import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
5
14
  import { NavigationProgress } from "./NavigationProgress";
6
15
  import { StableOutlet } from "./StableOutlet";
7
16
 
8
17
  declare global {
9
- interface Window {
10
- __deco_ready?: boolean;
11
- }
18
+ interface Window {
19
+ __deco_ready?: boolean;
20
+ }
12
21
  }
13
22
 
14
23
  function buildDecoEventsBootstrap(account?: string): string {
15
- const accountJson = JSON.stringify(account ?? "");
16
- return `
24
+ const accountJson = JSON.stringify(account ?? "");
25
+ return `
17
26
  window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
18
27
  window.DECO = window.DECO || {};
19
28
  window.DECO.events = window.DECO.events || {
@@ -37,23 +46,34 @@ window.dataLayer = window.dataLayer || [];
37
46
  }
38
47
 
39
48
  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;
49
+ /** Language attribute for the <html> tag. Default: "en" */
50
+ lang?: string;
51
+ /** DaisyUI data-theme attribute. Default: "light" */
52
+ dataTheme?: string;
53
+ /** Site name for LiveControls (admin iframe communication). Required. */
54
+ siteName: string;
55
+ /** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
56
+ account?: string;
57
+ /** CSS class for <body>. Default: "bg-base-200 text-base-content" */
58
+ bodyClassName?: string;
59
+ /** Delay in ms before firing deco:ready event. Default: 500 */
60
+ decoReadyDelay?: number;
61
+ /**
62
+ * Opt-in Speculation Rules API config override. Normally activated site-wide
63
+ * via `createDecoWorkerEntry({ speculationRules })` (like `asJson` /
64
+ * `renderJson`) and read from that shared config; pass this prop only to
65
+ * override it for a specific root. When set, emits a
66
+ * `<script type="speculationrules">` in <head> so the browser prerenders/
67
+ * prefetches the next document on intent. Ships DISABLED — only activate on
68
+ * sites whose analytics/pixel loaders are prerender-guarded.
69
+ * @see buildSpeculationRules
70
+ */
71
+ speculationRules?: SpeculationRulesConfig;
72
+ /**
73
+ * Extra content rendered inside <body> after the main outlet
74
+ * (e.g. Toast, custom analytics components).
75
+ */
76
+ children?: ReactNode;
57
77
  }
58
78
 
59
79
  /**
@@ -74,38 +94,99 @@ export interface DecoRootLayoutProps {
74
94
  * pieces (NavigationProgress, StableOutlet, etc.) instead.
75
95
  */
76
96
  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,
97
+ lang = "en",
98
+ dataTheme = "light",
99
+ siteName,
100
+ account,
101
+ bodyClassName = "bg-base-200 text-base-content",
102
+ decoReadyDelay = 500,
103
+ speculationRules,
104
+ children,
84
105
  }: 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]);
106
+ useEffect(() => {
107
+ const id = setTimeout(() => {
108
+ window.__deco_ready = true;
109
+ document.dispatchEvent(new Event("deco:ready"));
110
+ }, decoReadyDelay);
111
+ return () => clearTimeout(id);
112
+ }, [decoReadyDelay]);
113
+
114
+ // Pull the resolved CMS page's route pattern (`path` = pathTemplate) and block
115
+ // id (`blockKey`) out of the current route's loader data so LiveControls sends
116
+ // the admin the page the user is actually on — not the hardcoded "/*" fallback.
117
+ // The CMS page loader (cmsRoute) spreads both fields onto its loader data;
118
+ // we scan matches deepest-first to find the one that carries them.
119
+ const page = useRouterState({
120
+ select: (state) => {
121
+ for (let i = state.matches.length - 1; i >= 0; i--) {
122
+ const data = state.matches[i]?.loaderData as
123
+ | { path?: unknown; blockKey?: unknown }
124
+ | undefined;
125
+ if (data && typeof data.path === "string") {
126
+ return {
127
+ id: typeof data.blockKey === "string" ? data.blockKey : undefined,
128
+ pathTemplate: data.path,
129
+ };
130
+ }
131
+ }
132
+ return undefined;
133
+ },
134
+ });
135
+
136
+ // Worker-entry option wins; prop is a per-root override. Undefined → disabled.
137
+ const speculation = speculationRules ?? getSpeculationRules();
92
138
 
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
- );
139
+ return (
140
+ <html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
141
+ <head>
142
+ <HeadContent />
143
+ <CdnSegmentMarker />
144
+ {speculation && (
145
+ <script
146
+ type="speculationrules"
147
+ nonce={getRequestNonce()}
148
+ // JSON is inert; dangerouslySetInnerHTML avoids React escaping
149
+ // `>` in selectors (e.g. "nav > a"). Same pattern as JSON-LD.
150
+ dangerouslySetInnerHTML={{
151
+ __html: buildSpeculationRules(speculation),
152
+ }}
153
+ />
154
+ )}
155
+ {/* Dev-only: console.error when analytics fire during a prerender
156
+ so the dev fixes unguarded loaders before shipping. */}
157
+ {speculation && isDevMode() && (
158
+ <script
159
+ nonce={getRequestNonce()}
160
+ dangerouslySetInnerHTML={{ __html: SPECULATION_DEV_WARN_SCRIPT }}
161
+ />
162
+ )}
163
+ </head>
164
+ <body className={bodyClassName} suppressHydrationWarning>
165
+ <ScriptOnce children={buildDecoEventsBootstrap(account)} />
166
+ {/*
167
+ * AFTER the bootstrap above, and in the body rather than the head. The collector's
168
+ * deco module subscribes with `window?.DECO?.events?.subscribe?.(…)` — once,
169
+ * optional-chained, with no retry. In the head it is an `async` script the parser
170
+ * can reach and run before the inline bootstrap below it has defined
171
+ * `DECO.events`, and the subscription would simply not happen: pageviews would
172
+ * still flow while every commerce event went nowhere, with no error. Here the
173
+ * parser has already executed the bootstrap.
174
+ *
175
+ * Rendering unconditionally is safe — the component returns null unless
176
+ * DECO_ANALYTICS_ENABLED is exactly "true", which is what makes this an env-var
177
+ * change for a site instead of a code change.
178
+ */}
179
+ <Stats />
180
+ <NavigationProgress />
181
+ <main>
182
+ <StableOutlet />
183
+ </main>
184
+ {children}
185
+ <DraftPreviewIndicator />
186
+ <LiveControls site={siteName} page={page} />
187
+ <ScriptOnce children={ANALYTICS_SCRIPT} />
188
+ <Scripts />
189
+ </body>
190
+ </html>
191
+ );
111
192
  }
@@ -0,0 +1,60 @@
1
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { renderToString } from "react-dom/server";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
8
+ import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
9
+
10
+ const POINTER = "abc.preview-studio.decocms.com@v1";
11
+
12
+ function renderInRequest(pointer: string | null): string {
13
+ return RequestContext.run(new Request("https://example.com/"), () => {
14
+ if (pointer) RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
15
+ return renderToString(<DraftPreviewIndicator />);
16
+ });
17
+ }
18
+
19
+ describe("DraftPreviewIndicator — SSR", () => {
20
+ it("publishes the pointer on a global so client hydration matches", () => {
21
+ const html = renderInRequest(POINTER);
22
+ expect(html).toContain(`window.${DRAFT_POINTER_GLOBAL}=`);
23
+ expect(html).toContain(JSON.stringify(POINTER));
24
+ // The chip itself fails closed — nothing visible until the client effect.
25
+ expect(html).not.toContain("Preview mode");
26
+ });
27
+
28
+ it("renders nothing when the request is not drafting", () => {
29
+ expect(renderInRequest(null)).toBe("");
30
+ });
31
+ });
32
+
33
+ describe("DraftPreviewIndicator — client", () => {
34
+ let container: HTMLDivElement;
35
+ let root: Root;
36
+ const win = window as unknown as Record<string, string | undefined>;
37
+
38
+ beforeEach(() => {
39
+ container = document.createElement("div");
40
+ document.body.appendChild(container);
41
+ root = createRoot(container);
42
+ });
43
+
44
+ afterEach(() => {
45
+ act(() => root.unmount());
46
+ container.remove();
47
+ win[DRAFT_POINTER_GLOBAL] = undefined;
48
+ });
49
+
50
+ it("mounts the badge from the published global (cookie-nav, no bag on the client)", () => {
51
+ win[DRAFT_POINTER_GLOBAL] = POINTER;
52
+ act(() => root.render(<DraftPreviewIndicator />));
53
+ expect(container.textContent).toContain("Preview mode");
54
+ });
55
+
56
+ it("renders nothing when no pointer is published", () => {
57
+ act(() => root.render(<DraftPreviewIndicator />));
58
+ expect(container.textContent).toBe("");
59
+ });
60
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Preview-mode badge gate — the TanStack binding.
3
+ *
4
+ * Mounts the shared `DraftPreviewBadge` (`@decocms/blocks/preview`) only when
5
+ * the request is rendering a draft. `DecoRootLayout` renders it automatically,
6
+ * so sites get the badge on a package bump with no code change; it renders
7
+ * nothing at all on ordinary (published) traffic.
8
+ *
9
+ * ## Isomorphic pointer, hydration-safe
10
+ *
11
+ * TanStack Start renders this component on BOTH server and client (no RSC
12
+ * boundary). The pointer only exists server-side (the request bag), so:
13
+ *
14
+ * - **Server:** read it from `RequestContext` and emit a tiny inline `<script>`
15
+ * that publishes it on `window.<GLOBAL>`. Inline scripts execute during HTML
16
+ * parse, before hydration.
17
+ * - **Client:** `RequestContext` resolves to a no-op stub in the browser
18
+ * bundle (the `browser` export condition), so the bag read returns
19
+ * undefined and we fall back to `window.<GLOBAL>` — which the just-executed
20
+ * inline script set to the server's value, so the hydration render computes
21
+ * the SAME pointer and the `<script>` node reconciles cleanly.
22
+ *
23
+ * Reading the bag first (then the global) — rather than branching on
24
+ * `typeof window` — is also what makes this testable under jsdom, where
25
+ * `window` always exists.
26
+ *
27
+ * The badge itself starts hidden and reveals via a client effect (fails
28
+ * closed), so the visible chip never participates in the hydration diff.
29
+ */
30
+ import { DraftPreviewBadge } from "@decocms/blocks/preview";
31
+ import { getRequestNonce } from "@decocms/blocks/sdk/nonce";
32
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
33
+ import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
34
+
35
+ /**
36
+ * The active draft pointer. Server: the request bag (set by `bindRequestDraft`).
37
+ * Client: the bag is a stub → undefined → fall back to the published global.
38
+ */
39
+ function activePointer(): string | null {
40
+ const fromBag = RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
41
+ if (fromBag) return fromBag;
42
+ if (typeof window !== "undefined") {
43
+ return (window as unknown as Record<string, string | undefined>)[DRAFT_POINTER_GLOBAL] ?? null;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export function DraftPreviewIndicator() {
49
+ const pointer = activePointer();
50
+ if (!pointer) return null;
51
+ return (
52
+ <>
53
+ {/* Publishes a JSON-encoded pointer this server resolved (not user
54
+ input) so the client's hydration render matches. See the module doc. */}
55
+ <script
56
+ nonce={getRequestNonce()}
57
+ dangerouslySetInnerHTML={{
58
+ __html: `window.${DRAFT_POINTER_GLOBAL}=${JSON.stringify(pointer)}`,
59
+ }}
60
+ />
61
+ <DraftPreviewBadge pointer={pointer} />
62
+ </>
63
+ );
64
+ }
@@ -0,0 +1,58 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ // The component only needs the router's isLoading flag.
5
+ let isLoading = true;
6
+ vi.mock("@tanstack/react-router", () => ({
7
+ useRouterState: ({ select }: { select: (s: { isLoading: boolean }) => unknown }) =>
8
+ select({ isLoading }),
9
+ }));
10
+
11
+ const { NavigationProgress } = await import("./NavigationProgress");
12
+
13
+ /**
14
+ * The bug this guards: the bar used to be painted with the Tailwind utility
15
+ * `bg-brand-primary-500`. That token is a *site* concern — framework code
16
+ * cannot assume it exists. On a Tailwind v4 theme that resets
17
+ * `--color-*: initial` the utility is never generated, so the class resolves to
18
+ * nothing and the bar is fully transparent: an invisible progress indicator, in
19
+ * production, with no build error and nothing in the console.
20
+ *
21
+ * The fix paints through an inline custom-property expression, which a CSS build
22
+ * cannot elide. It has to satisfy BOTH directions at once: sites that never
23
+ * defined the token must still see a bar, and sites that DID must keep their
24
+ * brand color rather than silently dropping to black — hence the var-with-
25
+ * fallback rather than a bare `currentColor`.
26
+ */
27
+ describe("NavigationProgress — visibility does not depend on site color tokens", () => {
28
+ it("paints via inline style, never a site-defined Tailwind color utility", () => {
29
+ isLoading = true;
30
+ const html = renderToStaticMarkup(<NavigationProgress />);
31
+
32
+ expect(html).not.toMatch(/bg-brand-/);
33
+ expect(html).toMatch(/background-color:\s*currentColor/);
34
+ });
35
+
36
+ it("keeps the brand token when the site defines it, and falls back when it does not", () => {
37
+ isLoading = true;
38
+ const html = renderToStaticMarkup(<NavigationProgress />);
39
+
40
+ // Both halves matter: the var preserves branding for sites where the old
41
+ // utility worked, the fallback is what makes it visible for those where it
42
+ // silently did not.
43
+ expect(html).toContain("--color-brand-primary-500");
44
+ expect(html).toMatch(/var\(--color-brand-primary-500,\s*currentColor\)/);
45
+ });
46
+
47
+ it("accepts an explicit color so a site can brand it without that token name", () => {
48
+ isLoading = true;
49
+ const html = renderToStaticMarkup(<NavigationProgress color="#ff0080" />);
50
+ expect(html).toContain("#ff0080");
51
+ expect(html).not.toContain("--color-brand-primary-500");
52
+ });
53
+
54
+ it("renders nothing when the router is idle", () => {
55
+ isLoading = false;
56
+ expect(renderToStaticMarkup(<NavigationProgress />)).toBe("");
57
+ });
58
+ });
@@ -5,17 +5,59 @@ const PROGRESS_CSS = `
5
5
  .nav-progress-bar { animation: progressSlide 1s ease-in-out infinite; }
6
6
  `;
7
7
 
8
+ /**
9
+ * Brand token when the site defines it, inherited text color when it does not.
10
+ *
11
+ * The bar used to be painted with the Tailwind utility `bg-brand-primary-500`.
12
+ * That token is a *site* concern, and framework code cannot assume it exists: on
13
+ * a Tailwind v4 theme that resets `--color-*: initial` the utility is never
14
+ * generated, so the class resolved to nothing and the bar rendered fully
15
+ * transparent — an invisible progress indicator, in production, with no build
16
+ * error and nothing in the console.
17
+ *
18
+ * A CSS custom property with a fallback fixes that without regressing the sites
19
+ * where it already worked: they keep their brand color (Tailwind v4 emits
20
+ * `--color-brand-primary-500` for a `brand-primary` palette entry), and
21
+ * everyone else falls back to `currentColor` instead of nothing. Unlike a
22
+ * utility class, neither half can be dropped by a CSS build.
23
+ */
24
+ const DEFAULT_COLOR = "var(--color-brand-primary-500, currentColor)";
25
+
26
+ export interface NavigationProgressProps {
27
+ /**
28
+ * Bar color. Any CSS color or custom-property expression. Defaults to
29
+ * {@link DEFAULT_COLOR} — the site's `brand-primary-500` token when defined,
30
+ * otherwise the inherited text color. Pass an explicit value to brand the bar
31
+ * without relying on that token name.
32
+ */
33
+ color?: string;
34
+ }
35
+
8
36
  /**
9
37
  * Top-of-page loading bar that appears during SPA navigation.
10
38
  * Uses the router's isLoading state — no extra dependencies.
11
39
  */
12
- export function NavigationProgress() {
40
+ export function NavigationProgress({ color = DEFAULT_COLOR }: NavigationProgressProps = {}) {
13
41
  const isLoading = useRouterState({ select: (s) => s.isLoading });
14
42
  if (!isLoading) return null;
15
43
  return (
16
- <div className="fixed top-0 left-0 right-0 z-[9999] h-1 bg-brand-primary-500/20 overflow-hidden">
44
+ <div
45
+ className="fixed top-0 left-0 right-0 z-[9999] h-1 overflow-hidden"
46
+ style={{ color }}
47
+ role="progressbar"
48
+ aria-label="Carregando página"
49
+ >
17
50
  <style dangerouslySetInnerHTML={{ __html: PROGRESS_CSS }} />
18
- <div className="nav-progress-bar h-full w-1/3 bg-brand-primary-500 rounded-full" />
51
+ {/* Track and bar are siblings so the track's 20% alpha does not
52
+ inherit onto the bar (opacity applies to the whole subtree). */}
53
+ <div
54
+ className="absolute inset-0"
55
+ style={{ backgroundColor: "currentColor", opacity: 0.2 }}
56
+ />
57
+ <div
58
+ className="nav-progress-bar absolute inset-y-0 left-0 w-1/3 rounded-full"
59
+ style={{ backgroundColor: "currentColor" }}
60
+ />
19
61
  </div>
20
62
  );
21
63
  }
@@ -0,0 +1,42 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { useRouter } from "@tanstack/react-router";
3
+ import { renderToString } from "react-dom/server";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import PreviewProviders from "./PreviewProviders";
7
+
8
+ // A section that reads React Query at render time — the exact shape that made
9
+ // the /live/previews in-place render throw "No QueryClient set" for commerce
10
+ // sections (e.g. Carousel.tsx) before PreviewProviders provided a client.
11
+ function SectionUsingQuery() {
12
+ const { data } = useQuery({ queryKey: ["preview-probe"], queryFn: () => "loaded" });
13
+ return <div data-testid="section">{data ?? "loading"}</div>;
14
+ }
15
+
16
+ function SectionUsingRouter() {
17
+ const router = useRouter();
18
+ return <div data-testid="router">{router ? "has-router" : "no-router"}</div>;
19
+ }
20
+
21
+ describe("PreviewProviders", () => {
22
+ it("renders a section that calls useQuery without throwing", () => {
23
+ const html = renderToString(
24
+ <PreviewProviders>
25
+ <SectionUsingQuery />
26
+ </PreviewProviders>,
27
+ );
28
+ // No QueryClient error, and the section renders its pending state (static
29
+ // preview render — the query never resolves during renderToString).
30
+ expect(html).toContain("loading");
31
+ expect(html).not.toContain("No QueryClient");
32
+ });
33
+
34
+ it("still provides the TanStack Router context", () => {
35
+ const html = renderToString(
36
+ <PreviewProviders>
37
+ <SectionUsingRouter />
38
+ </PreviewProviders>,
39
+ );
40
+ expect(html).toContain("has-router");
41
+ });
42
+ });
@@ -1,3 +1,4 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1
2
  import {
2
3
  createMemoryHistory,
3
4
  createRootRoute,
@@ -16,11 +17,29 @@ const previewRouter = createRouter({
16
17
  history: createMemoryHistory({ initialEntries: ["/"] }),
17
18
  });
18
19
 
20
+ // A dedicated QueryClient for preview renders. Sections that call useQuery /
21
+ // useQueryClient at render time (e.g. commerce shelves, carousels) would
22
+ // otherwise throw "No QueryClient set" during the admin's isolated
23
+ // renderToString pass — the site's production QueryClientProvider (wired via
24
+ // createDecoRouter's `Wrap`) is not in scope on the /live/previews path.
25
+ // Retries are disabled so a preview render never hangs on a failing query;
26
+ // this is a static content render (matches __decoFBT=0), so cached data is
27
+ // not expected here.
28
+ const previewQueryClient = new QueryClient({
29
+ defaultOptions: { queries: { retry: false } },
30
+ });
31
+
19
32
  /**
20
33
  * Default preview wrapper for admin iframe rendering.
21
34
  * Provides a TanStack Router context with memory history so components
22
- * that depend on router hooks (Link, useNavigate, etc.) work in previews.
35
+ * that depend on router hooks (Link, useNavigate, etc.) work in previews,
36
+ * plus a QueryClientProvider so components that call React Query hooks at
37
+ * render time don't crash the preview.
23
38
  */
24
39
  export default function PreviewProviders({ children }: { children: ReactNode }) {
25
- return <RouterContextProvider router={previewRouter as any}>{children}</RouterContextProvider>;
40
+ return (
41
+ <QueryClientProvider client={previewQueryClient}>
42
+ <RouterContextProvider router={previewRouter as any}>{children}</RouterContextProvider>
43
+ </QueryClientProvider>
44
+ );
26
45
  }
@@ -0,0 +1,91 @@
1
+ import { act, useEffect, useLayoutEffect, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /**
6
+ * Guards the frame gate in `DeferredSectionWrapper`'s IntersectionObserver
7
+ * effect (`DecoPageRenderer.tsx`).
8
+ *
9
+ * Enabling deferral on client navigation made a latent ordering problem
10
+ * reachable. On a SPA transition the scroll position is still wherever the user
11
+ * left the previous page when the new page's skeletons mount. TanStack resets it
12
+ * from the `onRendered` event, emitted by a `useLayoutEffect` in react-router's
13
+ * `OnRendered` that depends on the `resolvedLocation` store — which is itself
14
+ * written from another `useLayoutEffect` (`Transitioner`). The reset therefore
15
+ * lands one commit AFTER the mount commit, and React flushes the mount commit's
16
+ * passive effects before starting that follow-up render.
17
+ *
18
+ * Net effect without a gate: the observer evaluates intersection against the
19
+ * stale offset, so a user navigating from the bottom of a long page has every
20
+ * skeleton in view simultaneously and every deferred section fires its serverFn
21
+ * POST at once — the thundering herd deferral exists to avoid.
22
+ *
23
+ * This models that exact effect topology rather than mounting the real router,
24
+ * because the ordering is a property of React's commit/flush sequence, not of
25
+ * TanStack. If React ever changes it so passive effects run after a
26
+ * layout-effect-triggered re-render, the first case flips and the gate can go.
27
+ */
28
+ function runOrdering(gate: "none" | "raf") {
29
+ const order: string[] = [];
30
+
31
+ /** Stands in for TanStack's Transitioner writing `resolvedLocation`. */
32
+ function Transitioner({ setResolved }: { setResolved: (n: number) => void }) {
33
+ useLayoutEffect(() => setResolved(1), [setResolved]);
34
+ return null;
35
+ }
36
+
37
+ /** Stands in for `OnRendered` → `onRendered` → scroll-restoration reset. */
38
+ function OnRendered({ resolved }: { resolved: number }) {
39
+ useLayoutEffect(() => {
40
+ if (resolved > 0) order.push("SCROLL_RESET");
41
+ }, [resolved]);
42
+ return null;
43
+ }
44
+
45
+ /** Stands in for DeferredSectionWrapper's observer effect. */
46
+ function DeferredWrapper() {
47
+ useEffect(() => {
48
+ const observe = () => order.push("OBSERVE");
49
+ if (gate === "none") {
50
+ observe();
51
+ return;
52
+ }
53
+ const raf = requestAnimationFrame(observe);
54
+ return () => cancelAnimationFrame(raf);
55
+ }, []);
56
+ return null;
57
+ }
58
+
59
+ function App() {
60
+ const [resolved, setResolved] = useState(0);
61
+ return (
62
+ <>
63
+ {/* Deliberately first: children flush before the root's own effects. */}
64
+ <DeferredWrapper />
65
+ <Transitioner setResolved={setResolved} />
66
+ <OnRendered resolved={resolved} />
67
+ </>
68
+ );
69
+ }
70
+
71
+ const el = document.createElement("div");
72
+ document.body.appendChild(el);
73
+ act(() => {
74
+ createRoot(el).render(<App />);
75
+ });
76
+ return order;
77
+ }
78
+
79
+ describe("deferred observer vs router scroll reset", () => {
80
+ it("observing synchronously loses the race — this is why the gate exists", () => {
81
+ // If this ever reports SCROLL_RESET first, React's flush order changed and
82
+ // the requestAnimationFrame gate in DecoPageRenderer is dead weight.
83
+ expect(runOrdering("none")).toEqual(["OBSERVE", "SCROLL_RESET"]);
84
+ });
85
+
86
+ it("one frame is enough for the scroll reset to land first", () => {
87
+ // rAF does not run inside act(), so the reset is the only thing recorded —
88
+ // i.e. observation is strictly after it, which is the point.
89
+ expect(runOrdering("raf")).toEqual(["SCROLL_RESET"]);
90
+ });
91
+ });
@@ -4,6 +4,7 @@ export {
4
4
  SectionRenderer,
5
5
  } from "./DecoPageRenderer";
6
6
  export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
7
+ export { DraftPreviewIndicator } from "./DraftPreviewIndicator";
7
8
  export { NavigationProgress } from "./NavigationProgress";
8
9
  export { StableOutlet } from "./StableOutlet";
9
10
  export { default as PreviewProviders } from "./PreviewProviders";