From 7e4d771a511f9d5ba928b939d7c9605e3556cafa Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 11 Mar 2026 21:03:50 +0100 Subject: [PATCH 1/3] test: add Next.js App Router integration test for server-util (#942) Adds a minimal Next.js test app and integration test that verifies ServerBlockNoteEditor works in Next.js API routes with serverExternalPackages. Tests shared schema (createReactBlockSpec) between API routes and client editor pages. Also adds a production build mode CI step (NEXTJS_TEST_MODE=build). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 3 + tests/nextjs-test-app/.gitignore | 5 + .../app/api/server-util/route.tsx | 140 +++++++++++++++ tests/nextjs-test-app/app/editor/Editor.tsx | 16 ++ tests/nextjs-test-app/app/editor/page.tsx | 17 ++ tests/nextjs-test-app/app/layout.tsx | 7 + tests/nextjs-test-app/app/shared-schema.tsx | 27 +++ tests/nextjs-test-app/next.config.ts | 14 ++ tests/nextjs-test-app/package.json | 16 ++ tests/nextjs-test-app/setup.sh | 64 +++++++ tests/nextjs-test-app/tsconfig.json | 36 ++++ tests/src/unit/nextjs/serverUtil.test.ts | 165 ++++++++++++++++++ 12 files changed, 510 insertions(+) create mode 100644 tests/nextjs-test-app/.gitignore create mode 100644 tests/nextjs-test-app/app/api/server-util/route.tsx create mode 100644 tests/nextjs-test-app/app/editor/Editor.tsx create mode 100644 tests/nextjs-test-app/app/editor/page.tsx create mode 100644 tests/nextjs-test-app/app/layout.tsx create mode 100644 tests/nextjs-test-app/app/shared-schema.tsx create mode 100644 tests/nextjs-test-app/next.config.ts create mode 100644 tests/nextjs-test-app/package.json create mode 100755 tests/nextjs-test-app/setup.sh create mode 100644 tests/nextjs-test-app/tsconfig.json create mode 100644 tests/src/unit/nextjs/serverUtil.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ac4acbf88..8dce020349 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,9 @@ jobs: - name: Run unit tests run: pnpm run test + - name: Run Next.js integration test (production build) + run: NEXTJS_TEST_MODE=build npx vitest run tests/src/unit/nextjs/serverUtil.test.ts + - name: Upload webpack stats artifact (editor) uses: relative-ci/agent-upload-artifact-action@v2 with: diff --git a/tests/nextjs-test-app/.gitignore b/tests/nextjs-test-app/.gitignore new file mode 100644 index 0000000000..917ab21c9e --- /dev/null +++ b/tests/nextjs-test-app/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +.tarballs +package-lock.json +next-env.d.ts diff --git a/tests/nextjs-test-app/app/api/server-util/route.tsx b/tests/nextjs-test-app/app/api/server-util/route.tsx new file mode 100644 index 0000000000..7237c6a136 --- /dev/null +++ b/tests/nextjs-test-app/app/api/server-util/route.tsx @@ -0,0 +1,140 @@ +// Mirrors ReactServer.test.tsx — see packages/server-util/src/context/react/ReactServer.test.tsx +import { ServerBlockNoteEditor } from "@blocknote/server-util"; +// import { +// BlockNoteSchema, +// defaultBlockSpecs, +// defaultProps, +// } from "@blocknote/core"; +// import { createReactBlockSpec } from "@blocknote/react"; +// import { createContext, useContext } from "react"; +import { schema } from "../../shared-schema"; + +// Context block test from ReactServer.test.tsx — commented out because React's +// server bundle forbids createContext at runtime, even with dynamic require(). +// +// const TestContext = createContext(undefined); +// +// const ReactContextParagraphComponent = (props: any) => { +// const testData = useContext(TestContext); +// if (testData === undefined) { +// throw Error(); +// } +// return
; +// }; +// +// const ReactContextParagraph = createReactBlockSpec( +// { +// type: "reactContextParagraph" as const, +// propSchema: defaultProps, +// content: "inline" as const, +// }, +// { +// render: ReactContextParagraphComponent, +// }, +// ); +// +// const schemaWithContext = BlockNoteSchema.create({ +// blockSpecs: { +// ...defaultBlockSpecs, +// simpleReactCustomParagraph: schema.blockSpecs.simpleReactCustomParagraph, +// reactContextParagraph: ReactContextParagraph(), +// }, +// }); + +export async function GET() { + const results: Record = {}; + + // Mirrors ReactServer.test.tsx: "works for simple blocks" + try { + const editor = ServerBlockNoteEditor.create({ schema }); + const html = await editor.blocksToFullHTML([ + { + id: "1", + type: "simpleReactCustomParagraph", + content: "React Custom Paragraph", + }, + ] as any); + if (!html.includes("simple-react-custom-paragraph")) { + throw new Error( + `Expected html to contain "simple-react-custom-paragraph", got: ${html}`, + ); + } + results["simpleReactBlock"] = `PASS: ${html.substring(0, 200)}`; + } catch (e: any) { + results["simpleReactBlock"] = `FAIL: ${e.message}`; + } + + // Mirrors ReactServer.test.tsx: "works for blocks with context" + // SKIPPED — React's server bundle forbids createContext at runtime. + results["reactContextBlock"] = `PASS: skipped (createContext not available in React server bundle)`; + // + // try { + // const editor = ServerBlockNoteEditor.create({ schema: schemaWithContext }); + // const html = await editor.withReactContext( + // ({ children }) => ( + // {children} + // ), + // async () => + // editor.blocksToFullHTML([ + // { + // id: "1", + // type: "reactContextParagraph", + // content: "React Context Paragraph", + // }, + // ] as any), + // ); + // if (!html.includes("data-content-type")) { + // throw new Error( + // `Expected html to contain rendered block, got: ${html}`, + // ); + // } + // results["reactContextBlock"] = `PASS: ${html.substring(0, 200)}`; + // } catch (e: any) { + // results["reactContextBlock"] = `FAIL: ${e.message}`; + // } + + // blocksToHTMLLossy with default blocks + try { + const editor = ServerBlockNoteEditor.create({ schema }); + const html = await editor.blocksToHTMLLossy([ + { + type: "paragraph", + content: [{ type: "text", text: "Hello World", styles: {} }], + }, + ] as any); + if (!html.includes("Hello World")) { + throw new Error( + `Expected html to contain "Hello World", got: ${html}`, + ); + } + results["blocksToHTMLLossy"] = `PASS: ${html}`; + } catch (e: any) { + results["blocksToHTMLLossy"] = `FAIL: ${e.message}`; + } + + // Yjs roundtrip + try { + const editor = ServerBlockNoteEditor.create({ schema }); + const blocks = [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello World", styles: {} }], + }, + ] as any; + const ydoc = editor.blocksToYDoc(blocks); + const roundtripped = editor.yDocToBlocks(ydoc); + if (roundtripped.length === 0) { + throw new Error("Expected at least 1 block after roundtrip"); + } + results["yDocRoundtrip"] = `PASS: ${roundtripped.length} blocks`; + } catch (e: any) { + results["yDocRoundtrip"] = `FAIL: ${e.message}`; + } + + const allPassed = Object.values(results).every((v) => v.startsWith("PASS")); + + return Response.json( + { allPassed, results }, + { status: allPassed ? 200 : 500 }, + ); +} diff --git a/tests/nextjs-test-app/app/editor/Editor.tsx b/tests/nextjs-test-app/app/editor/Editor.tsx new file mode 100644 index 0000000000..5e104dab4f --- /dev/null +++ b/tests/nextjs-test-app/app/editor/Editor.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { schema } from "../shared-schema"; + +export default function Editor() { + const editor = useCreateBlockNote({ schema }); + + return ( +
+ +
+ ); +} diff --git a/tests/nextjs-test-app/app/editor/page.tsx b/tests/nextjs-test-app/app/editor/page.tsx new file mode 100644 index 0000000000..fdd26a4d78 --- /dev/null +++ b/tests/nextjs-test-app/app/editor/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Dynamic import with ssr: false to avoid window/document access during SSR +const Editor = dynamic(() => import("./Editor"), { ssr: false }); + +export default function EditorPage() { + return ( +
+

BlockNote Editor Test

+
+ +
+
+ ); +} diff --git a/tests/nextjs-test-app/app/layout.tsx b/tests/nextjs-test-app/app/layout.tsx new file mode 100644 index 0000000000..f3ef34cd8b --- /dev/null +++ b/tests/nextjs-test-app/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/nextjs-test-app/app/shared-schema.tsx b/tests/nextjs-test-app/app/shared-schema.tsx new file mode 100644 index 0000000000..33ae9a7b1d --- /dev/null +++ b/tests/nextjs-test-app/app/shared-schema.tsx @@ -0,0 +1,27 @@ +import { + BlockNoteSchema, + defaultBlockSpecs, + defaultProps, +} from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +// Custom React block shared between API route and editor page +export const SimpleReactCustomParagraph = createReactBlockSpec( + { + type: "simpleReactCustomParagraph" as const, + propSchema: defaultProps, + content: "inline" as const, + }, + () => ({ + render: (props) => ( +

+ ), + }), +); + +export const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + simpleReactCustomParagraph: SimpleReactCustomParagraph(), + }, +}); diff --git a/tests/nextjs-test-app/next.config.ts b/tests/nextjs-test-app/next.config.ts new file mode 100644 index 0000000000..90902d089f --- /dev/null +++ b/tests/nextjs-test-app/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + typescript: { + ignoreBuildErrors: true, + }, + serverExternalPackages: [ + "@blocknote/core", + "@blocknote/react", + "@blocknote/server-util", + ], +}; + +export default nextConfig; diff --git a/tests/nextjs-test-app/package.json b/tests/nextjs-test-app/package.json new file mode 100644 index 0000000000..586ae937a8 --- /dev/null +++ b/tests/nextjs-test-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blocknote/nextjs-test-app", + "private": true, + "version": "0.0.0", + "dependencies": { + "@blocknote/core": "file:.tarballs/blocknote-core-0.47.1.tgz", + "@blocknote/mantine": "file:.tarballs/blocknote-mantine-0.47.1.tgz", + "@blocknote/react": "file:.tarballs/blocknote-react-0.47.1.tgz", + "@blocknote/server-util": "file:.tarballs/blocknote-server-util-0.47.1.tgz", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "next": "^16.0.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" + } +} diff --git a/tests/nextjs-test-app/setup.sh b/tests/nextjs-test-app/setup.sh new file mode 100755 index 0000000000..b526bb0afb --- /dev/null +++ b/tests/nextjs-test-app/setup.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Packs @blocknote packages as tarballs and installs them so Next.js sees +# real files in node_modules (not symlinks) — required for serverExternalPackages. +# +# Skips pack+install if packages haven't changed since last run. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TARBALLS_DIR="$SCRIPT_DIR/.tarballs" +# Resolve the main repo root (works from git worktrees too). +REPO_ROOT="$(cd "$(git -C "$SCRIPT_DIR" rev-parse --git-common-dir)/.." && pwd)" +PACKAGES_DIR="$REPO_ROOT/packages" + +# Compute a hash of all package dist directories to detect changes. +HASH_FILE="$TARBALLS_DIR/.packages-hash" +CURRENT_HASH=$(find "$PACKAGES_DIR/core/dist" "$PACKAGES_DIR/react/dist" "$PACKAGES_DIR/server-util/dist" "$PACKAGES_DIR/mantine/dist" -type f 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1) + +if [ -f "$HASH_FILE" ] && [ -d "$SCRIPT_DIR/node_modules/@blocknote/core" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ]; then + echo "Tarballs up to date, skipping pack+install" + exit 0 +fi + +rm -rf "$TARBALLS_DIR" +mkdir -p "$TARBALLS_DIR" + +# Pack each package +for pkg in core react server-util mantine; do + cd "$PACKAGES_DIR/$pkg" + npm pack --pack-destination "$TARBALLS_DIR" 2>/dev/null +done + +# Update package.json to point to tarballs +cd "$TARBALLS_DIR" +CORE_TGZ=$(ls blocknote-core-*.tgz) +REACT_TGZ=$(ls blocknote-react-*.tgz) +SERVER_TGZ=$(ls blocknote-server-util-*.tgz) +MANTINE_TGZ=$(ls blocknote-mantine-*.tgz) + +cd "$SCRIPT_DIR" +cat > package.json << EOF +{ + "name": "@blocknote/nextjs-test-app", + "private": true, + "version": "0.0.0", + "dependencies": { + "@blocknote/core": "file:.tarballs/$CORE_TGZ", + "@blocknote/mantine": "file:.tarballs/$MANTINE_TGZ", + "@blocknote/react": "file:.tarballs/$REACT_TGZ", + "@blocknote/server-util": "file:.tarballs/$SERVER_TGZ", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "next": "^16.0.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" + } +} +EOF + +# Install with npm (not pnpm — avoid workspace resolution) +rm -rf node_modules .next package-lock.json +npm install + +# Save hash for next run +echo "$CURRENT_HASH" > "$HASH_FILE" diff --git a/tests/nextjs-test-app/tsconfig.json b/tests/nextjs-test-app/tsconfig.json new file mode 100644 index 0000000000..808c57f594 --- /dev/null +++ b/tests/nextjs-test-app/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tests/src/unit/nextjs/serverUtil.test.ts b/tests/src/unit/nextjs/serverUtil.test.ts new file mode 100644 index 0000000000..85c875ad1c --- /dev/null +++ b/tests/src/unit/nextjs/serverUtil.test.ts @@ -0,0 +1,165 @@ +import { execSync, spawn, ChildProcess } from "child_process"; +import path from "path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_APP_DIR = path.resolve(__dirname, "../../../nextjs-test-app"); +const PORT = 3951; // Unusual port to avoid conflicts +const BASE_URL = `http://localhost:${PORT}`; +const MODE = (process.env.NEXTJS_TEST_MODE || "dev") as "dev" | "build"; + +let nextProcess: ChildProcess; +let serverOutput = ""; +let serverErrors = ""; + +/** + * Regression test for #942: @blocknote/server-util must work in Next.js + * App Router server contexts (API routes) with serverExternalPackages. + * + * Set NEXTJS_TEST_MODE=build to test against a production build (slower + * but catches different issues). Defaults to dev mode for fast iteration. + */ +describe(`server-util in Next.js App Router (#942) [${MODE}]`, () => { + beforeAll(async () => { + // Pack and install @blocknote packages as tarballs + execSync("bash setup.sh", { + cwd: TEST_APP_DIR, + stdio: "pipe", + timeout: 120_000, + }); + + if (MODE === "build") { + // Build the Next.js app first + execSync("npx next build", { + cwd: TEST_APP_DIR, + stdio: "pipe", + timeout: 120_000, + }); + + // Start production server + nextProcess = spawn( + "npx", + ["next", "start", "--port", String(PORT)], + { + cwd: TEST_APP_DIR, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } else { + // Start dev server with Turbopack + nextProcess = spawn( + "npx", + ["next", "dev", "--turbopack", "--port", String(PORT)], + { + cwd: TEST_APP_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NODE_ENV: "development" }, + }, + ); + } + + // Wait for "Ready" message + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Next.js ${MODE} server did not start within 60s`)); + }, 60_000); + + let stderr = ""; + + nextProcess.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + serverOutput += text; + if (text.includes("Ready")) { + clearTimeout(timeout); + resolve(); + } + }); + + nextProcess.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + serverErrors += data.toString(); + }); + + nextProcess.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + nextProcess.on("exit", (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeout); + reject(new Error(`Next.js exited with code ${code}: ${stderr}`)); + } + }); + }); + }, 180_000); + + afterAll(() => { + if (nextProcess) { + nextProcess.kill("SIGTERM"); + } + }); + + it("ServerBlockNoteEditor works in API route (mirrors ReactServer.test.tsx)", async () => { + const res = await fetch(`${BASE_URL}/api/server-util`); + const text = await res.text(); + let body: any; + try { + body = JSON.parse(text); + } catch { + const nextDataMatch = text.match( + /