@decocms/nextjs 7.16.6 → 7.16.7

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # `@decocms/nextjs`
2
2
 
3
3
  Deco framework binding for Next.js App Router — the Next.js sibling of
4
- `@decocms/tanstack`. Three surfaces, composed together:
4
+ `@decocms/tanstack`. Four surfaces, composed together:
5
5
 
6
6
  - **`@decocms/nextjs/config`** — `withDeco(nextConfig)`, a `next.config`
7
7
  wrapper that adds the rewrites and `transpilePackages` Deco's admin
@@ -9,12 +9,15 @@ Deco framework binding for Next.js App Router — the Next.js sibling of
9
9
  - **`@decocms/nextjs/routeHandlers`** — `createDecoRouteHandlers({ setup })`,
10
10
  a single catch-all dispatcher for the whole Studio admin protocol
11
11
  (decofile read/reload, meta schema, invoke, live previews).
12
+ - **`@decocms/nextjs`** — `createDecoPreviewPage({ setup })`, an App Router
13
+ Server Component that renders live previews through Next's RSC pipeline,
14
+ including sections marked with `"use client"`.
12
15
  - **`@decocms/nextjs/setup`** — `createNextSetup(options)`, a one-call site
13
16
  bootstrap: the Next.js analogue of Vite's
14
17
  `createSiteSetup` + `createAdminSetup` + `import.meta.glob`.
15
18
 
16
19
  This document is the complete recipe a new Next.js site follows to wire all
17
- three together. Every code block below is meant to be copied, not
20
+ all four together. Every code block below is meant to be copied, not
18
21
  paraphrased.
19
22
 
20
23
  ## Install
@@ -78,13 +81,50 @@ import { ensureSetup } from "../../../deco/setup";
78
81
 
79
82
  export const dynamic = "force-dynamic";
80
83
 
81
- export const { GET, POST, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup });
84
+ export const { GET, POST, OPTIONS } = createDecoRouteHandlers({
85
+ setup: ensureSetup,
86
+ });
82
87
  ```
83
88
 
84
89
  `dynamic = "force-dynamic"` is required — this route must never be
85
90
  statically cached (decofile reloads, invoke calls, and previews all need a
86
91
  fresh response per request).
87
92
 
93
+ ### The RSC preview page
94
+
95
+ Mount `createDecoPreviewPage` at the framework-owned `/deco/preview` path.
96
+ This path is intentionally not configurable: preview GET requests are always
97
+ redirected from the catch-all route to this App Router page, with the query
98
+ string preserved. Other admin requests, including `POST /deco/render`, remain
99
+ on the catch-all handler.
100
+
101
+ ```tsx
102
+ // src/app/deco/preview/[[...path]]/page.tsx
103
+ import { createDecoPreviewPage } from "@decocms/nextjs";
104
+ import { ensureSetup } from "../../../../deco/setup";
105
+
106
+ export const dynamic = "force-dynamic";
107
+
108
+ export default createDecoPreviewPage({ setup: ensureSetup });
109
+ ```
110
+
111
+ The page boundary is essential for Client Components. A route handler can
112
+ produce plain HTML with `react-dom/server`, but that renderer cannot execute
113
+ the client-reference proxies Next creates for modules marked
114
+ `"use client"`. An App Router page is rendered by Next's React Server
115
+ Components pipeline, so it can render a server section tree containing
116
+ Client Components and preserve their hydration metadata.
117
+
118
+ Do not work around this error by removing `"use client"` from interactive
119
+ components. Keep sections and wrappers as Server Components when they only
120
+ render markup, but retain a Client Component boundary wherever hooks, event
121
+ handlers, browser APIs, or client-only context are required. The RSC preview
122
+ page exists so those two kinds of component can compose exactly as they do in
123
+ the storefront.
124
+
125
+ Importing the root barrel is correct in this `page.tsx`; the subpath-only
126
+ rule below applies specifically to `route.ts` files.
127
+
88
128
  ### Route-handler import rule: subpaths, never the root barrel
89
129
 
90
130
  **Always import from `@decocms/nextjs/routeHandlers` (or `/config`,
@@ -189,7 +229,7 @@ successful bootstrap is cached for the life of the warm serverless
189
229
  instance; a *rejected* bootstrap clears the memo so the next call retries
190
230
  from scratch (the triggering call still rejects with the original error).
191
231
 
192
- Two call sites need `ensureSetup()`:
232
+ Three call sites need `ensureSetup()`:
193
233
 
194
234
  1. **The catch-all route** (above) passes it as `{ setup: ensureSetup }` —
195
235
  `createDecoRouteHandlers` awaits it before dispatching every admin
@@ -211,6 +251,9 @@ Two call sites need `ensureSetup()`:
211
251
 
212
252
  (`app/layout.tsx` is a Server Component, not a route handler, so it's
213
253
  fine to import the root barrel here — see the rule above.)
254
+ 3. **The RSC preview page** passes it to `createDecoPreviewPage`, as shown
255
+ above. The setup function is memoized, so these call sites share one
256
+ successful bootstrap per warm process.
214
257
 
215
258
  ### `NextSetupOptions` at a glance
216
259
 
@@ -317,7 +360,8 @@ implementation file) can opt into these, each read as a literal
317
360
  ## Verifying your setup
318
361
 
319
362
  `examples/nextjs-smoke` in this monorepo is a minimal, real Next.js App
320
- Router build exercising all three surfaces end to end — `withDeco` rewrites,
321
- the catch-all route, `createNextSetup`, and a resolved page render. Use it
322
- as a working reference if any of the above doesn't compose the way you
323
- expect.
363
+ Router build exercising all four surfaces end to end — `withDeco` rewrites,
364
+ the catch-all route, the RSC preview page, `createNextSetup`, and a resolved
365
+ page render. Its preview fixture is a real interactive Client Component, so
366
+ the build also guards the server/client boundary described above. Use it as
367
+ a working reference if any of the above doesn't compose the way you expect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/nextjs",
3
- "version": "7.16.6",
3
+ "version": "7.16.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -28,8 +28,8 @@
28
28
  "lint:unused": "knip"
29
29
  },
30
30
  "dependencies": {
31
- "@decocms/blocks": "7.16.6",
32
- "@decocms/blocks-admin": "7.16.6"
31
+ "@decocms/blocks": "7.16.7",
32
+ "@decocms/blocks-admin": "7.16.7"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "next": ">=15.0.0",
@@ -0,0 +1,71 @@
1
+ // @vitest-environment node
2
+
3
+ import { registerSection, setBlocks } from "@decocms/blocks/cms";
4
+ import { setRenderShell } from "@decocms/blocks-admin";
5
+ import { renderToString } from "react-dom/server";
6
+ import { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createDecoPreviewPage } from "./createDecoPreviewPage";
8
+ import * as nextjs from "./index";
9
+
10
+ vi.mock("next/headers", () => ({
11
+ headers: vi.fn(
12
+ async () =>
13
+ new Headers({
14
+ host: "store.test",
15
+ "x-forwarded-proto": "https",
16
+ "user-agent": "preview-test",
17
+ }),
18
+ ),
19
+ }));
20
+
21
+ const HERO = "site/sections/PreviewHero.tsx";
22
+
23
+ beforeEach(() => {
24
+ setBlocks({
25
+ "Preview Hero": { __resolveType: HERO, label: "rsc" },
26
+ });
27
+ registerSection(HERO, async () => ({
28
+ default: ({ label }: { label?: string }) => <h1>{`hero-${label}`}</h1>,
29
+ }));
30
+ setRenderShell({
31
+ css: "/preview.css",
32
+ fonts: ["/font.css"],
33
+ theme: "light",
34
+ bodyClass: "preview-body",
35
+ });
36
+ });
37
+
38
+ describe("createDecoPreviewPage", () => {
39
+ it("is exposed by the Next.js binding", () => {
40
+ expect((nextjs as Record<string, unknown>).createDecoPreviewPage).toBeTypeOf("function");
41
+ });
42
+
43
+ it("resolves and renders a section through the RSC-aware SectionRenderer", async () => {
44
+ const setup = vi.fn(async () => {});
45
+ const Page = createDecoPreviewPage({ setup });
46
+ const element = await Page({
47
+ params: Promise.resolve({ path: ["Preview Hero"] }),
48
+ searchParams: Promise.resolve({ deviceHint: "desktop" }),
49
+ });
50
+ const html = renderToString(element);
51
+
52
+ expect(setup).toHaveBeenCalledOnce();
53
+ expect(html).toContain("hero-rsc");
54
+ expect(html).toContain('data-manifest-key="site/sections/PreviewHero.tsx"');
55
+ expect(html).toContain('href="/preview.css"');
56
+ expect(html).toContain('href="/font.css"');
57
+ expect(html).toContain('data-theme="light"');
58
+ expect(html).toContain("preview-body");
59
+ expect(html).toContain("editor::inject");
60
+ });
61
+
62
+ it("renders a visible diagnostic for an unknown section", async () => {
63
+ const Page = createDecoPreviewPage();
64
+ const element = await Page({
65
+ params: Promise.resolve({ path: ["Missing"] }),
66
+ searchParams: Promise.resolve({}),
67
+ });
68
+
69
+ expect(renderToString(element)).toContain("Unknown section: Missing");
70
+ });
71
+ });
@@ -0,0 +1,108 @@
1
+ import { LIVE_CONTROLS_SCRIPT, resolvePreviewRequest } from "@decocms/blocks-admin";
2
+ import { getPreviewWrapper, getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
3
+ import { headers } from "next/headers";
4
+ import { cloneElement, createElement, type ReactElement, type ReactNode } from "react";
5
+ import { SectionRenderer } from "./SectionRenderer";
6
+
7
+ export interface CreateDecoPreviewPageOptions {
8
+ setup?: () => Promise<void>;
9
+ }
10
+
11
+ export interface DecoPreviewPageProps {
12
+ params: Promise<{ path?: string[] }>;
13
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
14
+ }
15
+
16
+ function appendSearchParams(
17
+ target: URLSearchParams,
18
+ source: Record<string, string | string[] | undefined>,
19
+ ) {
20
+ for (const [key, value] of Object.entries(source)) {
21
+ if (Array.isArray(value)) {
22
+ for (const item of value) target.append(key, item);
23
+ } else if (value !== undefined) {
24
+ target.set(key, value);
25
+ }
26
+ }
27
+ }
28
+
29
+ async function buildPreviewRequest(props: DecoPreviewPageProps): Promise<Request> {
30
+ const [params, searchParams, requestHeaders] = await Promise.all([
31
+ props.params,
32
+ props.searchParams,
33
+ headers(),
34
+ ]);
35
+ const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
36
+ const protocol = requestHeaders.get("x-forwarded-proto") ?? "http";
37
+ const encodedPath = (params.path ?? []).map(encodeURIComponent).join("/");
38
+ const url = new URL(`/live/previews/${encodedPath}`, `${protocol}://${host}`);
39
+ appendSearchParams(url.searchParams, searchParams);
40
+ return new Request(url, { headers: new Headers(requestHeaders) });
41
+ }
42
+
43
+ function PreviewResources() {
44
+ const { cssHref, fontHrefs } = getRenderShellConfig();
45
+ return (
46
+ <>
47
+ {fontHrefs.map((href) => (
48
+ <link key={href} rel="stylesheet" href={href} />
49
+ ))}
50
+ {cssHref ? <link rel="stylesheet" href={cssHref} /> : null}
51
+ </>
52
+ );
53
+ }
54
+
55
+ function PreviewFrame({ children }: { children: ReactNode }) {
56
+ const { themeName, bodyClass } = getRenderShellConfig();
57
+ const Wrapper = getPreviewWrapper();
58
+ const content = Wrapper ? createElement(Wrapper, null, children) : children;
59
+
60
+ return (
61
+ <>
62
+ <PreviewResources />
63
+ <div data-theme={themeName || "light"} className={bodyClass || undefined}>
64
+ {content}
65
+ </div>
66
+ <script dangerouslySetInnerHTML={{ __html: LIVE_CONTROLS_SCRIPT }} />
67
+ </>
68
+ );
69
+ }
70
+
71
+ function PreviewDiagnostic({ children }: { children: ReactNode }) {
72
+ return <div style={{ padding: 20, color: "red" }}>{children}</div>;
73
+ }
74
+
75
+ export function createDecoPreviewPage(options: CreateDecoPreviewPageOptions = {}) {
76
+ return async function DecoPreviewPage(props: DecoPreviewPageProps) {
77
+ await options.setup?.();
78
+
79
+ try {
80
+ const request = await buildPreviewRequest(props);
81
+ const resolution = await resolvePreviewRequest(request);
82
+ if (resolution.type === "unknown") {
83
+ return (
84
+ <PreviewFrame>
85
+ <PreviewDiagnostic>{`Unknown section: ${resolution.component}`}</PreviewDiagnostic>
86
+ </PreviewFrame>
87
+ );
88
+ }
89
+
90
+ const rendered = await Promise.all(
91
+ resolution.sections.map(async (section, index): Promise<ReactElement | null> => {
92
+ const element = await SectionRenderer({ resolved: section });
93
+ return element
94
+ ? cloneElement(element, { key: `${section.key}-${section.index ?? index}` })
95
+ : null;
96
+ }),
97
+ );
98
+
99
+ return <PreviewFrame>{rendered}</PreviewFrame>;
100
+ } catch (error) {
101
+ return (
102
+ <PreviewFrame>
103
+ <PreviewDiagnostic>{`Render error: ${(error as Error).message}`}</PreviewDiagnostic>
104
+ </PreviewFrame>
105
+ );
106
+ }
107
+ };
108
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,12 @@
1
+ export { ClientOnlySection } from "./ClientOnlySection";
1
2
  export { createDecoPage } from "./createDecoPage";
2
- export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
3
+ export {
4
+ type CreateDecoPreviewPageOptions,
5
+ createDecoPreviewPage,
6
+ type DecoPreviewPageProps,
7
+ } from "./createDecoPreviewPage";
3
8
  export { DecoPageRenderer } from "./DecoPageRenderer";
4
- export { SectionRenderer } from "./SectionRenderer";
5
- export { ClientOnlySection } from "./ClientOnlySection";
9
+ export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
6
10
  export { DeferredSectionBoundary } from "./DeferredSection";
7
11
  export {
8
12
  decofileGET,
@@ -12,3 +16,4 @@ export {
12
16
  renderGET,
13
17
  renderPOST,
14
18
  } from "./routeHandlers";
19
+ export { SectionRenderer } from "./SectionRenderer";
@@ -67,12 +67,44 @@ describe("createDecoRouteHandlers", () => {
67
67
  expect(mocks.handleInvoke).toHaveBeenCalled();
68
68
  });
69
69
 
70
- it("rebuilds /deco/previews/* URLs to the /live/previews/* prefix handleRender parses", async () => {
70
+ it("redirects preview GETs to the fixed RSC page and preserves the path and query", async () => {
71
71
  const { GET } = createDecoRouteHandlers();
72
- const res = await GET(new Request("http://x/deco/previews/pages-Home-123?props=x"));
73
- expect(await res.text()).toBe("/live/previews/pages-Home-123");
74
- const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url);
75
- expect(calledUrl.searchParams.get("props")).toBe("x");
72
+ const res = await GET(
73
+ new Request("http://x/deco/previews/pages-Home-123?props=x&deviceHint=mobile"),
74
+ );
75
+
76
+ expect(res.status).toBe(307);
77
+ expect(res.headers.get("location")).toBe(
78
+ "http://x/deco/preview/pages-Home-123?props=x&deviceHint=mobile",
79
+ );
80
+ expect(mocks.handleRender).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it("redirects the public /live/previews path to the fixed RSC page", async () => {
84
+ const { GET } = createDecoRouteHandlers();
85
+ const res = await GET(
86
+ new Request("http://x/live/previews/site/sections/Hero.tsx?deviceHint=desktop"),
87
+ );
88
+
89
+ expect(res.status).toBe(307);
90
+ expect(res.headers.get("location")).toBe(
91
+ "http://x/deco/preview/site/sections/Hero.tsx?deviceHint=desktop",
92
+ );
93
+ expect(mocks.handleRender).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("keeps preview POSTs on handleRender", async () => {
97
+ const { POST } = createDecoRouteHandlers();
98
+ const res = await POST(
99
+ new Request("http://x/deco/previews/pages-Home-123", {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ hello: "world" }),
103
+ }),
104
+ );
105
+
106
+ expect(res.status).toBe(200);
107
+ expect(mocks.handleRender).toHaveBeenCalledOnce();
76
108
  });
77
109
 
78
110
  it("forwards a POST body through the /deco/previews/* URL rebuild (Request-as-init carries the body stream)", async () => {
@@ -135,12 +167,12 @@ describe("createDecoRouteHandlers", () => {
135
167
  expect(mocks.handleMeta).not.toHaveBeenCalled();
136
168
  });
137
169
 
138
- it("routes the rewrite-source /live/previews/* path straight through to handleRender with the prefix intact", async () => {
170
+ it("redirects the rewrite-source /live/previews/* path to the fixed RSC page", async () => {
139
171
  const { GET } = createDecoRouteHandlers();
140
172
  const res = await GET(new Request("http://x/live/previews/pages-Home-123?props=x"));
141
- expect(await res.text()).toBe("/live/previews/pages-Home-123");
142
- const calledUrl = new URL(mocks.handleRender.mock.calls[0][0].url);
143
- expect(calledUrl.searchParams.get("props")).toBe("x");
173
+ expect(res.status).toBe(307);
174
+ expect(res.headers.get("location")).toBe("http://x/deco/preview/pages-Home-123?props=x");
175
+ expect(mocks.handleRender).not.toHaveBeenCalled();
144
176
  });
145
177
 
146
178
  it("405s GET /deco/invoke/* with Allow: POST (CSRF protection — see comment on the invoke branch)", async () => {
@@ -93,6 +93,8 @@ export interface DecoRouteHandlersOptions {
93
93
  setup?: () => Promise<void>;
94
94
  }
95
95
 
96
+ const PREVIEW_PAGE_PATH = "/deco/preview";
97
+
96
98
  /**
97
99
  * Single catch-all dispatcher for the whole Studio admin protocol. Mount
98
100
  * at `app/deco/[[...deco]]/route.ts` and wrap next.config with
@@ -104,7 +106,9 @@ export interface DecoRouteHandlersOptions {
104
106
  * import { createDecoRouteHandlers } from "@decocms/nextjs/routeHandlers";
105
107
  * import { ensureSetup } from "../../../deco/setup";
106
108
  * export const dynamic = "force-dynamic";
107
- * export const { GET, POST, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup });
109
+ * export const { GET, POST, OPTIONS } = createDecoRouteHandlers({
110
+ * setup: ensureSetup,
111
+ * });
108
112
  * ```
109
113
  *
110
114
  * `resolveAction` accepts BOTH the rewrite's public source path (e.g.
@@ -184,6 +188,13 @@ export function createDecoRouteHandlers(options: DecoRouteHandlersOptions = {}):
184
188
  return handleInvoke(request);
185
189
  }
186
190
  if (action === "previews" || action.startsWith("previews/")) {
191
+ if (request.method === "GET") {
192
+ const rest = action === "previews" ? "" : action.slice("previews/".length);
193
+ const target = new URL(url);
194
+ target.pathname = rest ? `${PREVIEW_PAGE_PATH}/${rest}` : PREVIEW_PAGE_PATH;
195
+ return Response.redirect(target, 307);
196
+ }
197
+
187
198
  // handleRender parses the literal "/live/previews/" prefix.
188
199
  // `resolveAction` above already normalizes BOTH a direct
189
200
  // `/deco/previews/*` hit and a rewritten `/live/previews/*` hit down