From 4cab52d2a05a0f84aa32ff67b7a3bb9198519eb8 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 20:15:52 +0200 Subject: [PATCH 1/3] fix(server-util): wait for React before the editor removes its DOM globals --- .../src/context/ServerBlockNoteEditor.ts | 51 ++++++++++++++++++- .../src/context/react/ReactServer.test.tsx | 18 +++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index 21a259f6c0..847e2136fc 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -26,12 +26,53 @@ import { import { BlockNoteViewRaw } from "@blocknote/react"; import { Node } from "@tiptap/pm/model"; import * as jsdom from "jsdom"; +import { createRequire } from "node:module"; import * as React from "react"; import { createElement } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type * as Y from "yjs"; +type ReactScheduler = { + unstable_scheduleCallback: (priority: number, callback: () => void) => void; + unstable_IdlePriority: number; +}; + +/** + * The scheduler instance that `react-dom` uses, resolved from its location. + * `undefined` when `react-dom` can't be resolved at runtime, e.g. in a bundle. + */ +function reactDOMScheduler(): ReactScheduler | undefined { + // `__filename` in the CommonJS build, `import.meta.url` in the ES build. + const require = createRequire( + typeof __filename === "string" ? __filename : import.meta.url, + ); + let reactDOMPath: string; + try { + reactDOMPath = require.resolve("react-dom"); + } catch { + return undefined; + } + return createRequire(reactDOMPath)("scheduler") as ReactScheduler; +} + +/** + * Resolves once React's scheduler has run the work queued before it, or after + * one turn of the event loop when the scheduler can't be found. + */ +function reactSchedulerIdle() { + const scheduler = reactDOMScheduler(); + return new Promise((resolve) => { + if (scheduler) { + scheduler.unstable_scheduleCallback(scheduler.unstable_IdlePriority, () => + resolve(), + ); + } else { + setImmediate(resolve); + } + }); +} + /** * Use the ServerBlockNoteEditor to interact with BlockNote documents in a server (nodejs) environment. */ @@ -71,8 +112,14 @@ export class ServerBlockNoteEditor< try { return await fn(); } finally { - globalThis.document = prevDocument; - globalThis.window = prevWindow; + try { + // React leaves work queued after a render, which reads `window.event` + // in its development build. It runs before the globals are restored. + await reactSchedulerIdle(); + } finally { + globalThis.document = prevDocument; + globalThis.window = prevWindow; + } } } diff --git a/packages/server-util/src/context/react/ReactServer.test.tsx b/packages/server-util/src/context/react/ReactServer.test.tsx index f42873b07c..b5f538b223 100644 --- a/packages/server-util/src/context/react/ReactServer.test.tsx +++ b/packages/server-util/src/context/react/ReactServer.test.tsx @@ -88,4 +88,22 @@ describe("Test ServerBlockNoteEditor with React blocks", () => { expect(html).toMatchSnapshot(); }); + + // React leaves work queued after it renders a block, and its development + // build reads `window` there. In Node there is no `window` (#3230). + it("leaves no React work behind that needs window", async () => { + const testWindow = globalThis.window; + // @ts-expect-error -- Node has no `window`. + delete globalThis.window; + try { + const editor = ServerBlockNoteEditor.create({ schema }); + await editor.blocksToMarkdownLossy([ + { type: "simpleReactCustomParagraph", content: "Hello" }, + ]); + // Gives React's scheduler its turn, after the export. + await new Promise((resolve) => setImmediate(resolve)); + } finally { + globalThis.window = testWindow; + } + }); }); From 01ad1e63c69e6aa9064fa88e001a38f30420b8af Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 29 Oct 2026 17:01:25 +0100 Subject: [PATCH 2/3] fix(server-util): use persistent pluggable DOM environments --- .../docs/features/server-processing.mdx | 34 ++++- packages/server-util/README.md | 14 ++ packages/server-util/package.json | 5 + .../src/context/DOMEnvironment.test.ts | 66 +++++++++ .../src/context/ServerBlockNoteEditor.ts | 129 +++++------------ .../src/context/react/ReactServer.test.tsx | 136 ++++++++++++++++-- packages/server-util/src/dom.test.ts | 99 +++++++++++++ packages/server-util/src/dom.ts | 6 + packages/server-util/vite.config.ts | 1 + packages/server-util/vitestSetup.ts | 10 +- 10 files changed, 391 insertions(+), 109 deletions(-) create mode 100644 packages/server-util/README.md create mode 100644 packages/server-util/src/context/DOMEnvironment.test.ts create mode 100644 packages/server-util/src/dom.test.ts create mode 100644 packages/server-util/src/dom.ts diff --git a/docs/content/docs/features/server-processing.mdx b/docs/content/docs/features/server-processing.mdx index 8488e00639..cf6cab7ecb 100644 --- a/docs/content/docs/features/server-processing.mdx +++ b/docs/content/docs/features/server-processing.mdx @@ -8,18 +8,44 @@ path: /docs/server-side-processing While you can use the `BlockNoteEditor` on the client side, you can also use `ServerBlockNoteEditor` from `@blocknote/server-util` to process BlockNote documents on the server. -For example, use the following code to convert a BlockNote document to HTML on the server: +Install `@blocknote/server-util`: + +```bash +pnpm add @blocknote/server-util +``` + +For HTML or Markdown processing, pass a document to the editor. The included JSDOM factory provides a ready-to-use implementation: ```tsx import { ServerBlockNoteEditor } from "@blocknote/server-util"; +import { getDocument } from "@blocknote/server-util/dom"; -const editor = ServerBlockNoteEditor.create(); -const html = await editor.blocksToFullHTML(blocks); +const document = getDocument(); +const editor = ServerBlockNoteEditor.create({}, document); +const html = await editor.blocksToFullHTML([ + { type: "paragraph", content: "Hello" }, +]); ``` `ServerBlockNoteEditor.create` takes the same BlockNoteEditorOptions as `useCreateBlockNote` and `BlockNoteEditor.create` ([see docs](/docs/getting-started)), so you can pass the same configuration (for example, your custom schema) to your server-side BlockNote editor as on the client. +## DOM lifetime and isolation + +`ServerBlockNoteEditor.create(options, document)` accepts a document from a compatible DOM implementation. Share one document across all editors in a rendering environment. If no document is passed, the editor falls back to the global `window` and `document`. Importing the factory or creating a document does not install globals. + +React and custom block renderers need global DOM access. On first DOM use, the editor binds the supplied document and its `defaultView` persistently if neither global exists, reuses a matching pair, and rejects partial or conflicting environments. It never swaps or disposes them. Use a document attached to a window, not a detached document whose `defaultView` is `null`. + +Keep the DOM and these globals alive for the lifetime of the rendering environment. Do not restore previous globals or close the DOM after an export: React can still read the DOM from scheduled work after an export resolves and its temporary root is unmounted. The export promise means the output is ready, not that asynchronous work started by custom components has finished. + +Nested exports and exports from different editors share this stable DOM without swapping globals. JSON, ProseMirror, and Yjs conversions do not require DOM setup. + +Installing DOM globals affects browser-environment detection in other libraries. If your application cannot accept this, initialize the DOM, editor, schema, and providers inside a dedicated worker or process, and send document data into it. Use the same DOM implementation for related APIs (for example, DOM events and their abort signals); mixing Node and DOM implementations can cause incompatibilities. Terminate the worker or process to dispose the rendering environment, rather than tearing down the DOM while React can still run. + +### Custom DOM implementations + +Pass your implementation's document directly as the second argument to `ServerBlockNoteEditor.create`. No adapter or acquisition/release hooks are needed. The main `@blocknote/server-util` entry point does not load JSDOM. + ## Functions for converting blocks `ServerBlockNoteEditor` exposes the same functions for converting blocks as the client side editor ([HTML](/docs/features/import/html), [Markdown](/docs/features/import/markdown)): @@ -53,6 +79,8 @@ const html = await editor.withReactContext( ); ``` +Serialize `withReactContext` calls on the same editor, because its context renderer is editor-local mutable state. Use separate editor instances for concurrent context-dependent exports. Custom React blocks continue to use the same callback-ref API as on the client. + ## Next.js App Router If you're using `@blocknote/server-util` in a Next.js App Router API route (Route Handler), you need to add the BlockNote packages to `serverExternalPackages` in your `next.config.ts`: diff --git a/packages/server-util/README.md b/packages/server-util/README.md new file mode 100644 index 0000000000..e096d5bbba --- /dev/null +++ b/packages/server-util/README.md @@ -0,0 +1,14 @@ +# BlockNote server utilities + +Process BlockNote documents on the server with `ServerBlockNoteEditor`. + +HTML/Markdown operations and React context rendering require an +persistent DOM. Pass `getDocument()` from `@blocknote/server-util/dom` +as the second argument to `ServerBlockNoteEditor.create(options, document)`, +or provide a document from another compatible implementation. Share one +document across editors. If omitted, the editor uses the global DOM. Imports +do not install globals, and the main entry point does not load JSDOM. JSON, +ProseMirror, and Yjs conversions need no DOM setup. + +See [server-side processing](https://www.blocknotejs.org/docs/features/server-processing) +for setup, React context, concurrency, and worker isolation. diff --git a/packages/server-util/package.json b/packages/server-util/package.json index c03ff811d5..53775c8990 100644 --- a/packages/server-util/package.json +++ b/packages/server-util/package.json @@ -42,6 +42,11 @@ "import": "./dist/blocknote-server-util.js", "require": "./dist/blocknote-server-util.cjs" }, + "./dom": { + "types": "./types/src/dom.d.ts", + "import": "./dist/blocknote-server-util-dom.js", + "require": "./dist/blocknote-server-util-dom.cjs" + }, "./style.css": { "import": "./dist/style.css", "require": "./dist/style.css", diff --git a/packages/server-util/src/context/DOMEnvironment.test.ts b/packages/server-util/src/context/DOMEnvironment.test.ts new file mode 100644 index 0000000000..886849710e --- /dev/null +++ b/packages/server-util/src/context/DOMEnvironment.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { ServerBlockNoteEditor } from "./ServerBlockNoteEditor.js"; + +describe("application-owned DOM environment", () => { + it.each(["window", "document"] as const)( + "fails clearly when %s is missing without installing globals", + async (global) => { + const testWindow = globalThis.window; + const testDocument = globalThis.document; + vi.stubGlobal(global, undefined); + try { + const editor = ServerBlockNoteEditor.create(); + await expect(editor.blocksToFullHTML([])).rejects.toThrow(); + await expect(editor.blocksToHTMLLossy([])).rejects.toThrow(); + await expect(editor.blocksToMarkdownLossy([])).rejects.toThrow(); + await expect(editor.tryParseHTMLToBlocks("Hello")).rejects.toThrow(); + await expect( + editor.tryParseMarkdownToBlocks("Hello"), + ).rejects.toThrow(); + await expect( + editor.withReactContext( + ({ children }) => children, + async () => "unused", + ), + ).rejects.toThrow(); + expect(globalThis[global]).toBeUndefined(); + expect( + global === "window" ? globalThis.document : globalThis.window, + ).toBe(global === "window" ? testDocument : testWindow); + } finally { + vi.unstubAllGlobals(); + } + }, + ); + + it("allows JSON and Yjs conversions without any DOM globals", () => { + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); + try { + const editor = ServerBlockNoteEditor.create(); + const blocks = [{ type: "paragraph", content: "No DOM" }] as const; + const node = editor._blocksToProsemirrorNode([...blocks]); + expect(editor._prosemirrorNodeToBlocks(node)[0].type).toBe("paragraph"); + const doc = editor.blocksToYDoc([...blocks]); + expect(editor.yDocToBlocks(doc)[0].type).toBe("paragraph"); + doc.destroy(); + expect(globalThis.window).toBeUndefined(); + expect(globalThis.document).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects mismatched DOM globals without replacing either", async () => { + const unrelatedDocument = document.implementation.createHTMLDocument(); + vi.stubGlobal("document", unrelatedDocument); + try { + await expect( + ServerBlockNoteEditor.create().blocksToHTMLLossy([]), + ).rejects.toThrow(); + expect(globalThis.document).toBe(unrelatedDocument); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index 847e2136fc..bd993aa02a 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -25,52 +25,30 @@ import { import { BlockNoteViewRaw } from "@blocknote/react"; import { Node } from "@tiptap/pm/model"; -import * as jsdom from "jsdom"; -import { createRequire } from "node:module"; import * as React from "react"; import { createElement } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type * as Y from "yjs"; -type ReactScheduler = { - unstable_scheduleCallback: (priority: number, callback: () => void) => void; - unstable_IdlePriority: number; -}; - -/** - * The scheduler instance that `react-dom` uses, resolved from its location. - * `undefined` when `react-dom` can't be resolved at runtime, e.g. in a bundle. - */ -function reactDOMScheduler(): ReactScheduler | undefined { - // `__filename` in the CommonJS build, `import.meta.url` in the ES build. - const require = createRequire( - typeof __filename === "string" ? __filename : import.meta.url, - ); - let reactDOMPath: string; - try { - reactDOMPath = require.resolve("react-dom"); - } catch { - return undefined; +function getDOMDocument(document?: Document): Document { + document ??= globalThis.document; + const window = document?.defaultView; + if (!window) { + throw new Error(); } - return createRequire(reactDOMPath)("scheduler") as ReactScheduler; -} -/** - * Resolves once React's scheduler has run the work queued before it, or after - * one turn of the event loop when the scheduler can't be found. - */ -function reactSchedulerIdle() { - const scheduler = reactDOMScheduler(); - return new Promise((resolve) => { - if (scheduler) { - scheduler.unstable_scheduleCallback(scheduler.unstable_IdlePriority, () => - resolve(), - ); - } else { - setImmediate(resolve); - } - }); + // React can access globals after an export returns. Bind once, never swap. + if ( + typeof globalThis.window === "undefined" && + typeof globalThis.document === "undefined" + ) { + Object.assign(globalThis, { window, document }); + } + if (globalThis.window !== window || globalThis.document !== document) { + throw new Error(); + } + return document; } /** @@ -87,56 +65,30 @@ export class ServerBlockNoteEditor< public readonly editor: BlockNoteEditor; /** - * We currently use a JSDOM instance to mock document and window methods - * - * A possible improvement could be to make this: - * a) pluggable so other shims can be used as well - * b) obsolete, but for this all blocks should be React based and we need to remove all references to document / window - * from the core / react package. (and even then, it's likely some custom blocks would still use document / window methods) - */ - private jsdom = new jsdom.JSDOM(); - - /** - * Calls a function with mocking window and document using JSDOM - * - * We could make this obsolete by passing in a document / window object to the render / serialize methods of Blocks + * Calls a function with the supplied document, falling back to the global DOM. + * Nested and concurrent exports use the same globals without swapping them. */ - public async _withJSDOM(fn: () => Promise) { - const prevWindow = globalThis.window; - const prevDocument = globalThis.document; - globalThis.document = this.jsdom.window.document; - (globalThis as any).window = this.jsdom.window; - (globalThis as any).window.__TEST_OPTIONS = ( - prevWindow as any - )?.__TEST_OPTIONS; - try { - return await fn(); - } finally { - try { - // React leaves work queued after a render, which reads `window.event` - // in its development build. It runs before the globals are restored. - await reactSchedulerIdle(); - } finally { - globalThis.document = prevDocument; - globalThis.window = prevWindow; - } - } + public async _withDOM(fn: (document: Document) => Promise) { + return fn(getDOMDocument(this.document)); } public static create< BSchema extends BlockSchema = DefaultBlockSchema, ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema, - >(options: Partial> = {}) { - return new ServerBlockNoteEditor(options) as ServerBlockNoteEditor< - BSchema, - ISchema, - SSchema - >; + >( + options: Partial> = {}, + document?: Document, + ) { + return new ServerBlockNoteEditor( + options, + document, + ) as ServerBlockNoteEditor; } protected constructor( options: Partial>, + private readonly document?: Document, ) { this.editor = BlockNoteEditor.create(options) as any; } @@ -233,14 +185,14 @@ export class ServerBlockNoteEditor< public async blocksToHTMLLossy( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async (document) => { const exporter = createExternalHTMLExporter( this.editor.pmSchema, this.editor, ); return exporter.exportBlocks(blocks, { - document: this.jsdom.window.document, + document, }); }); } @@ -257,14 +209,14 @@ export class ServerBlockNoteEditor< public async blocksToFullHTML( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async (document) => { const exporter = createInternalHTMLSerializer( this.editor.pmSchema, this.editor, ); return exporter.serializeBlocks(blocks, { - document: this.jsdom.window.document, + document, }); }); } @@ -279,7 +231,7 @@ export class ServerBlockNoteEditor< public async tryParseHTMLToBlocks( html: string, ): Promise[]> { - return this._withJSDOM(async () => { + return this._withDOM(async () => { return this.editor.tryParseHTMLToBlocks(html); }); } @@ -295,9 +247,9 @@ export class ServerBlockNoteEditor< public async blocksToMarkdownLossy( blocks: PartialBlock[], ): Promise { - return this._withJSDOM(async () => { + return this._withDOM(async (document) => { return blocksToMarkdown(blocks, this.editor.pmSchema, this.editor, { - document: this.jsdom.window.document, + document, }); }); } @@ -312,7 +264,7 @@ export class ServerBlockNoteEditor< public async tryParseMarkdownToBlocks( markdown: string, ): Promise[]> { - return this._withJSDOM(async () => { + return this._withDOM(async () => { return this.editor.tryParseMarkdownToBlocks(markdown); }); } @@ -332,10 +284,8 @@ export class ServerBlockNoteEditor< ); */ public async withReactContext(comp: React.FC, fn: () => Promise) { - return this._withJSDOM(async () => { - const tmpRoot = createRoot( - this.jsdom.window.document.createElement("div"), - ); + return this._withDOM(async (document) => { + const tmpRoot = createRoot(document.createElement("div")); flushSync(() => { tmpRoot.render( @@ -361,7 +311,6 @@ export class ServerBlockNoteEditor< return await fn(); } finally { tmpRoot.unmount(); - await new Promise((resolve) => setTimeout(resolve, 3)); } }); } diff --git a/packages/server-util/src/context/react/ReactServer.test.tsx b/packages/server-util/src/context/react/ReactServer.test.tsx index b5f538b223..e55402d968 100644 --- a/packages/server-util/src/context/react/ReactServer.test.tsx +++ b/packages/server-util/src/context/react/ReactServer.test.tsx @@ -5,7 +5,7 @@ import { defaultProps, } from "@blocknote/core"; import { createReactBlockSpec } from "@blocknote/react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { describe, expect, it } from "vite-plus/test"; import { ServerBlockNoteEditor } from "../ServerBlockNoteEditor.js"; @@ -89,21 +89,127 @@ describe("Test ServerBlockNoteEditor with React blocks", () => { expect(html).toMatchSnapshot(); }); - // React leaves work queued after it renders a block, and its development - // build reads `window` there. In Node there is no `window` (#3230). - it("leaves no React work behind that needs window", async () => { + // React's development scheduler reads window after an export (#3230). + // Component-owned async work can outlive even a scheduler-idle callback. + it("keeps the application DOM available for work after the export", async () => { const testWindow = globalThis.window; - // @ts-expect-error -- Node has no `window`. - delete globalThis.window; - try { - const editor = ServerBlockNoteEditor.create({ schema }); + const testDocument = globalThis.document; + const { promise: continueWork, resolve: releaseDeferredWork } = + Promise.withResolvers(); + let deferredWork: + | Promise<{ renderDocument: Document; currentDocument: Document }> + | undefined; + const DeferredParagraph = createReactBlockSpec( + { type: "deferredParagraph", propSchema: {}, content: "inline" }, + { + render: function DeferredParagraphComponent(props) { + useEffect(() => { + const renderDocument = window.document; + return () => { + deferredWork = continueWork.then(() => ({ + renderDocument, + currentDocument: window.document, + })); + }; + }, []); + return

; + }, + }, + ); + const editor = ServerBlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + deferredParagraph: DeferredParagraph(), + }, + }), + }); + expect( await editor.blocksToMarkdownLossy([ - { type: "simpleReactCustomParagraph", content: "Hello" }, - ]); - // Gives React's scheduler its turn, after the export. - await new Promise((resolve) => setImmediate(resolve)); - } finally { - globalThis.window = testWindow; - } + { type: "deferredParagraph", content: "Hello" }, + ]), + ).toContain("Hello"); + expect(globalThis.window).toBe(testWindow); + expect(globalThis.document).toBe(testDocument); + // Explicitly release component-owned work only after the export resolves. + expect(deferredWork).toBeDefined(); + releaseDeferredWork(); + const observed = await deferredWork; + // Identity comparisons avoid traversing JSDOM objects in failure diagnostics. + expect(observed?.renderDocument === testDocument).toBe(true); + expect(observed?.currentDocument === testDocument).toBe(true); + }); + + it("shares the application DOM across concurrent editors and exports", async () => { + const testWindow = globalThis.window; + const testDocument = globalThis.document; + const editors = Array.from({ length: 4 }, () => + ServerBlockNoteEditor.create({ schema }), + ); + const results = await Promise.all( + Array.from({ length: 100 }, (_, index) => + editors[index % editors.length].blocksToMarkdownLossy([ + { type: "simpleReactCustomParagraph", content: `Paragraph ${index}` }, + ]), + ), + ); + results.forEach((result, index) => { + expect(result).toContain(`Paragraph ${index}`); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(globalThis.window === testWindow).toBe(true); + expect(globalThis.document === testDocument).toBe(true); + }); + + it("uses the same DOM for context providers and nested exports", async () => { + const testDocument = globalThis.document; + const editor = ServerBlockNoteEditor.create({ schema }); + const html = await editor.withReactContext( + function Provider({ children }) { + expect(globalThis.document === testDocument).toBe(true); + return ( + {children} + ); + }, + async () => { + expect(globalThis.document === testDocument).toBe(true); + return editor.blocksToFullHTML([ + { type: "reactContextParagraph", content: "Nested export" }, + ]); + }, + ); + expect(html).toContain("Nested export"); + expect(globalThis.document).toBe(testDocument); + }); + + it("preserves the application DOM and unmounts context after a failure", async () => { + const testWindow = globalThis.window; + const testDocument = globalThis.document; + const failure = new Error("Unexpected rendering failure"); + let providerDocument: Document | undefined; + let unmounted = false; + const editor = ServerBlockNoteEditor.create({ schema }); + + await expect( + editor.withReactContext( + function Provider({ children }) { + useEffect(() => { + providerDocument = document; + return () => { + unmounted = true; + }; + }, []); + return children; + }, + async () => { + throw failure; + }, + ), + ).rejects.toBe(failure); + + expect(unmounted).toBe(true); + expect(providerDocument === testDocument).toBe(true); + expect(globalThis.window).toBe(testWindow); + expect(globalThis.document).toBe(testDocument); }); }); diff --git a/packages/server-util/src/dom.test.ts b/packages/server-util/src/dom.test.ts new file mode 100644 index 0000000000..e5777cac99 --- /dev/null +++ b/packages/server-util/src/dom.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { ServerBlockNoteEditor } from "./context/ServerBlockNoteEditor.js"; + +describe("pluggable DOM document", () => { + it("does not install globals when importing or creating a document", async () => { + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); + try { + const { getDocument } = await import("./dom.js"); + const document = getDocument(); + expect(document.defaultView).not.toBeNull(); + expect(globalThis.window).toBeUndefined(); + expect(globalThis.document).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("shares an injected document across nested and concurrent exports", async () => { + const document = globalThis.document; + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); + try { + const first = ServerBlockNoteEditor.create({}, document); + const second = ServerBlockNoteEditor.create({}, document); + await Promise.all([ + first.blocksToHTMLLossy([{ type: "paragraph", content: "First" }]), + second.blocksToMarkdownLossy([ + { type: "paragraph", content: "Second" }, + ]), + first._withDOM(async (activeDocument) => { + expect(activeDocument === document).toBe(true); + return first.blocksToFullHTML([]); + }), + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("binds the injected document persistently on first DOM use", async () => { + const document = globalThis.document; + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); + try { + const editor = ServerBlockNoteEditor.create({}, document); + expect(globalThis.window).toBeUndefined(); + expect( + await editor.blocksToMarkdownLossy([ + { type: "paragraph", content: "Hello" }, + ]), + ).toContain("Hello"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(globalThis.window === document.defaultView).toBe(true); + expect(globalThis.document === document).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects an injected document conflicting with the active environment", async () => { + const activeWindow = globalThis.window; + const activeDocument = globalThis.document; + const { getDocument } = await import("./dom.js"); + const editor = ServerBlockNoteEditor.create({}, getDocument()); + await expect(editor.blocksToFullHTML([])).rejects.toThrow(); + expect(globalThis.window === activeWindow).toBe(true); + expect(globalThis.document === activeDocument).toBe(true); + }); + + it.each(["window", "document"] as const)( + "does not overwrite a partial environment missing %s", + async (missing) => { + const document = globalThis.document; + vi.stubGlobal(missing, undefined); + try { + const editor = ServerBlockNoteEditor.create({}, document); + await expect(editor.blocksToFullHTML([])).rejects.toThrow(); + expect(globalThis[missing]).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }, + ); + + it("rejects a document without a window before installing globals", async () => { + const document = globalThis.document.implementation.createHTMLDocument(); + vi.stubGlobal("window", undefined); + vi.stubGlobal("document", undefined); + try { + const editor = ServerBlockNoteEditor.create({}, document); + await expect(editor.blocksToFullHTML([])).rejects.toThrow(); + expect(globalThis.window).toBeUndefined(); + expect(globalThis.document).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/server-util/src/dom.ts b/packages/server-util/src/dom.ts new file mode 100644 index 0000000000..f4473f049c --- /dev/null +++ b/packages/server-util/src/dom.ts @@ -0,0 +1,6 @@ +import { JSDOM } from "jsdom"; + +/** Creates a JSDOM document without installing globals. Share it across editors. */ +export function getDocument(): Document { + return new JSDOM().window.document; +} diff --git a/packages/server-util/vite.config.ts b/packages/server-util/vite.config.ts index eb80907dc0..f44ef0318e 100644 --- a/packages/server-util/vite.config.ts +++ b/packages/server-util/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig( lib: { entry: { "blocknote-server-util": path.resolve(__dirname, "src/index.ts"), + "blocknote-server-util-dom": path.resolve(__dirname, "src/dom.ts"), }, name: "blocknote-server-util", formats: ["es", "cjs"], diff --git a/packages/server-util/vitestSetup.ts b/packages/server-util/vitestSetup.ts index dbcf3eb39c..ecc6d65372 100644 --- a/packages/server-util/vitestSetup.ts +++ b/packages/server-util/vitestSetup.ts @@ -1,7 +1,15 @@ +import { JSDOM } from "jsdom"; import { afterEach, beforeEach } from "vite-plus/test"; +// Own one persistent DOM for this test environment, including React's queued +// work between tests. ServerBlockNoteEditor must never replace or close it. +const dom = new JSDOM(); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, +}); + beforeEach(() => { - globalThis.window = globalThis.window || ({} as any); (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; }); From 350a95023a5fa90061bc9f7549b0777fed290c1e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 29 Oct 2026 20:11:07 +0100 Subject: [PATCH 3/3] fix(server-util): clarify DOM setup errors --- packages/server-util/README.md | 2 +- packages/server-util/src/context/ServerBlockNoteEditor.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/server-util/README.md b/packages/server-util/README.md index e096d5bbba..3b5cb84d65 100644 --- a/packages/server-util/README.md +++ b/packages/server-util/README.md @@ -2,7 +2,7 @@ Process BlockNote documents on the server with `ServerBlockNoteEditor`. -HTML/Markdown operations and React context rendering require an +HTML/Markdown operations and React context rendering require a persistent DOM. Pass `getDocument()` from `@blocknote/server-util/dom` as the second argument to `ServerBlockNoteEditor.create(options, document)`, or provide a document from another compatible implementation. Share one diff --git a/packages/server-util/src/context/ServerBlockNoteEditor.ts b/packages/server-util/src/context/ServerBlockNoteEditor.ts index bd993aa02a..46c7393a3c 100644 --- a/packages/server-util/src/context/ServerBlockNoteEditor.ts +++ b/packages/server-util/src/context/ServerBlockNoteEditor.ts @@ -35,7 +35,9 @@ function getDOMDocument(document?: Document): Document { document ??= globalThis.document; const window = document?.defaultView; if (!window) { - throw new Error(); + throw new Error( + "ServerBlockNoteEditor requires a document with a window (defaultView). Pass a document, such as getDocument() from @blocknote/server-util/dom, or define matching window and document globals.", + ); } // React can access globals after an export returns. Bind once, never swap. @@ -46,7 +48,9 @@ function getDOMDocument(document?: Document): Document { Object.assign(globalThis, { window, document }); } if (globalThis.window !== window || globalThis.document !== document) { - throw new Error(); + throw new Error( + "ServerBlockNoteEditor found partial or conflicting DOM globals. Use one document consistently, or remove partial or conflicting globals before initializing the rendering environment.", + ); } return document; }