@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.
- package/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
3
|
+
import { DecoPageRenderer } from "../hooks/DecoPageRenderer";
|
|
4
|
+
import type { Device } from "@decocms/blocks/sdk/useDevice";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default CMS page component. Renders all resolved sections.
|
|
8
|
+
* Sites can use this directly or build their own.
|
|
9
|
+
*/
|
|
10
|
+
export function CmsPage({
|
|
11
|
+
sections,
|
|
12
|
+
deferredSections,
|
|
13
|
+
deferredPromises,
|
|
14
|
+
pagePath,
|
|
15
|
+
pageUrl,
|
|
16
|
+
device,
|
|
17
|
+
}: {
|
|
18
|
+
sections: ResolvedSection[];
|
|
19
|
+
deferredSections?: DeferredSection[];
|
|
20
|
+
deferredPromises?: Record<string, Promise<ResolvedSection | null>>;
|
|
21
|
+
pagePath?: string;
|
|
22
|
+
pageUrl?: string;
|
|
23
|
+
/** Server-resolved device from the page loader — keeps useDevice() hydration-stable. */
|
|
24
|
+
device?: Device;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<div>
|
|
28
|
+
<DecoPageRenderer
|
|
29
|
+
sections={sections}
|
|
30
|
+
deferredSections={deferredSections}
|
|
31
|
+
deferredPromises={deferredPromises}
|
|
32
|
+
pagePath={pagePath}
|
|
33
|
+
pageUrl={pageUrl}
|
|
34
|
+
device={device}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default 404 page for CMS routes.
|
|
42
|
+
* Sites can override with their own branded version.
|
|
43
|
+
*/
|
|
44
|
+
export function NotFoundPage() {
|
|
45
|
+
return (
|
|
46
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
47
|
+
<div className="text-center">
|
|
48
|
+
<h1 className="text-6xl font-bold text-base-content/20 mb-4">404</h1>
|
|
49
|
+
<h2 className="text-2xl font-bold mb-2">Page Not Found</h2>
|
|
50
|
+
<p className="text-base-content/60 mb-6">No CMS page block matches this URL.</p>
|
|
51
|
+
<Link to="/" className="btn btn-primary">
|
|
52
|
+
Go Home
|
|
53
|
+
</Link>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type { PageSeo } from "@decocms/blocks/cms";
|
|
2
|
+
export type { Device } from "@decocms/blocks/sdk/useDevice";
|
|
3
|
+
export {
|
|
4
|
+
decoInvokeRoute,
|
|
5
|
+
decoMetaRoute,
|
|
6
|
+
decoRenderRoute,
|
|
7
|
+
} from "./adminRoutes";
|
|
8
|
+
export {
|
|
9
|
+
CmsPagePendingFallback,
|
|
10
|
+
type CmsRouteOptions,
|
|
11
|
+
cmsHomeRouteConfig,
|
|
12
|
+
cmsRouteConfig,
|
|
13
|
+
deferredSectionLoader,
|
|
14
|
+
loadCmsHomePage,
|
|
15
|
+
loadCmsPage,
|
|
16
|
+
loadDeferredSection,
|
|
17
|
+
setSectionChunkMap,
|
|
18
|
+
} from "./cmsRoute";
|
|
19
|
+
export { CmsPage, NotFoundPage } from "./components";
|
|
20
|
+
export {
|
|
21
|
+
resolveSiteGlobals,
|
|
22
|
+
type SiteGlobalsLoaderData,
|
|
23
|
+
withSiteGlobals,
|
|
24
|
+
} from "./withSiteGlobals";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { derivePageUrl, isClientNavigation } from "./pageUrl";
|
|
3
|
+
|
|
4
|
+
describe("derivePageUrl (#280 — client-nav request URL)", () => {
|
|
5
|
+
it("SSR real page without query: rebuilds from fullPath", () => {
|
|
6
|
+
expect(
|
|
7
|
+
derivePageUrl("/granado/produto", new URL("https://o.com/granado/produto")),
|
|
8
|
+
).toBe("https://o.com/granado/produto");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("SSR real page preserves duplicate query params from the server URL", () => {
|
|
12
|
+
// The TanStack search record collapses dup params; the server URL keeps them.
|
|
13
|
+
expect(
|
|
14
|
+
derivePageUrl(
|
|
15
|
+
"/c/shoes?filter.category-1=a",
|
|
16
|
+
new URL("https://o.com/c/shoes?filter.category-1=a&filter.category-1=b"),
|
|
17
|
+
),
|
|
18
|
+
).toBe("https://o.com/c/shoes?filter.category-1=a&filter.category-1=b");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("client nav without query: rebuilds from fullPath, NOT the /_serverFn URL", () => {
|
|
22
|
+
// The regression guard: getRequestUrl() is the serverFn endpoint here.
|
|
23
|
+
const serverUrl = new URL(
|
|
24
|
+
"https://o.com/_serverFn/loadCmsPage?payload=%2Fgranado%2Fproduto",
|
|
25
|
+
);
|
|
26
|
+
expect(derivePageUrl("/granado/produto", serverUrl)).toBe(
|
|
27
|
+
"https://o.com/granado/produto",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("client nav with query: rebuilds path+search from fullPath", () => {
|
|
32
|
+
const serverUrl = new URL("https://o.com/_serverFn/loadCmsPage?payload=x");
|
|
33
|
+
expect(derivePageUrl("/s?q=foo", serverUrl)).toBe("https://o.com/s?q=foo");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("home '/' on client nav: rebuilds to '/', not the /_serverFn URL", () => {
|
|
37
|
+
// basePath '/' is a prefix of every path; the old startsWith check would
|
|
38
|
+
// have returned the serverFn URL here.
|
|
39
|
+
const serverUrl = new URL("https://o.com/_serverFn/loadCmsPage?payload=x");
|
|
40
|
+
expect(derivePageUrl("/", serverUrl)).toBe("https://o.com/");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isClientNavigation (SPA vs SSR detection)", () => {
|
|
45
|
+
it("SSR real page (path matches): false", () => {
|
|
46
|
+
expect(
|
|
47
|
+
isClientNavigation("/c/shoes?q=a", new URL("https://o.com/c/shoes?q=a")),
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("SSR real page without query (path matches): false", () => {
|
|
52
|
+
expect(
|
|
53
|
+
isClientNavigation("/granado/produto", new URL("https://o.com/granado/produto")),
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("client nav (server fn endpoint): true", () => {
|
|
58
|
+
const serverUrl = new URL("https://o.com/_serverFn/loadCmsPage?payload=x");
|
|
59
|
+
expect(isClientNavigation("/c/shoes", serverUrl)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("home '/' on client nav: true", () => {
|
|
63
|
+
const serverUrl = new URL("https://o.com/_serverFn/loadCmsPage?payload=x");
|
|
64
|
+
expect(isClientNavigation("/", serverUrl)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("home '/' on SSR: false", () => {
|
|
68
|
+
expect(isClientNavigation("/", new URL("https://o.com/"))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the real page URL for a CMS page load.
|
|
3
|
+
*
|
|
4
|
+
* On client-side (SPA) navigation the CMS server function runs at a
|
|
5
|
+
* `/_serverFn/<hash>?payload=...` URL — that's what `getRequestUrl()` returns,
|
|
6
|
+
* and it is NOT the page the router is navigating to. In that case we rebuild
|
|
7
|
+
* the URL from `fullPath` (the page's path + search). On a real page request
|
|
8
|
+
* (SSR / full reload) the server URL's path equals the page path, and we prefer
|
|
9
|
+
* it because it preserves duplicate query params (e.g.
|
|
10
|
+
* `filter.category-1=a&filter.category-1=b`) that the TanStack Router search
|
|
11
|
+
* object — a plain `Record<string,string>` — would collapse.
|
|
12
|
+
*
|
|
13
|
+
* Keeping the derived `__pageUrl` consistent with `__pagePath` is what makes
|
|
14
|
+
* URL/slug-keyed loaders resolve identically on SSR and SPA navigation (#280).
|
|
15
|
+
* The previous logic compared with `startsWith` and fell back to the serverFn
|
|
16
|
+
* URL when `fullPath` had no query string, leaking `/_serverFn/...` into
|
|
17
|
+
* `matcherCtx.url` on client nav.
|
|
18
|
+
*
|
|
19
|
+
* @param fullPath page path + optional search, e.g. `/c/shoes?q=foo`
|
|
20
|
+
* @param serverUrl URL returned by `getRequestUrl()` (real page URL on SSR,
|
|
21
|
+
* `/_serverFn/...` on client nav)
|
|
22
|
+
*/
|
|
23
|
+
export function derivePageUrl(fullPath: string, serverUrl: URL): string {
|
|
24
|
+
const [basePath] = fullPath.split("?");
|
|
25
|
+
// Trust the server URL (to keep duplicate query params) only when its path
|
|
26
|
+
// IS the page being loaded — i.e. a real page request. On client nav the
|
|
27
|
+
// path is `/_serverFn/...`, so we rebuild from `fullPath` instead.
|
|
28
|
+
return serverUrl.pathname === basePath && serverUrl.search
|
|
29
|
+
? serverUrl.toString()
|
|
30
|
+
: new URL(fullPath, serverUrl.origin).toString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when the page load came from a client-side (SPA) navigation via
|
|
35
|
+
* TanStack `<Link>`: the CMS server function runs at `/_serverFn/<hash>`, so
|
|
36
|
+
* `serverUrl.pathname` is NOT the page path. On a real document request (SSR /
|
|
37
|
+
* full reload, including bots) `serverUrl.pathname === basePath`.
|
|
38
|
+
*
|
|
39
|
+
* Section deferral is a streaming-SSR optimization (shrinks the initial HTML /
|
|
40
|
+
* TTFB). On a client navigation the server fn returns JSON in one shot — there
|
|
41
|
+
* is no streaming benefit, so deferral only adds a round-trip and a skeleton.
|
|
42
|
+
* Callers use this to resolve all sections eagerly on SPA navigation.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors the `serverUrl.pathname === basePath` comparison `derivePageUrl`
|
|
45
|
+
* already relies on, so the two stay consistent and we avoid hardcoding the
|
|
46
|
+
* internal `/_serverFn` path.
|
|
47
|
+
*
|
|
48
|
+
* @param fullPath page path + optional search, e.g. `/c/shoes?q=foo`
|
|
49
|
+
* @param serverUrl URL returned by `getRequestUrl()`
|
|
50
|
+
*/
|
|
51
|
+
export function isClientNavigation(fullPath: string, serverUrl: URL): boolean {
|
|
52
|
+
const [basePath] = fullPath.split("?");
|
|
53
|
+
return serverUrl.pathname !== basePath;
|
|
54
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { onChangeListeners } = vi.hoisted(() => ({
|
|
4
|
+
onChangeListeners: [] as Array<() => void>,
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("@decocms/blocks/cms", () => ({
|
|
8
|
+
loadBlocks: vi.fn(),
|
|
9
|
+
onChange: vi.fn((listener: () => void) => {
|
|
10
|
+
onChangeListeners.push(listener);
|
|
11
|
+
}),
|
|
12
|
+
resolvePageSections: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { loadBlocks, resolvePageSections } from "@decocms/blocks/cms";
|
|
16
|
+
import {
|
|
17
|
+
__resetSiteGlobalsCache,
|
|
18
|
+
dedupeGlobals,
|
|
19
|
+
resolveSiteGlobals,
|
|
20
|
+
withSiteGlobals,
|
|
21
|
+
} from "./withSiteGlobals";
|
|
22
|
+
|
|
23
|
+
const mockedLoadBlocks = loadBlocks as unknown as ReturnType<typeof vi.fn>;
|
|
24
|
+
const mockedResolvePageSections = resolvePageSections as unknown as ReturnType<typeof vi.fn>;
|
|
25
|
+
|
|
26
|
+
describe("withSiteGlobals", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
__resetSiteGlobalsCache();
|
|
29
|
+
mockedLoadBlocks.mockReset();
|
|
30
|
+
mockedResolvePageSections.mockReset();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("resolveSiteGlobals", () => {
|
|
34
|
+
it("returns empty when there is no Site block", async () => {
|
|
35
|
+
mockedLoadBlocks.mockReturnValue({});
|
|
36
|
+
const result = await resolveSiteGlobals();
|
|
37
|
+
expect(result.resolvedSections).toEqual([]);
|
|
38
|
+
expect(result.rawRefs).toEqual([]);
|
|
39
|
+
expect(mockedResolvePageSections).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns empty when Site block has no globals", async () => {
|
|
43
|
+
mockedLoadBlocks.mockReturnValue({ site: { seo: { title: "x" } } });
|
|
44
|
+
const result = await resolveSiteGlobals();
|
|
45
|
+
expect(result.resolvedSections).toEqual([]);
|
|
46
|
+
expect(result.rawRefs).toEqual([]);
|
|
47
|
+
expect(mockedResolvePageSections).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("gathers theme + global + pageSections in order", async () => {
|
|
51
|
+
mockedLoadBlocks.mockReturnValue({
|
|
52
|
+
site: {
|
|
53
|
+
theme: { __resolveType: "Theme" },
|
|
54
|
+
global: [{ __resolveType: "Analytics" }, { __resolveType: "WishlistProvider" }],
|
|
55
|
+
pageSections: [{ __resolveType: "Session" }],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const resolved = [
|
|
59
|
+
{ component: "Theme.tsx", props: {}, key: "k0" },
|
|
60
|
+
{ component: "Analytics.tsx", props: {}, key: "k1" },
|
|
61
|
+
{ component: "Wishlist.tsx", props: {}, key: "k2" },
|
|
62
|
+
{ component: "Session.tsx", props: {}, key: "k3" },
|
|
63
|
+
];
|
|
64
|
+
mockedResolvePageSections.mockResolvedValue(resolved);
|
|
65
|
+
|
|
66
|
+
const result = await resolveSiteGlobals();
|
|
67
|
+
|
|
68
|
+
expect(result.rawRefs).toEqual([
|
|
69
|
+
{ __resolveType: "Theme" },
|
|
70
|
+
{ __resolveType: "Analytics" },
|
|
71
|
+
{ __resolveType: "WishlistProvider" },
|
|
72
|
+
{ __resolveType: "Session" },
|
|
73
|
+
]);
|
|
74
|
+
expect(result.resolvedSections).toEqual(resolved);
|
|
75
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("accepts both `site` (lowercase) and `Site` (PascalCase) block keys", async () => {
|
|
79
|
+
mockedLoadBlocks.mockReturnValue({
|
|
80
|
+
Site: { theme: { __resolveType: "Theme" } },
|
|
81
|
+
});
|
|
82
|
+
mockedResolvePageSections.mockResolvedValue([
|
|
83
|
+
{ component: "Theme.tsx", props: {}, key: "k0" },
|
|
84
|
+
]);
|
|
85
|
+
const result = await resolveSiteGlobals();
|
|
86
|
+
expect(result.rawRefs).toEqual([{ __resolveType: "Theme" }]);
|
|
87
|
+
expect(result.resolvedSections).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("dedupes inflight requests (single resolvePageSections call for parallel callers)", async () => {
|
|
91
|
+
mockedLoadBlocks.mockReturnValue({
|
|
92
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
93
|
+
});
|
|
94
|
+
let resolveFn!: (v: unknown[]) => void;
|
|
95
|
+
mockedResolvePageSections.mockImplementation(
|
|
96
|
+
() =>
|
|
97
|
+
new Promise((res) => {
|
|
98
|
+
resolveFn = res as any;
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const a = resolveSiteGlobals();
|
|
103
|
+
const b = resolveSiteGlobals();
|
|
104
|
+
resolveFn([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
105
|
+
const [ra, rb] = await Promise.all([a, b]);
|
|
106
|
+
|
|
107
|
+
expect(ra).toEqual(rb);
|
|
108
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("caches across calls within TTL", async () => {
|
|
112
|
+
mockedLoadBlocks.mockReturnValue({
|
|
113
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
114
|
+
});
|
|
115
|
+
mockedResolvePageSections.mockResolvedValue([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
116
|
+
|
|
117
|
+
await resolveSiteGlobals();
|
|
118
|
+
await resolveSiteGlobals();
|
|
119
|
+
await resolveSiteGlobals();
|
|
120
|
+
|
|
121
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("invalidates cache when onChange fires", async () => {
|
|
125
|
+
mockedLoadBlocks.mockReturnValue({
|
|
126
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
127
|
+
});
|
|
128
|
+
mockedResolvePageSections.mockResolvedValue([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
129
|
+
|
|
130
|
+
await resolveSiteGlobals();
|
|
131
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
132
|
+
|
|
133
|
+
// Simulate a CMS reload
|
|
134
|
+
for (const listener of onChangeListeners) listener();
|
|
135
|
+
|
|
136
|
+
await resolveSiteGlobals();
|
|
137
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("does not cache failures (next call retries)", async () => {
|
|
141
|
+
mockedLoadBlocks.mockReturnValue({
|
|
142
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
143
|
+
});
|
|
144
|
+
mockedResolvePageSections
|
|
145
|
+
.mockRejectedValueOnce(new Error("boom"))
|
|
146
|
+
.mockResolvedValueOnce([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
147
|
+
|
|
148
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
149
|
+
const first = await resolveSiteGlobals();
|
|
150
|
+
expect(first.resolvedSections).toEqual([]);
|
|
151
|
+
|
|
152
|
+
const second = await resolveSiteGlobals();
|
|
153
|
+
expect(second.resolvedSections).toHaveLength(1);
|
|
154
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
|
|
155
|
+
errSpy.mockRestore();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("withSiteGlobals (deprecated no-op)", () => {
|
|
160
|
+
// Site globals merging moved into the `loadCmsPage` server function so SSR
|
|
161
|
+
// and SPA navigations both go through the same server-side path (#233).
|
|
162
|
+
// The wrapper is now a passthrough kept only for backward compatibility.
|
|
163
|
+
it("is an identity wrapper — returns the route config unchanged", () => {
|
|
164
|
+
const baseLoader = vi.fn().mockResolvedValue({ resolvedSections: [] });
|
|
165
|
+
const input = { loader: baseLoader, otherField: "kept" } as any;
|
|
166
|
+
const cfg = withSiteGlobals(input);
|
|
167
|
+
expect(cfg).toBe(input);
|
|
168
|
+
expect(cfg.loader).toBe(baseLoader);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("dedupeGlobals", () => {
|
|
173
|
+
it("returns empty when globals is empty", () => {
|
|
174
|
+
expect(
|
|
175
|
+
dedupeGlobals(
|
|
176
|
+
[],
|
|
177
|
+
[{ component: "Header.tsx", props: {}, key: "p0" }],
|
|
178
|
+
),
|
|
179
|
+
).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("drops globals whose component already appears in existing", () => {
|
|
183
|
+
const globals = [
|
|
184
|
+
{ component: "Theme.tsx", props: {}, key: "g0" },
|
|
185
|
+
{ component: "Session.tsx", props: {}, key: "g1" },
|
|
186
|
+
];
|
|
187
|
+
const existing = [
|
|
188
|
+
{ component: "Session.tsx", props: { fromPage: true }, key: "p0" },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const result = dedupeGlobals(globals, existing);
|
|
192
|
+
// Session dropped (already on page); Theme kept.
|
|
193
|
+
expect(result.map((s) => s.component)).toEqual(["Theme.tsx"]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("dedupes within globals (first-wins)", () => {
|
|
197
|
+
const globals = [
|
|
198
|
+
{ component: "Session.tsx", props: { from: "global" }, key: "g0" },
|
|
199
|
+
{ component: "Session.tsx", props: { from: "pageSections" }, key: "g1" },
|
|
200
|
+
];
|
|
201
|
+
const result = dedupeGlobals(globals, []);
|
|
202
|
+
expect(result).toHaveLength(1);
|
|
203
|
+
expect(result[0].props.from).toBe("global");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Globals Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Opt-in helper that merges sections declared in the CMS `Site` block
|
|
5
|
+
* (`site.theme + site.global + site.pageSections`) into every page's
|
|
6
|
+
* `resolvedSections` array.
|
|
7
|
+
*
|
|
8
|
+
* Without this wrapper, only `site.seo` is consumed by `cmsRouteConfig` —
|
|
9
|
+
* the rest of the Site block is dormant CMS data. Sites that declare
|
|
10
|
+
* theme/analytics/wishlist/help-button blocks at the site level (rather
|
|
11
|
+
* than per-page) can opt in here to have them rendered automatically.
|
|
12
|
+
*
|
|
13
|
+
* @example Site's `src/routes/$.tsx`:
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
16
|
+
* import { cmsRouteConfig, withSiteGlobals } from "@decocms/start/routes";
|
|
17
|
+
*
|
|
18
|
+
* export const Route = createFileRoute("/$")({
|
|
19
|
+
* ...withSiteGlobals(cmsRouteConfig({
|
|
20
|
+
* siteName: "Bagaggio",
|
|
21
|
+
* defaultTitle: "Bagaggio",
|
|
22
|
+
* })),
|
|
23
|
+
* component: ...,
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
29
|
+
import { loadBlocks, onChange, resolvePageSections } from "@decocms/blocks/cms";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A raw site-block ref — the JSON object pulled from a `.deco/blocks/*.json`
|
|
37
|
+
* file before block resolution. Always an object with at least
|
|
38
|
+
* `__resolveType`; concrete props vary by block.
|
|
39
|
+
*/
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
export type SiteGlobalRef = Record<string, any>;
|
|
42
|
+
|
|
43
|
+
/** Loader output additions when site globals are merged into the page. */
|
|
44
|
+
export interface SiteGlobalsLoaderData {
|
|
45
|
+
/**
|
|
46
|
+
* Raw refs (before resolution) declared in `site.theme`, `site.global`, and
|
|
47
|
+
* `site.pageSections`. Includes refs for sections that don't resolve into
|
|
48
|
+
* the section tree (`SKIP_RESOLVE_TYPES`) — useful for sites that need to
|
|
49
|
+
* read site-level data (analytics IDs, manifest config, etc.) outside the
|
|
50
|
+
* normal section render path.
|
|
51
|
+
*
|
|
52
|
+
* Ordering: `theme`, then `global`, then `pageSections`.
|
|
53
|
+
*/
|
|
54
|
+
rawRefs: SiteGlobalRef[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SiteBlock {
|
|
58
|
+
theme?: SiteGlobalRef;
|
|
59
|
+
global?: SiteGlobalRef[];
|
|
60
|
+
pageSections?: SiteGlobalRef[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface CacheEntry {
|
|
64
|
+
resolvedSections: ResolvedSection[];
|
|
65
|
+
rawRefs: SiteGlobalRef[];
|
|
66
|
+
expiresAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Globals resolution (cached, with onChange invalidation)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60_000;
|
|
74
|
+
const cacheTtlMs = DEFAULT_CACHE_TTL_MS;
|
|
75
|
+
|
|
76
|
+
let cache: CacheEntry | null = null;
|
|
77
|
+
let inflight: Promise<CacheEntry> | null = null;
|
|
78
|
+
|
|
79
|
+
onChange(() => {
|
|
80
|
+
cache = null;
|
|
81
|
+
inflight = null;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function readSiteBlock(): SiteBlock | null {
|
|
85
|
+
const blocks = loadBlocks();
|
|
86
|
+
// Block keys vary by site convention — try both common cases.
|
|
87
|
+
const site = (blocks.site ?? blocks.Site) as SiteBlock | undefined;
|
|
88
|
+
return site ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gatherSectionRefs(site: SiteBlock): SiteGlobalRef[] {
|
|
92
|
+
const refs: SiteGlobalRef[] = [];
|
|
93
|
+
if (site.theme) refs.push(site.theme);
|
|
94
|
+
if (Array.isArray(site.global)) refs.push(...site.global);
|
|
95
|
+
if (Array.isArray(site.pageSections)) refs.push(...site.pageSections);
|
|
96
|
+
return refs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const EMPTY_ENTRY: CacheEntry = {
|
|
100
|
+
resolvedSections: [],
|
|
101
|
+
rawRefs: [],
|
|
102
|
+
expiresAt: Number.POSITIVE_INFINITY, // empty entries don't need refresh
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve `site.theme + site.global + site.pageSections` into a list of
|
|
107
|
+
* `ResolvedSection`s, with in-flight dedup and 5-minute SWR caching.
|
|
108
|
+
*
|
|
109
|
+
* Cache is invalidated by `onChange()` from the CMS loader, so admin edits
|
|
110
|
+
* and decofile reloads are reflected on the next request.
|
|
111
|
+
*
|
|
112
|
+
* Exposed as a util so sites can call it directly if they need globals
|
|
113
|
+
* outside the route loader path (rare).
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveSiteGlobals(): Promise<{
|
|
116
|
+
resolvedSections: ResolvedSection[];
|
|
117
|
+
rawRefs: SiteGlobalRef[];
|
|
118
|
+
}> {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (cache && cache.expiresAt > now) return cache;
|
|
121
|
+
if (inflight) return inflight;
|
|
122
|
+
|
|
123
|
+
const site = readSiteBlock();
|
|
124
|
+
if (!site) {
|
|
125
|
+
// Cache the empty result so subsequent requests don't re-walk the
|
|
126
|
+
// block registry. `onChange` invalidation still applies — if a Site
|
|
127
|
+
// block appears later, the listener nulls `cache` and we re-check.
|
|
128
|
+
cache = EMPTY_ENTRY;
|
|
129
|
+
return EMPTY_ENTRY;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rawRefs = gatherSectionRefs(site);
|
|
133
|
+
if (rawRefs.length === 0) {
|
|
134
|
+
cache = EMPTY_ENTRY;
|
|
135
|
+
return EMPTY_ENTRY;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
inflight = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const resolvedSections = await resolvePageSections(rawRefs);
|
|
141
|
+
const entry: CacheEntry = {
|
|
142
|
+
resolvedSections,
|
|
143
|
+
rawRefs,
|
|
144
|
+
expiresAt: Date.now() + cacheTtlMs,
|
|
145
|
+
};
|
|
146
|
+
cache = entry;
|
|
147
|
+
return entry;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error("[site-globals] failed to resolve:", err);
|
|
150
|
+
// Don't cache failures — let the next request retry.
|
|
151
|
+
return { resolvedSections: [], rawRefs, expiresAt: 0 };
|
|
152
|
+
} finally {
|
|
153
|
+
inflight = null;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
|
|
157
|
+
return inflight;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Dedupe — collapse global/pageSection components that also exist on the page
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Filter `globals` to remove sections whose `component` already appears in
|
|
166
|
+
* `existing` (page-level sections). Page sections take precedence — globals
|
|
167
|
+
* that conflict are dropped.
|
|
168
|
+
*
|
|
169
|
+
* This collapses the common case where a section like `Session` is declared
|
|
170
|
+
* both in `site.global` and in a page's section list, which would otherwise
|
|
171
|
+
* render twice.
|
|
172
|
+
*/
|
|
173
|
+
export function dedupeGlobals(globals: ResolvedSection[], existing: ResolvedSection[]): ResolvedSection[] {
|
|
174
|
+
if (globals.length === 0) return [];
|
|
175
|
+
const seenComponents = new Set<string>();
|
|
176
|
+
for (const s of existing) {
|
|
177
|
+
if (typeof s.component === "string") seenComponents.add(s.component);
|
|
178
|
+
}
|
|
179
|
+
const result: ResolvedSection[] = [];
|
|
180
|
+
for (const s of globals) {
|
|
181
|
+
if (typeof s.component === "string") {
|
|
182
|
+
if (seenComponents.has(s.component)) continue;
|
|
183
|
+
seenComponents.add(s.component); // also dedupe within globals (e.g. Session in both site.global AND site.pageSections)
|
|
184
|
+
}
|
|
185
|
+
result.push(s);
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Public wrapper API (deprecated — kept for backward compat)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
type AnyLoader = (...args: any[]) => Promise<any>;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @deprecated Site globals are now resolved automatically inside the
|
|
198
|
+
* `loadCmsPage` / `loadCmsHomePage` server functions. Wrapping
|
|
199
|
+
* `cmsRouteConfig` is a no-op — the call is kept for backward compat so
|
|
200
|
+
* existing sites don't need to change their `routes/$.tsx` immediately.
|
|
201
|
+
*
|
|
202
|
+
* Previously, this wrapper called `resolveSiteGlobals()` from the route
|
|
203
|
+
* loader, which runs **client-side** on SPA navigations. On SPA, the vite
|
|
204
|
+
* plugin replaces `blocks.gen.ts` with `{}` in the client bundle, so
|
|
205
|
+
* `loadBlocks()` returned a stub and globals silently dropped — see #233.
|
|
206
|
+
*
|
|
207
|
+
* Moving resolution into the server function makes SSR (F5) and SPA
|
|
208
|
+
* navigations both go through the same server-side path.
|
|
209
|
+
*/
|
|
210
|
+
export function withSiteGlobals<T extends { loader: AnyLoader }>(routeConfig: T): T {
|
|
211
|
+
return routeConfig;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Test-only resets (not exported in public types — used by withSiteGlobals.test.ts)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/** @internal */
|
|
219
|
+
export function __resetSiteGlobalsCache() {
|
|
220
|
+
cache = null;
|
|
221
|
+
inflight = null;
|
|
222
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request / Response cookie passthrough helpers for TanStack Start.
|
|
3
|
+
*
|
|
4
|
+
* Provides two framework-bound functions that commerce apps
|
|
5
|
+
* (VTEX, Shopify, etc.) can plug into their cookie provider hooks
|
|
6
|
+
* to transparently forward browser cookies to API calls and
|
|
7
|
+
* Set-Cookie headers back to the browser.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* // setup.ts
|
|
12
|
+
* import { getRequestCookieHeader, forwardResponseCookies } from "@decocms/start/sdk/cookiePassthrough";
|
|
13
|
+
* import { setRequestCookieProvider, setResponseCookieForwarder } from "@decocms/apps/vtex";
|
|
14
|
+
*
|
|
15
|
+
* setRequestCookieProvider(getRequestCookieHeader);
|
|
16
|
+
* setResponseCookieForwarder(forwardResponseCookies);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
getRequestHeader,
|
|
22
|
+
getResponseHeaders,
|
|
23
|
+
setResponseHeader,
|
|
24
|
+
} from "@tanstack/react-start/server";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the Cookie header from the current TanStack Start request context.
|
|
28
|
+
* Safe to call outside a request scope (returns undefined).
|
|
29
|
+
*/
|
|
30
|
+
export function getRequestCookieHeader(): string | undefined {
|
|
31
|
+
try {
|
|
32
|
+
return getRequestHeader("cookie") ?? undefined;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Appends Set-Cookie headers to the current TanStack Start response.
|
|
40
|
+
* Preserves any Set-Cookie headers already set by other middleware or
|
|
41
|
+
* earlier calls, so multiple API calls in one request don't clobber
|
|
42
|
+
* each other's cookies.
|
|
43
|
+
*
|
|
44
|
+
* Safe to call outside a request scope (no-op).
|
|
45
|
+
*/
|
|
46
|
+
export function forwardResponseCookies(cookies: string[]): void {
|
|
47
|
+
if (!cookies.length) return;
|
|
48
|
+
try {
|
|
49
|
+
const headers = getResponseHeaders();
|
|
50
|
+
const existing: string[] =
|
|
51
|
+
typeof headers.getSetCookie === "function"
|
|
52
|
+
? headers.getSetCookie()
|
|
53
|
+
: [];
|
|
54
|
+
setResponseHeader("set-cookie", [...existing, ...cookies]);
|
|
55
|
+
} catch {
|
|
56
|
+
// Outside request context (build time, etc.) — ignore.
|
|
57
|
+
}
|
|
58
|
+
}
|