@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- package/src/vite/plugin.test.ts +125 -1
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
interface Window {
|
|
19
|
+
__deco_ready?: boolean;
|
|
20
|
+
}
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
function buildDecoEventsBootstrap(account?: string): string {
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -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";
|