@decocms/nextjs 8.0.0 → 8.4.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 (85) hide show
  1. package/README.md +77 -332
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +35 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +43 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/proxy/index.d.ts +79 -0
  27. package/dist/proxy/index.d.ts.map +1 -0
  28. package/dist/proxy/index.js +92 -0
  29. package/dist/proxy/index.js.map +1 -0
  30. package/dist/server/index.d.ts +46 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +64 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/slot.d.ts +6 -0
  35. package/dist/server/slot.d.ts.map +1 -0
  36. package/dist/server/slot.js +13 -0
  37. package/dist/server/slot.js.map +1 -0
  38. package/dist/studio.d.ts +30 -0
  39. package/dist/studio.d.ts.map +1 -0
  40. package/dist/studio.js +55 -0
  41. package/dist/studio.js.map +1 -0
  42. package/package.json +41 -22
  43. package/src/DraftPreviewIndicator.tsx +17 -16
  44. package/src/StudioBridge.ts +23 -0
  45. package/src/config/index.cjs +78 -0
  46. package/src/config/index.d.cts +27 -0
  47. package/src/draft.ts +30 -210
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -43
  51. package/src/proxy/index.ts +161 -0
  52. package/src/server/index.ts +77 -0
  53. package/src/server/slot.ts +14 -0
  54. package/src/studio.ts +54 -0
  55. package/src/ClientOnlySection.tsx +0 -20
  56. package/src/DecoPageRenderer.test.tsx +0 -37
  57. package/src/DecoPageRenderer.tsx +0 -107
  58. package/src/DecoRootLayout.test.tsx +0 -22
  59. package/src/DecoRootLayout.tsx +0 -82
  60. package/src/DeferredSection.test.tsx +0 -27
  61. package/src/DeferredSection.tsx +0 -59
  62. package/src/DraftPreviewBadge.render.test.tsx +0 -16
  63. package/src/DraftPreviewBadge.test.ts +0 -37
  64. package/src/DraftPreviewBadge.tsx +0 -252
  65. package/src/SectionRenderer.test.tsx +0 -97
  66. package/src/SectionRenderer.tsx +0 -58
  67. package/src/config.cjs +0 -92
  68. package/src/config.d.cts +0 -4
  69. package/src/config.test.ts +0 -86
  70. package/src/createDecoPage.draft.test.tsx +0 -91
  71. package/src/createDecoPage.test.tsx +0 -63
  72. package/src/createDecoPage.tsx +0 -173
  73. package/src/createDecoPreviewPage.test.tsx +0 -71
  74. package/src/createDecoPreviewPage.tsx +0 -108
  75. package/src/decoMark.ts +0 -11
  76. package/src/draft.test.ts +0 -73
  77. package/src/draftConstants.test.ts +0 -21
  78. package/src/draftConstants.ts +0 -15
  79. package/src/draftMiddleware.test.ts +0 -147
  80. package/src/draftMiddleware.ts +0 -136
  81. package/src/routeHandlers.test.ts +0 -243
  82. package/src/routeHandlers.ts +0 -227
  83. package/src/setup.test.ts +0 -253
  84. package/src/setup.ts +0 -180
  85. package/tsconfig.json +0 -7
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@decocms/nextjs/proxy`: draft previews in a Next.js proxy (`proxy.ts`, or
3
+ * `middleware.ts` before Next 16). No React and no block map: pass the proxy
4
+ * a CMS handle made with an empty block map and the app's options, which
5
+ * shares the app's instance (one per process) without pulling the site's
6
+ * sections into the proxy bundle:
7
+ *
8
+ * ```ts
9
+ * // src/cms.proxy.ts
10
+ * export const proxyCms = createCMS({ blocks: {}, ...cmsConfig });
11
+ * ```
12
+ *
13
+ * A site with a proxy of its own calls the pieces (`decideDraft`, then
14
+ * `draftRequestHeaders` on the request it forwards, then `applyDraft` on the
15
+ * response); a site without one exports `draftProxy(...)`.
16
+ *
17
+ * Statically rendered pages can't read a draft, so a drafted request to one
18
+ * is rewritten onto a dynamic draft route (`/_draft/<path>`, mounted as
19
+ * `app/%5Fdraft/...`: a literal `_draft` folder is private in Next and never
20
+ * routes).
21
+ */
22
+ import type { CMS } from "@decocms/blocks";
23
+ import { type NextRequest, NextResponse } from "next/server.js";
24
+ import { DRAFT_HEADER, draftResponseHeaders } from "../draft.ts";
25
+ import {
26
+ allowedDraft,
27
+ draftCookie,
28
+ endsPreview,
29
+ fromDraftLink,
30
+ hasDraftSignal,
31
+ type PreviewPolicy,
32
+ requestLike,
33
+ } from "../drafts.ts";
34
+
35
+ export {
36
+ inferredPreviewHosts,
37
+ type PreviewPolicy,
38
+ previewHostDecision,
39
+ requestLike,
40
+ } from "../drafts.ts";
41
+
42
+ /** What the proxy needs from the CMS: the site's declared preview hosts and their check. */
43
+ export type CMSHandle = Pick<CMS, "draftPointer" | "settings" | "forRelease">;
44
+
45
+ /** What the proxy does with a request's draft state. */
46
+ export interface DraftDecision {
47
+ /** The draft this request renders, after the host check; `null` for the release. */
48
+ pointer: string | null;
49
+ /** The `Set-Cookie` the response carries: storing a draft link's pointer, or ending a preview. */
50
+ setCookie: string | null;
51
+ }
52
+
53
+ /** The URL prefix a drafted request is rewritten onto (`app/%5Fdraft/[[...path]]`). */
54
+ export const DRAFT_ROUTE_PREFIX = "/_draft";
55
+
56
+ const INERT: DraftDecision = Object.freeze({ pointer: null, setCookie: null });
57
+
58
+ /**
59
+ * The draft a request renders and the cookie its response sets, under
60
+ * `policy` (the same options the pages pass to `bindDraft`). A request with
61
+ * no `?__draft=` and no draft cookie never reaches the CMS.
62
+ */
63
+ export async function decideDraft(
64
+ cms: CMSHandle,
65
+ request: NextRequest | Request,
66
+ policy: PreviewPolicy = {},
67
+ ): Promise<DraftDecision> {
68
+ const url = new URL(request.url);
69
+ const like = requestLike(request.headers, url.pathname + url.search, url.host);
70
+ if (like === null || !hasDraftSignal(like)) return INERT;
71
+ // Ending a preview is allowed on any host, so a cookie set earlier always goes.
72
+ if (endsPreview(like)) return { pointer: null, setCookie: draftCookie("", ["Max-Age=0"]) };
73
+ const pointer = await allowedDraft(cms, like, policy);
74
+ if (pointer === null) return INERT;
75
+ return {
76
+ pointer,
77
+ setCookie: fromDraftLink(like, pointer) ? draftCookie(encodeURIComponent(pointer)) : null,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * The request headers to forward: the accepted pointer on `x-deco-draft`, so
83
+ * the root layout can bind the draft on the same request (a layout gets no
84
+ * `searchParams`, and on a draft link the cookie is only on the response).
85
+ * A copy that arrives from outside is always removed.
86
+ */
87
+ export function draftRequestHeaders(
88
+ request: NextRequest | Request,
89
+ decision: DraftDecision,
90
+ ): Headers {
91
+ const headers = new Headers(request.headers);
92
+ if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
93
+ else headers.delete(DRAFT_HEADER);
94
+ return headers;
95
+ }
96
+
97
+ /** Whether `pathname` is on the draft route: a site answers a direct hit there with its 404. */
98
+ export function isDraftRoutePath(pathname: string): boolean {
99
+ return pathname === DRAFT_ROUTE_PREFIX || pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`);
100
+ }
101
+
102
+ /**
103
+ * The rewrite of a drafted request onto the draft route, keeping its path and
104
+ * forwarding the pointer; `null` when the request isn't drafted or is already
105
+ * on the draft route.
106
+ */
107
+ export function rewriteToDraftRoute(
108
+ request: NextRequest,
109
+ decision: DraftDecision,
110
+ ): NextResponse | null {
111
+ if (!decision.pointer) return null;
112
+ const url = request.nextUrl.clone();
113
+ if (isDraftRoutePath(url.pathname)) return null;
114
+ url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
115
+ return NextResponse.rewrite(url, {
116
+ request: { headers: draftRequestHeaders(request, decision) },
117
+ });
118
+ }
119
+
120
+ /**
121
+ * The cookie and the response headers of a draft: `Set-Cookie`, and for a
122
+ * drafted response `Cache-Control: no-store, private`, `Vary: Cookie` and
123
+ * `X-Robots-Tag: noindex, nofollow` (`draftResponseHeaders`). With the
124
+ * pointer in a cookie a draft shares its URL with the published page, so a
125
+ * cache keyed on the URL alone would serve it to visitors.
126
+ */
127
+ export function applyDraft<R extends Response>(response: R, decision: DraftDecision): R {
128
+ if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
129
+ for (const [name, value] of Object.entries(draftResponseHeaders(decision.pointer))) {
130
+ response.headers.set(name, value);
131
+ }
132
+ return response;
133
+ }
134
+
135
+ export interface DraftProxyOptions extends PreviewPolicy {
136
+ /**
137
+ * Which drafted requests go to the draft route (a statically rendered
138
+ * page's). Default: every drafted request outside the draft route. Return
139
+ * `false` for a route that is already dynamic and binds the draft itself.
140
+ */
141
+ rewrite?: (request: NextRequest) => boolean;
142
+ }
143
+
144
+ /**
145
+ * The whole draft flow for a site with no proxy of its own:
146
+ * `export const proxy = draftProxy(proxyCms, { site: "my-store" })`.
147
+ */
148
+ export function draftProxy(
149
+ cms: CMSHandle,
150
+ options: DraftProxyOptions = {},
151
+ ): (request: NextRequest) => Promise<NextResponse> {
152
+ const { rewrite = () => true, ...policy } = options;
153
+ return async (request) => {
154
+ const decision = await decideDraft(cms, request, policy);
155
+ const rewritten = rewrite(request) ? rewriteToDraftRoute(request, decision) : null;
156
+ const response =
157
+ rewritten ??
158
+ NextResponse.next({ request: { headers: draftRequestHeaders(request, decision) } });
159
+ return applyDraft(response, decision);
160
+ };
161
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `@decocms/nextjs/server`: draft previews in Server Components (pages,
3
+ * layouts, route handlers). Server only: it reads `next/headers`.
4
+ *
5
+ * A page (and, through the `x-deco-draft` header the proxy forwards, the root
6
+ * layout) binds the request's draft before it reads content, then reads
7
+ * through `contentClient(cms)`:
8
+ *
9
+ * ```tsx
10
+ * export default async function Page({ searchParams }) {
11
+ * await bindDraft(cms, { searchParams: await searchParams, site: "my-store" });
12
+ * const client = contentClient(cms);
13
+ * …
14
+ * return <>{sections}<DraftPreviewIndicator /></>;
15
+ * }
16
+ * ```
17
+ *
18
+ * The host check is the proxy's (`decideDraft`), repeated here with the same
19
+ * options, so a route the proxy doesn't match, or a forged `x-deco-draft`,
20
+ * still gets the release on a host that may not preview.
21
+ */
22
+
23
+ import type { Client, CMS } from "@decocms/blocks";
24
+ import { headers } from "next/headers.js";
25
+ import { DRAFT_HEADER, DRAFT_PARAM } from "../draft.ts";
26
+ import { allowedDraft, isDraftPreviewEnabled, type PreviewPolicy, requestLike } from "../drafts.ts";
27
+ import { activeDraftPointer, draftSlot } from "./slot.ts";
28
+
29
+ export { type PreviewPolicy, requestLike } from "../drafts.ts";
30
+ export { activeDraftPointer };
31
+
32
+ /** A page's `searchParams` prop, awaited. */
33
+ export type SearchParams = Record<string, string | string[] | undefined>;
34
+
35
+ export interface BindDraftOptions extends PreviewPolicy {
36
+ /** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-draft`). */
37
+ searchParams?: SearchParams;
38
+ }
39
+
40
+ function firstParam(searchParams: SearchParams | undefined, key: string): string | null {
41
+ const raw = searchParams?.[key];
42
+ return (Array.isArray(raw) ? raw[0] : raw) ?? null;
43
+ }
44
+
45
+ /**
46
+ * Binds the request's draft, if any: the page's own `?__draft=` first (`off`
47
+ * leaves preview), then the pointer the proxy forwarded on `x-deco-draft`,
48
+ * then the draft cookie, each on a host the options allow. Returns whether a
49
+ * draft was bound. With previews off on every host it returns `false`
50
+ * without reading the request, so a static page stays static.
51
+ */
52
+ export async function bindDraft(
53
+ cms: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
54
+ options: BindDraftOptions = {},
55
+ ): Promise<boolean> {
56
+ const { searchParams, ...policy } = options;
57
+ if (!(await isDraftPreviewEnabled(cms, policy))) return false;
58
+ const param = firstParam(searchParams, DRAFT_PARAM);
59
+ if (param === "off") return false;
60
+ const requestHeaders = await headers();
61
+ const forwarded = param ?? requestHeaders.get(DRAFT_HEADER);
62
+ const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
63
+ const request = requestLike(requestHeaders as Headers, `/${query}`);
64
+ const pointer = request ? await allowedDraft(cms, request, policy) : null;
65
+ if (pointer === null) return false;
66
+ draftSlot().pointer = pointer;
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * The content client for this request: the draft `bindDraft` bound, or the
72
+ * release.
73
+ */
74
+ export function contentClient(cms: Pick<CMS, "forDraft" | "forRelease">): Client {
75
+ const pointer = activeDraftPointer();
76
+ return pointer ? cms.forDraft(pointer) : cms.forRelease();
77
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The draft a request renders, held for the request's Server Components.
3
+ * React's `cache()` gives one slot per request in the React Server
4
+ * Components graph, the scope a draft needs; no `next/headers` here, so the
5
+ * root components can read it without binding anything.
6
+ */
7
+ import { cache } from "react";
8
+
9
+ export const draftSlot = cache((): { pointer: string | null } => ({ pointer: null }));
10
+
11
+ /** The draft pointer bound to this request (`bindDraft`), or `null` when it renders the release. */
12
+ export function activeDraftPointer(): string | null {
13
+ return draftSlot().pointer;
14
+ }
package/src/studio.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where Deco Studio runs: the origins its site editor frames a site from and
3
+ * talks to it from (`postMessage`). One list, so the Studio bridge
4
+ * (`StudioBridge`), `withDeco()`'s dev origins and a site's own
5
+ * `frame-ancestors` never drift apart. This package's own copy: it never
6
+ * imports `@decocms/tanstack`.
7
+ *
8
+ * No module state and no framework code: safe in a browser bundle.
9
+ */
10
+
11
+ /**
12
+ * Studio's origins, in the CSP source form (`https://*.decocms.com` matches
13
+ * any subdomain). Test an origin with {@link isStudioOrigin}: the list holds a
14
+ * wildcard, so `includes()` isn't the check.
15
+ */
16
+ export const STUDIO_ORIGINS: readonly string[] = Object.freeze([
17
+ "https://studio.decocms.com",
18
+ "https://studio-stg.decocms.com",
19
+ "https://decocms.com",
20
+ "https://*.decocms.com",
21
+ ]);
22
+
23
+ /**
24
+ * The `frame-ancestors` sources a site sends so Studio can show it in the
25
+ * site editor: the site itself, {@link STUDIO_ORIGINS}, and v7's
26
+ * `https://*.deco.studio`. For a site that writes its own CSP; add the
27
+ * loopback origins in local development (`http://localhost:*`), where a
28
+ * Studio runs on the same machine.
29
+ */
30
+ export const STUDIO_FRAME_ANCESTORS: readonly string[] = Object.freeze([
31
+ "'self'",
32
+ ...STUDIO_ORIGINS,
33
+ "https://*.deco.studio",
34
+ ]);
35
+
36
+ /**
37
+ * Whether `origin` (a `MessageEvent.origin`, an `Origin` header) is one of
38
+ * {@link STUDIO_ORIGINS}: `https`, the default port, and `decocms.com` or a
39
+ * name under it.
40
+ */
41
+ export function isStudioOrigin(origin: string | null | undefined): boolean {
42
+ if (typeof origin !== "string" || origin === "") return false;
43
+ let url: URL;
44
+ try {
45
+ url = new URL(origin);
46
+ } catch {
47
+ return false;
48
+ }
49
+ // An origin is the scheme and host alone: `https://decocms.com/x` or one with
50
+ // credentials isn't one.
51
+ if (url.origin !== origin || url.protocol !== "https:" || url.port !== "") return false;
52
+ const host = url.hostname;
53
+ return host === "decocms.com" || host.endsWith(".decocms.com");
54
+ }
@@ -1,20 +0,0 @@
1
- "use client";
2
-
3
- import dynamic from "next/dynamic";
4
- import type { ComponentType } from "react";
5
-
6
- interface ClientOnlySectionProps {
7
- loader: () => Promise<{ default: ComponentType<any> }>;
8
- props: Record<string, unknown>;
9
- }
10
-
11
- const cache = new Map<() => Promise<{ default: ComponentType<any> }>, ComponentType<any>>();
12
-
13
- export function ClientOnlySection({ loader, props }: ClientOnlySectionProps) {
14
- let Dynamic = cache.get(loader);
15
- if (!Dynamic) {
16
- Dynamic = dynamic(loader, { ssr: false });
17
- cache.set(loader, Dynamic);
18
- }
19
- return <Dynamic {...props} />;
20
- }
@@ -1,37 +0,0 @@
1
- import { registerSectionsSync } from "@decocms/blocks/cms";
2
- import { renderToString } from "react-dom/server";
3
- import { describe, expect, it } from "vitest";
4
- import { DecoPageRenderer } from "./DecoPageRenderer";
5
-
6
- function Hero({ label }: { label?: string }) {
7
- return <h1>{`hero-${label ?? "none"}`}</h1>;
8
- }
9
-
10
- describe("DecoPageRenderer (next)", () => {
11
- it("renders eager sections in order", async () => {
12
- registerSectionsSync({
13
- "site/sections/NextPageA.tsx": { default: Hero },
14
- "site/sections/NextPageB.tsx": { default: Hero },
15
- });
16
-
17
- const element = await DecoPageRenderer({
18
- sections: [
19
- {
20
- key: "site/sections/NextPageA.tsx",
21
- component: "site/sections/NextPageA.tsx",
22
- props: { label: "first" },
23
- index: 0,
24
- } as any,
25
- {
26
- key: "site/sections/NextPageB.tsx",
27
- component: "site/sections/NextPageB.tsx",
28
- props: { label: "second" },
29
- index: 1,
30
- } as any,
31
- ],
32
- pagePath: "/",
33
- });
34
- const html = renderToString(element);
35
- expect(html.indexOf("hero-first")).toBeLessThan(html.indexOf("hero-second"));
36
- });
37
- });
@@ -1,107 +0,0 @@
1
- import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
- import { resolveDeferredSection } from "@decocms/blocks/cms";
3
- import { cloneElement, type ReactElement, type ReactNode } from "react";
4
- import { DeferredSectionBoundary } from "./DeferredSection";
5
- import { SectionRenderer } from "./SectionRenderer";
6
-
7
- interface DecoPageRendererProps {
8
- sections: ResolvedSection[];
9
- deferredSections?: DeferredSection[];
10
- pagePath: string;
11
- pageUrl?: string;
12
- loadingFallback?: ReactNode;
13
- errorFallback?: ReactNode;
14
- }
15
-
16
- type PageItem =
17
- | { type: "eager"; section: ResolvedSection; sort: number }
18
- | { type: "deferred"; deferred: DeferredSection; sort: number };
19
-
20
- function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
21
- const items: PageItem[] = [];
22
- resolved.forEach((section, i) =>
23
- items.push({ type: "eager", section, sort: section.index ?? i }),
24
- );
25
- for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
26
- items.sort((a, b) => a.sort - b.sort);
27
- return items;
28
- }
29
-
30
- /**
31
- * Top-level renderer for a resolved CMS page. Kicks off (but does not await)
32
- * each deferred section's resolve promise immediately, so they resolve
33
- * concurrently with the eager sections' own rendering — React's streaming
34
- * SSR then flushes each <Suspense> boundary as its promise settles.
35
- */
36
- export async function DecoPageRenderer({
37
- sections,
38
- deferredSections = [],
39
- pagePath,
40
- pageUrl,
41
- loadingFallback,
42
- errorFallback,
43
- }: DecoPageRendererProps) {
44
- const items = mergeSections(sections, deferredSections);
45
-
46
- // `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts)
47
- // takes positional args `(component, rawProps, pagePath, matcherCtx?)` and
48
- // returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped
49
- // call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it
50
- // flows in via `matcherCtx.url`, mirroring how the TanStack route loader
51
- // builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts).
52
- // We also stamp `index` onto the resolved section ourselves (mirroring what
53
- // `resolveDeferredSectionFull` does) so downstream consumers can rely on it.
54
- const matcherCtx = pageUrl ? { url: pageUrl } : undefined;
55
- const deferredPromises = new Map<number, Promise<ResolvedSection | null>>();
56
- for (const d of deferredSections) {
57
- deferredPromises.set(
58
- d.index,
59
- resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
60
- (resolved) => {
61
- if (!resolved) return null;
62
- return { ...resolved, index: d.index };
63
- },
64
- ),
65
- );
66
- }
67
-
68
- // Eager sections are resolved via a direct (awaited) function call rather
69
- // than nested as a `<SectionRenderer />` JSX child. `SectionRenderer` is an
70
- // async function; Next's real RSC renderer awaits async components nested
71
- // anywhere in the tree, but that's specific to the RSC streaming renderer —
72
- // plain `react-dom/server` (used by this package's unit tests, and by any
73
- // consumer that isn't going through Next's RSC pipeline) throws ("A
74
- // component suspended while responding to synchronous input") when an async
75
- // component suspends outside a `<Suspense>` boundary. Deferred sections are
76
- // deliberately exempt: `DeferredSectionBoundary` wraps its async child in a
77
- // `<Suspense>`, so letting it suspend there is exactly the intended
78
- // behavior (renders the fallback synchronously, then streams in later).
79
- //
80
- // `deferredPromises` above is already populated before this `Promise.all`
81
- // runs, so every deferred resolve is in flight concurrently with eager
82
- // section rendering — nothing here blocks on it.
83
- const rendered = await Promise.all(
84
- items.map(async (item): Promise<ReactElement | null> => {
85
- if (item.type === "eager") {
86
- const element = await SectionRenderer({
87
- resolved: item.section,
88
- errorFallback,
89
- });
90
- if (!element) return null;
91
- return cloneElement(element, { key: `${item.section.key}-${item.section.index}` });
92
- }
93
- return (
94
- <DeferredSectionBoundary
95
- key={`deferred-${item.deferred.key}-${item.deferred.index}`}
96
- deferred={item.deferred}
97
- promise={deferredPromises.get(item.deferred.index)!}
98
- pagePath={pagePath}
99
- fallback={loadingFallback}
100
- errorFallback={errorFallback}
101
- />
102
- );
103
- }),
104
- );
105
-
106
- return <>{rendered}</>;
107
- }
@@ -1,22 +0,0 @@
1
- import { renderToString } from "react-dom/server";
2
- import { describe, expect, it } from "vitest";
3
- import { DecoRootLayout } from "./DecoRootLayout";
4
-
5
- describe("DecoRootLayout (next)", () => {
6
- it("renders the html shell with LiveControls and the analytics bootstrap script", () => {
7
- const html = renderToString(
8
- <DecoRootLayout siteName="test-site">
9
- <div>page content</div>
10
- </DecoRootLayout>,
11
- );
12
- expect(html).toContain("page content");
13
- expect(html).toContain("__DECO_STATE");
14
- // "window.DECO" alone is a false-positive trap: it also appears twice in
15
- // this component's own inline bootstrap script (buildDecoEventsBootstrap's
16
- // `window.DECO = window.DECO || {}` and `window.DECO.events = ...`), so it
17
- // would still pass even if the ANALYTICS_SCRIPT <script> tag were deleted
18
- // entirely. "IntersectionObserver" only exists inside ANALYTICS_SCRIPT
19
- // (packages/blocks/src/sdk/analytics.ts) — it genuinely pins that script.
20
- expect(html).toContain("IntersectionObserver");
21
- });
22
- });
@@ -1,82 +0,0 @@
1
- import { LiveControls } from "@decocms/blocks/hooks";
2
- import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
3
- import type { ReactNode } from "react";
4
-
5
- function buildDecoEventsBootstrap(account?: string): string {
6
- const accountJson = JSON.stringify(account ?? "");
7
- return `
8
- window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
9
- window.DECO = window.DECO || {};
10
- window.DECO.events = window.DECO.events || {
11
- _q: [],
12
- _subs: [],
13
- dispatch: function(e) {
14
- this._q.push(e);
15
- for (var i = 0; i < this._subs.length; i++) {
16
- try { this._subs[i](e); } catch(err) { console.error('[DECO.events]', err); }
17
- }
18
- },
19
- subscribe: function(fn) {
20
- this._subs.push(fn);
21
- for (var i = 0; i < this._q.length; i++) {
22
- try { fn(this._q[i]); } catch(err) {}
23
- }
24
- }
25
- };
26
- window.dataLayer = window.dataLayer || [];
27
- `;
28
- }
29
-
30
- export interface DecoRootLayoutProps {
31
- /** Language attribute for the <html> tag. Default: "en" */
32
- lang?: string;
33
- /** DaisyUI data-theme attribute. Default: "light" */
34
- dataTheme?: string;
35
- /** Site name for LiveControls (admin iframe communication). Required. */
36
- siteName: string;
37
- /** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
38
- account?: string;
39
- /** CSS class for <body>. Default: "bg-base-200 text-base-content" */
40
- bodyClassName?: string;
41
- children?: ReactNode;
42
- }
43
-
44
- /**
45
- * Root layout for app/layout.tsx — the Next.js analogue of
46
- * `@decocms/tanstack`'s `DecoRootLayout`.
47
- *
48
- * The bootstrap/analytics <script> tags run once per navigation session
49
- * because Next.js App Router doesn't remount a shared layout.tsx across
50
- * same-layout navigations — the same property TanStack's <ScriptOnce>
51
- * provides explicitly, here implicit in the framework's own layout model.
52
- * That's why a plain inline <script> (rather than a ScriptOnce-like helper)
53
- * is sufficient here.
54
- *
55
- * No <head>/<HeadContent> equivalent: Next.js App Router injects
56
- * `generateMetadata`'s output automatically, and a root layout.tsx doesn't
57
- * render its own <head> element by convention.
58
- *
59
- * No NavigationProgress/StableOutlet port in v1 — those are
60
- * TanStack-Router-specific SPA-navigation UX polish (a loading bar during
61
- * client-side nav, height-preserved outlet), not part of the core
62
- * CMS-rendering feature set this plan scopes.
63
- */
64
- export function DecoRootLayout({
65
- lang = "en",
66
- dataTheme = "light",
67
- siteName,
68
- account,
69
- bodyClassName = "bg-base-200 text-base-content",
70
- children,
71
- }: DecoRootLayoutProps) {
72
- return (
73
- <html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
74
- <body className={bodyClassName} suppressHydrationWarning>
75
- <script dangerouslySetInnerHTML={{ __html: buildDecoEventsBootstrap(account) }} />
76
- {children}
77
- <LiveControls site={siteName} />
78
- <script dangerouslySetInnerHTML={{ __html: ANALYTICS_SCRIPT }} />
79
- </body>
80
- </html>
81
- );
82
- }
@@ -1,27 +0,0 @@
1
- import { registerSectionsSync } from "@decocms/blocks/cms";
2
- import { renderToString } from "react-dom/server";
3
- import { describe, expect, it } from "vitest";
4
- import { DeferredSectionBoundary } from "./DeferredSection";
5
-
6
- function Hero({ label }: { label?: string }) {
7
- return <h1>{`hero-${label ?? "none"}`}</h1>;
8
- }
9
-
10
- describe("DeferredSectionBoundary (next)", () => {
11
- it("renders the fallback while the promise is pending, per Suspense semantics", () => {
12
- const KEY = "site/sections/NextDeferredA.tsx";
13
- registerSectionsSync({ [KEY]: { default: Hero } });
14
-
15
- const neverResolves = new Promise<any>(() => {});
16
- const html = renderToString(
17
- <DeferredSectionBoundary
18
- deferred={{ key: KEY, component: KEY, index: 0 } as any}
19
- promise={neverResolves}
20
- pagePath="/"
21
- fallback={<div>loading</div>}
22
- />,
23
- );
24
- expect(html).toContain("loading");
25
- expect(html).not.toContain("hero-");
26
- });
27
- });
@@ -1,59 +0,0 @@
1
- import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
- import { SectionErrorBoundary } from "@decocms/blocks/hooks";
3
- import { type ReactNode, Suspense } from "react";
4
- import { SectionRenderer } from "./SectionRenderer";
5
-
6
- interface DeferredSectionBoundaryProps {
7
- deferred: DeferredSection;
8
- promise: Promise<ResolvedSection | null>;
9
- pagePath: string;
10
- fallback?: ReactNode;
11
- errorFallback?: ReactNode;
12
- }
13
-
14
- async function ResolvedDeferredSection({
15
- promise,
16
- errorFallback,
17
- }: {
18
- promise: Promise<ResolvedSection | null>;
19
- errorFallback?: ReactNode;
20
- }) {
21
- const resolved = await promise;
22
- if (!resolved) return null;
23
- return <SectionRenderer resolved={resolved} errorFallback={errorFallback} />;
24
- }
25
-
26
- /**
27
- * Deferred/streamed section — the RSC-native analogue of DecoPageRenderer's
28
- * <Suspense><Await promise={promise}>{...}</Await></Suspense>. React 19
29
- * Server Components can await a promise directly inside an async component,
30
- * so there is no need for TanStack's <Await> wrapper — <Suspense> here is
31
- * the same React primitive TanStack also uses, just without the extra
32
- * unwrapping component.
33
- */
34
- export function DeferredSectionBoundary({
35
- deferred,
36
- promise,
37
- pagePath: _pagePath,
38
- fallback,
39
- errorFallback,
40
- }: DeferredSectionBoundaryProps) {
41
- const sectionId = deferred.key
42
- .replace(/\//g, "-")
43
- .replace(/\.tsx$/, "")
44
- .replace(/^site-sections-/, "");
45
-
46
- return (
47
- <SectionErrorBoundary sectionKey={deferred.key} fallback={errorFallback}>
48
- <Suspense
49
- fallback={
50
- <section id={sectionId} data-manifest-key={deferred.key} data-deferred="true">
51
- {fallback}
52
- </section>
53
- }
54
- >
55
- <ResolvedDeferredSection promise={promise} errorFallback={errorFallback} />
56
- </Suspense>
57
- </SectionErrorBoundary>
58
- );
59
- }