@decocms/nextjs 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 +36 -0
- package/src/ClientOnlySection.tsx +20 -0
- package/src/DecoPageRenderer.test.tsx +27 -0
- package/src/DecoPageRenderer.tsx +103 -0
- package/src/DecoRootLayout.test.tsx +22 -0
- package/src/DecoRootLayout.tsx +82 -0
- package/src/DeferredSection.test.tsx +27 -0
- package/src/DeferredSection.tsx +59 -0
- package/src/SectionRenderer.test.tsx +97 -0
- package/src/SectionRenderer.tsx +62 -0
- package/src/createDecoPage.test.tsx +65 -0
- package/src/createDecoPage.tsx +107 -0
- package/src/index.ts +14 -0
- package/src/routeHandlers.test.ts +11 -0
- package/src/routeHandlers.ts +37 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/nextjs",
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco framework binding for Next.js App Router",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"test": "vitest run --root ../.. packages/nextjs/",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint:unused": "knip"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@decocms/blocks": "7.0.0",
|
|
18
|
+
"@decocms/blocks-admin": "7.0.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"next": ">=15.0.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/react": "^19.0.0",
|
|
27
|
+
"@types/react-dom": "^19.0.0",
|
|
28
|
+
"knip": "^5.86.0",
|
|
29
|
+
"next": "^15.4.0",
|
|
30
|
+
"typescript": "^5.9.0"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"registry": "https://registry.npmjs.org",
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
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
|
+
{ key: "site/sections/NextPageA.tsx", component: "site/sections/NextPageA.tsx", props: { label: "first" }, index: 0 } as any,
|
|
20
|
+
{ key: "site/sections/NextPageB.tsx", component: "site/sections/NextPageB.tsx", props: { label: "second" }, index: 1 } as any,
|
|
21
|
+
],
|
|
22
|
+
pagePath: "/",
|
|
23
|
+
});
|
|
24
|
+
const html = renderToString(element);
|
|
25
|
+
expect(html.indexOf("hero-first")).toBeLessThan(html.indexOf("hero-second"));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
+
import { resolveDeferredSection } from "@decocms/blocks/cms";
|
|
3
|
+
import { SectionRenderer } from "./SectionRenderer";
|
|
4
|
+
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
|
+
import { cloneElement, type ReactElement, type ReactNode } from "react";
|
|
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) => items.push({ type: "eager", section, sort: section.index ?? i }));
|
|
23
|
+
for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
|
|
24
|
+
items.sort((a, b) => a.sort - b.sort);
|
|
25
|
+
return items;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Top-level renderer for a resolved CMS page. Kicks off (but does not await)
|
|
30
|
+
* each deferred section's resolve promise immediately, so they resolve
|
|
31
|
+
* concurrently with the eager sections' own rendering — React's streaming
|
|
32
|
+
* SSR then flushes each <Suspense> boundary as its promise settles.
|
|
33
|
+
*/
|
|
34
|
+
export async function DecoPageRenderer({
|
|
35
|
+
sections,
|
|
36
|
+
deferredSections = [],
|
|
37
|
+
pagePath,
|
|
38
|
+
pageUrl,
|
|
39
|
+
loadingFallback,
|
|
40
|
+
errorFallback,
|
|
41
|
+
}: DecoPageRendererProps) {
|
|
42
|
+
const items = mergeSections(sections, deferredSections);
|
|
43
|
+
|
|
44
|
+
// `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts)
|
|
45
|
+
// takes positional args `(component, rawProps, pagePath, matcherCtx?)` and
|
|
46
|
+
// returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped
|
|
47
|
+
// call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it
|
|
48
|
+
// flows in via `matcherCtx.url`, mirroring how the TanStack route loader
|
|
49
|
+
// builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts).
|
|
50
|
+
// We also stamp `index` onto the resolved section ourselves (mirroring what
|
|
51
|
+
// `resolveDeferredSectionFull` does) so downstream consumers can rely on it.
|
|
52
|
+
const matcherCtx = pageUrl ? { url: pageUrl } : undefined;
|
|
53
|
+
const deferredPromises = new Map<number, Promise<ResolvedSection | null>>();
|
|
54
|
+
for (const d of deferredSections) {
|
|
55
|
+
deferredPromises.set(
|
|
56
|
+
d.index,
|
|
57
|
+
resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => {
|
|
58
|
+
if (!resolved) return null;
|
|
59
|
+
return { ...resolved, index: d.index };
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Eager sections are resolved via a direct (awaited) function call rather
|
|
65
|
+
// than nested as a `<SectionRenderer />` JSX child. `SectionRenderer` is an
|
|
66
|
+
// async function; Next's real RSC renderer awaits async components nested
|
|
67
|
+
// anywhere in the tree, but that's specific to the RSC streaming renderer —
|
|
68
|
+
// plain `react-dom/server` (used by this package's unit tests, and by any
|
|
69
|
+
// consumer that isn't going through Next's RSC pipeline) throws ("A
|
|
70
|
+
// component suspended while responding to synchronous input") when an async
|
|
71
|
+
// component suspends outside a `<Suspense>` boundary. Deferred sections are
|
|
72
|
+
// deliberately exempt: `DeferredSectionBoundary` wraps its async child in a
|
|
73
|
+
// `<Suspense>`, so letting it suspend there is exactly the intended
|
|
74
|
+
// behavior (renders the fallback synchronously, then streams in later).
|
|
75
|
+
//
|
|
76
|
+
// `deferredPromises` above is already populated before this `Promise.all`
|
|
77
|
+
// runs, so every deferred resolve is in flight concurrently with eager
|
|
78
|
+
// section rendering — nothing here blocks on it.
|
|
79
|
+
const rendered = await Promise.all(
|
|
80
|
+
items.map(async (item): Promise<ReactElement | null> => {
|
|
81
|
+
if (item.type === "eager") {
|
|
82
|
+
const element = await SectionRenderer({
|
|
83
|
+
resolved: item.section,
|
|
84
|
+
errorFallback,
|
|
85
|
+
});
|
|
86
|
+
if (!element) return null;
|
|
87
|
+
return cloneElement(element, { key: `${item.section.key}-${item.section.index}` });
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<DeferredSectionBoundary
|
|
91
|
+
key={`deferred-${item.deferred.key}-${item.deferred.index}`}
|
|
92
|
+
deferred={item.deferred}
|
|
93
|
+
promise={deferredPromises.get(item.deferred.index)!}
|
|
94
|
+
pagePath={pagePath}
|
|
95
|
+
fallback={loadingFallback}
|
|
96
|
+
errorFallback={errorFallback}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return <>{rendered}</>;
|
|
103
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { LiveControls } from "@decocms/blocks/hooks";
|
|
3
|
+
import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from "react";
|
|
2
|
+
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
3
|
+
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
|
|
4
|
+
import { SectionRenderer } from "./SectionRenderer";
|
|
5
|
+
|
|
6
|
+
function Hero({ label }: { label?: string }) {
|
|
7
|
+
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The test environment runs under jsdom (see root vitest.config.ts), where
|
|
11
|
+
// `window` is defined. next/dynamic's internals branch on
|
|
12
|
+
// `typeof window === "undefined"` to decide server vs. client behavior, so
|
|
13
|
+
// under jsdom it always takes the client path — `{ ssr: false }` vs.
|
|
14
|
+
// `{ ssr: true }` produce IDENTICAL rendered output here (both effectively
|
|
15
|
+
// render `null` during a synchronous renderToString call, since the loader's
|
|
16
|
+
// promise can't resolve in time regardless of the ssr option). Asserting on
|
|
17
|
+
// rendered HTML would therefore pass even if ClientOnlySection passed
|
|
18
|
+
// `{ ssr: true }` by mistake — it exercises no real server/window-undefined
|
|
19
|
+
// code path in this environment.
|
|
20
|
+
//
|
|
21
|
+
// Instead we verify the actual contract ClientOnlySection is responsible
|
|
22
|
+
// for: that it calls next/dynamic with `{ ssr: false }`. We spy on the
|
|
23
|
+
// `next/dynamic` module import via `vi.mock` + `vi.hoisted`.
|
|
24
|
+
const { dynamicSpy } = vi.hoisted(() => ({ dynamicSpy: vi.fn() }));
|
|
25
|
+
|
|
26
|
+
vi.mock("next/dynamic", () => ({
|
|
27
|
+
default: (loader: unknown, options: unknown) => {
|
|
28
|
+
dynamicSpy(loader, options);
|
|
29
|
+
return function DynamicStub() {
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dynamicSpy.mockClear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("SectionRenderer (next)", () => {
|
|
40
|
+
it("renders a sync-registered section directly", async () => {
|
|
41
|
+
const KEY = "site/sections/NextSyncA.tsx";
|
|
42
|
+
registerSectionsSync({ [KEY]: { default: Hero } });
|
|
43
|
+
|
|
44
|
+
const element = await SectionRenderer({
|
|
45
|
+
resolved: { key: KEY, component: KEY, props: { label: "a" } } as any,
|
|
46
|
+
});
|
|
47
|
+
const html = renderToString(element);
|
|
48
|
+
expect(html).toContain("hero-a");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("awaits a code-split (non-sync) section's loader", async () => {
|
|
52
|
+
const KEY = "site/sections/NextLazyA.tsx";
|
|
53
|
+
registerSections({ [KEY]: () => Promise.resolve({ default: Hero }) });
|
|
54
|
+
|
|
55
|
+
const element = await SectionRenderer({
|
|
56
|
+
resolved: { key: KEY, component: KEY, props: { label: "b" } } as any,
|
|
57
|
+
});
|
|
58
|
+
const html = renderToString(element);
|
|
59
|
+
expect(html).toContain("hero-b");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("routes a clientOnly-registered section through next/dynamic with ssr:false", async () => {
|
|
63
|
+
const KEY = "site/sections/NextClientOnlyA.tsx";
|
|
64
|
+
const loader = () => Promise.resolve({ default: Hero });
|
|
65
|
+
registerSection(KEY, loader, { clientOnly: true });
|
|
66
|
+
|
|
67
|
+
const element = await SectionRenderer({
|
|
68
|
+
resolved: { key: KEY, component: KEY, props: { label: "c" } } as any,
|
|
69
|
+
});
|
|
70
|
+
const html = renderToString(element);
|
|
71
|
+
|
|
72
|
+
// The actual contract: ClientOnlySection must call next/dynamic with
|
|
73
|
+
// { ssr: false } for this section's loader. This fails if the
|
|
74
|
+
// implementation is changed to pass { ssr: true } (or omits the option).
|
|
75
|
+
expect(dynamicSpy).toHaveBeenCalledWith(loader, { ssr: false });
|
|
76
|
+
|
|
77
|
+
// The section shell (wrapping <section> + manifest key) still renders
|
|
78
|
+
// server-side regardless of the dynamic child's ssr option.
|
|
79
|
+
expect(html).toContain(`data-manifest-key="${KEY}"`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("warns and renders nothing for an unregistered section", async () => {
|
|
83
|
+
const KEY = "site/sections/NextMissingA.tsx";
|
|
84
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
85
|
+
|
|
86
|
+
const element = await SectionRenderer({
|
|
87
|
+
resolved: { key: KEY, component: KEY, props: {} } as any,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(element).toBeNull();
|
|
91
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
92
|
+
expect.stringContaining(`No component registered for: ${KEY}`),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
warnSpy.mockRestore();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getSection,
|
|
4
|
+
getSectionOptions,
|
|
5
|
+
getSyncComponent,
|
|
6
|
+
} from "@decocms/blocks/cms";
|
|
7
|
+
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
8
|
+
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
9
|
+
import { ClientOnlySection } from "./ClientOnlySection";
|
|
10
|
+
|
|
11
|
+
interface SectionRendererProps {
|
|
12
|
+
resolved: ResolvedSection;
|
|
13
|
+
errorFallback?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders one resolved section. Runs inside an async Server Component tree,
|
|
18
|
+
* so code-split sections are loaded via a plain await — no client-side
|
|
19
|
+
* React.lazy/Suspense needed for content in the initial response.
|
|
20
|
+
*/
|
|
21
|
+
export async function SectionRenderer({ resolved, errorFallback }: SectionRendererProps) {
|
|
22
|
+
const sectionId = resolved.key
|
|
23
|
+
.replace(/\//g, "-")
|
|
24
|
+
.replace(/\.tsx$/, "")
|
|
25
|
+
.replace(/^site-sections-/, "");
|
|
26
|
+
|
|
27
|
+
const options = getSectionOptions(resolved.component);
|
|
28
|
+
const errFallback = options?.errorFallback
|
|
29
|
+
? createElement(options.errorFallback, { error: new Error("") })
|
|
30
|
+
: errorFallback;
|
|
31
|
+
|
|
32
|
+
const isClientOnly = options?.clientOnly === true;
|
|
33
|
+
const SyncComp = getSyncComponent(resolved.component);
|
|
34
|
+
let content: ReactNode;
|
|
35
|
+
|
|
36
|
+
if (isClientOnly) {
|
|
37
|
+
const loader = getSection(resolved.component);
|
|
38
|
+
if (!loader) {
|
|
39
|
+
console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
content = <ClientOnlySection loader={loader} props={resolved.props} />;
|
|
43
|
+
} else if (SyncComp) {
|
|
44
|
+
content = createElement(SyncComp, resolved.props);
|
|
45
|
+
} else {
|
|
46
|
+
const loader = getSection(resolved.component);
|
|
47
|
+
if (!loader) {
|
|
48
|
+
console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const mod = await loader();
|
|
52
|
+
content = createElement(mod.default, resolved.props);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<section id={sectionId} data-manifest-key={resolved.key}>
|
|
57
|
+
<SectionErrorBoundary sectionKey={resolved.key} fallback={errFallback}>
|
|
58
|
+
{content}
|
|
59
|
+
</SectionErrorBoundary>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { registerSections, setBlocks } from "@decocms/blocks/cms";
|
|
4
|
+
import { createDecoPage } from "./createDecoPage";
|
|
5
|
+
|
|
6
|
+
function Hero({ label }: { label?: string }) {
|
|
7
|
+
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("createDecoPage (next)", () => {
|
|
11
|
+
it("resolves and renders the page for the current path", async () => {
|
|
12
|
+
// registerSections (the async-loader registry, packages/blocks/src/cms/registry.ts)
|
|
13
|
+
// — NOT registerSectionsSync — is what resolveDecoPage's pipeline reads:
|
|
14
|
+
// resolveRawSection() gates each raw CMS section through getSection(),
|
|
15
|
+
// which only consults the registry registerSections populates.
|
|
16
|
+
// registerSectionsSync populates a separate `syncComponents` map that
|
|
17
|
+
// SectionRenderer checks first for already-rendered pages, but a section
|
|
18
|
+
// registered ONLY via registerSectionsSync is silently dropped (with a
|
|
19
|
+
// "[CMS] No component registered for: ..." warning) during CMS
|
|
20
|
+
// resolution itself — confirmed by running this test against the plan's
|
|
21
|
+
// original registerSectionsSync-based sketch.
|
|
22
|
+
registerSections({ "site/sections/CreateDecoPageHero.tsx": async () => ({ default: Hero }) });
|
|
23
|
+
// Block key MUST start with "pages-" — that's the prefix getAllPages()
|
|
24
|
+
// (packages/blocks/src/cms/loader.ts) filters on. The plan's sketch used
|
|
25
|
+
// "pages/home.json", which getAllPages() would silently skip, so
|
|
26
|
+
// resolveDecoPage would return null and this test would fail even with a
|
|
27
|
+
// correct implementation.
|
|
28
|
+
setBlocks({
|
|
29
|
+
"pages-home": {
|
|
30
|
+
path: "/",
|
|
31
|
+
sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
|
|
32
|
+
seo: {
|
|
33
|
+
__resolveType: "website/sections/Seo/SeoV2.tsx",
|
|
34
|
+
title: "Home title",
|
|
35
|
+
description: "Home description",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { generateMetadata, default: Page } = createDecoPage({ siteName: "test-site" });
|
|
41
|
+
|
|
42
|
+
const element = await Page({ params: Promise.resolve({ slug: [] }) });
|
|
43
|
+
const html = renderToString(element);
|
|
44
|
+
expect(html).toContain("hero-home");
|
|
45
|
+
|
|
46
|
+
const metadata = await generateMetadata({ params: Promise.resolve({ slug: [] }) });
|
|
47
|
+
expect(metadata.title).toBe("Home title");
|
|
48
|
+
expect(metadata.description).toBe("Home description");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("calls next/navigation notFound() when no page matches the path", async () => {
|
|
52
|
+
setBlocks({
|
|
53
|
+
"pages-home": {
|
|
54
|
+
path: "/",
|
|
55
|
+
sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { default: Page } = createDecoPage({ siteName: "test-site" });
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
Page({ params: Promise.resolve({ slug: ["missing"] }) }),
|
|
63
|
+
).rejects.toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import type { Metadata } from "next";
|
|
4
|
+
import {
|
|
5
|
+
extractSeoFromProps,
|
|
6
|
+
extractSeoFromSections,
|
|
7
|
+
resolveDecoPage,
|
|
8
|
+
type DecoPageResult,
|
|
9
|
+
type PageSeo,
|
|
10
|
+
} from "@decocms/blocks/cms";
|
|
11
|
+
import { DecoPageRenderer } from "./DecoPageRenderer";
|
|
12
|
+
|
|
13
|
+
interface CreateDecoPageOptions {
|
|
14
|
+
siteName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PageProps {
|
|
18
|
+
params: Promise<{ slug?: string[] }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathFromSlug(slug: string[] | undefined): string {
|
|
22
|
+
return `/${(slug ?? []).join("/")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge page-level SEO (the `seo` block resolved into `DecoPageResult.seoSection`)
|
|
27
|
+
* with section-contributed SEO (sections registered via `registerSeoSections`
|
|
28
|
+
* whose resolved props also carry SEO fields — e.g. a PDP SEO section). Page
|
|
29
|
+
* level fields win on conflict, mirroring `buildPageSeo` in the TanStack
|
|
30
|
+
* binding (packages/tanstack/src/routes/cmsRoute.ts:408-484).
|
|
31
|
+
*
|
|
32
|
+
* Deliberately narrower than that binding's version: it does NOT run
|
|
33
|
+
* `seoSection` through its own section loader (so commerce-loader-backed
|
|
34
|
+
* jsonLD on the seo block won't resolve here) and does NOT fall back to
|
|
35
|
+
* site-wide SEO defaults or apply title/description templates. Those are
|
|
36
|
+
* out of scope for this minimal page.tsx wiring.
|
|
37
|
+
*/
|
|
38
|
+
function buildSeo(page: DecoPageResult): PageSeo {
|
|
39
|
+
const sectionSeo = extractSeoFromSections(page.resolvedSections);
|
|
40
|
+
const pageSeo = page.seoSection ? extractSeoFromProps(page.seoSection.props) : {};
|
|
41
|
+
return { ...sectionSeo, ...pageSeo };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates the { generateMetadata, default } pair a site spreads into
|
|
46
|
+
* app/[[...slug]]/page.tsx. Mirrors `@decocms/tanstack`'s `cmsRouteConfig`.
|
|
47
|
+
*
|
|
48
|
+
* `resolveForPath` is wrapped in React's `cache()` so `generateMetadata` and
|
|
49
|
+
* the page body share one `resolveDecoPage` call per request instead of
|
|
50
|
+
* resolving twice — the same pattern faststore-fila's own
|
|
51
|
+
* `resolveCmsPageByPath` already used against the old /next tier. This relies
|
|
52
|
+
* on Next's RSC renderer establishing a per-request cache boundary that
|
|
53
|
+
* `cache()` memoizes against: calling the returned functions directly outside
|
|
54
|
+
* that renderer (e.g. in a plain unit test) will NOT dedupe, since there is
|
|
55
|
+
* no active cache boundary for `cache()` to key off — verified empirically
|
|
56
|
+
* against this repo's `react` version. The functions still return correct,
|
|
57
|
+
* independent results either way; only the single-request sharing is
|
|
58
|
+
* untestable outside Next's own pipeline.
|
|
59
|
+
*/
|
|
60
|
+
export function createDecoPage({ siteName }: CreateDecoPageOptions) {
|
|
61
|
+
// `siteName` is unused here. `MatcherContext` (packages/blocks/src/cms/resolve.ts)
|
|
62
|
+
// has no siteName field, so there's nothing to thread it into `resolveDecoPage`.
|
|
63
|
+
// It's kept in the options shape to mirror `cmsRouteConfig({ siteName })`'s call
|
|
64
|
+
// signature and as the extension point for Task 7's root layout
|
|
65
|
+
// (LiveControls/analytics wiring) — genuinely unused in this file.
|
|
66
|
+
void siteName;
|
|
67
|
+
|
|
68
|
+
const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
|
|
69
|
+
|
|
70
|
+
async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
71
|
+
const { slug } = await params;
|
|
72
|
+
const page = await resolveForPath(pathFromSlug(slug));
|
|
73
|
+
if (!page) return {};
|
|
74
|
+
|
|
75
|
+
const seo = buildSeo(page);
|
|
76
|
+
return {
|
|
77
|
+
title: seo.title,
|
|
78
|
+
description: seo.description,
|
|
79
|
+
alternates: seo.canonical ? { canonical: seo.canonical } : undefined,
|
|
80
|
+
robots: seo.noIndexing ? { index: false, follow: false } : undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function Page({ params }: PageProps) {
|
|
85
|
+
const { slug } = await params;
|
|
86
|
+
const pathname = pathFromSlug(slug);
|
|
87
|
+
const page = await resolveForPath(pathname);
|
|
88
|
+
if (!page) notFound();
|
|
89
|
+
|
|
90
|
+
// Call DecoPageRenderer directly (await its result) rather than nesting
|
|
91
|
+
// it as `<DecoPageRenderer .../>` JSX. DecoPageRenderer.tsx documents why:
|
|
92
|
+
// it's an async function, and Next's real RSC renderer awaits async
|
|
93
|
+
// components anywhere in the tree — but react-dom/server's synchronous
|
|
94
|
+
// renderer (used by this package's unit tests, and by any consumer not
|
|
95
|
+
// going through Next's RSC pipeline) throws when an async component
|
|
96
|
+
// suspends outside a <Suspense> boundary. Awaiting it here directly
|
|
97
|
+
// keeps both paths working, matching the same convention
|
|
98
|
+
// DecoPageRenderer itself uses for SectionRenderer.
|
|
99
|
+
return await DecoPageRenderer({
|
|
100
|
+
sections: page.resolvedSections,
|
|
101
|
+
deferredSections: page.deferredSections,
|
|
102
|
+
pagePath: pathname,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { generateMetadata, default: Page };
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createDecoPage } from "./createDecoPage";
|
|
2
|
+
export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
|
|
3
|
+
export { DecoPageRenderer } from "./DecoPageRenderer";
|
|
4
|
+
export { SectionRenderer } from "./SectionRenderer";
|
|
5
|
+
export { ClientOnlySection } from "./ClientOnlySection";
|
|
6
|
+
export { DeferredSectionBoundary } from "./DeferredSection";
|
|
7
|
+
export {
|
|
8
|
+
decofileGET,
|
|
9
|
+
decofilePOST,
|
|
10
|
+
invokePOST,
|
|
11
|
+
metaGET,
|
|
12
|
+
renderGET,
|
|
13
|
+
renderPOST,
|
|
14
|
+
} from "./routeHandlers";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { setMetaData } from "@decocms/blocks-admin";
|
|
3
|
+
import { metaGET } from "./routeHandlers";
|
|
4
|
+
|
|
5
|
+
describe("routeHandlers (next)", () => {
|
|
6
|
+
it("metaGET returns the schema response", async () => {
|
|
7
|
+
setMetaData({ sections: {}, actions: {}, loaders: {} } as any);
|
|
8
|
+
const response = await metaGET(new Request("https://example.com/live/_meta"));
|
|
9
|
+
expect(response.status).toBe(200);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
handleDecofileRead,
|
|
3
|
+
handleDecofileReload,
|
|
4
|
+
handleInvoke,
|
|
5
|
+
handleMeta,
|
|
6
|
+
handleRender,
|
|
7
|
+
} from "@decocms/blocks-admin";
|
|
8
|
+
|
|
9
|
+
/** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs"` */
|
|
10
|
+
export async function metaGET(request: Request): Promise<Response> {
|
|
11
|
+
return handleMeta(request);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
|
|
15
|
+
* segments can't literally start with a dot; see Task 9's fixture for the
|
|
16
|
+
* rewrite-rule workaround). */
|
|
17
|
+
export async function decofileGET(): Promise<Response> {
|
|
18
|
+
return handleDecofileRead();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function decofilePOST(request: Request): Promise<Response> {
|
|
22
|
+
return handleDecofileReload(request);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** For app/deco/invoke/[...key]/route.ts */
|
|
26
|
+
export async function invokePOST(request: Request): Promise<Response> {
|
|
27
|
+
return handleInvoke(request);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** For app/live/previews/[...path]/route.ts */
|
|
31
|
+
export async function renderGET(request: Request): Promise<Response> {
|
|
32
|
+
return handleRender(request);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function renderPOST(request: Request): Promise<Response> {
|
|
36
|
+
return handleRender(request);
|
|
37
|
+
}
|