Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@shikijs/langs-precompiled": "^4",
"@shikijs/themes": "^4",
"@shikijs/types": "^4",
"@tiptap/core": "^3.13.0",
"@tiptap/core": "^3.29.2",
"@uppy/core": "^3.13.1",
"@uppy/dashboard": "^3.9.1",
"@uppy/drag-drop": "^3.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@tiptap/core": "^3.13.0"
"@tiptap/core": "^3.29.2"
},
"devDependencies": {
"@types/react": "^19.2.3",
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
"@ai-sdk/react": "3.0.5",
"@ai-sdk/gateway": "3.0.4",
"@headlessui/react": "^2.2.4",
"@tiptap/core": "^3.0.0",
"@tiptap/pm": "^3.0.0"
"@tiptap/core": "^3.29.2",
"@tiptap/pm": "^3.29.2"
},
"workspaces": [
"packages/*",
Expand Down
26 changes: 13 additions & 13 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,24 @@
"@handlewithcare/prosemirror-inputrules": "^0.1.4",
"@shikijs/types": "^4",
"@tanstack/store": "^0.7.7",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-bold": "^3.13.0",
"@tiptap/extension-code": "^3.13.0",
"@tiptap/extension-italic": "^3.13.0",
"@tiptap/extension-strike": "^3.13.0",
"@tiptap/extension-text": "^3.13.0",
"@tiptap/extension-underline": "^3.13.0",
"@tiptap/extensions": "^3.13.0",
"@tiptap/pm": "^3.13.0",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-bold": "^3.29.2",
"@tiptap/extension-code": "^3.29.2",
"@tiptap/extension-italic": "^3.29.2",
"@tiptap/extension-strike": "^3.29.2",
"@tiptap/extension-text": "^3.29.2",
"@tiptap/extension-underline": "^3.29.2",
"@tiptap/extensions": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"emoji-mart": "^5.6.0",
"fast-deep-equal": "^3.1.3",
"lib0": "1.0.0-rc.22",
"prosemirror-highlight": "^0.15.1",
"prosemirror-model": "^1.25.4",
"prosemirror-model": "^1.25.11",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.3",
"prosemirror-transform": "^1.11.0",
"prosemirror-view": "^1.41.4"
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.9"
},
"devDependencies": {
"jsdom": "^29.0.2",
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/schema/blocks/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { nonFormattingMarks } from "../markGroups.js";
import { PropSchema } from "../propTypes.js";
import {
getBlockFromPos,
getBlockFromNodeView,
propsToAttributes,
wrapInBlockStructure,
} from "./internal.js";
Expand Down Expand Up @@ -247,8 +247,15 @@ export function addNodeAndExtensionsToSpec<
return (props) => {
// Gets the BlockNote editor instance
const editor = this.options.editor;
// Gets the block
const block = getBlockFromPos(props.getPos, props.view.state.doc);
// Gets the block. Resolving this can't rely on `getPos()` alone —
// node views are constructed part-way through ProseMirror's
// reconciliation, where positions don't always line up with
// `view.state.doc` yet (see `getBlockFromNodeView`).
const block = getBlockFromNodeView(
props.getPos,
props.node,
props.view.state.doc,
);
// Gets the custom HTML attributes for `blockContent` nodes
const blockContentDOMAttributes =
this.options.domAttributes?.blockContent || {};
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/schema/blocks/internal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";

import { getNodeById } from "../../api/nodeUtil.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { getBlockFromNodeView } from "./internal.js";

describe("getBlockFromNodeView", () => {
let editor: BlockNoteEditor;

beforeEach(() => {
editor = BlockNoteEditor.create({
trailingBlock: false,
initialContent: [
{ type: "paragraph", content: "first" },
{ type: "paragraph", content: "second" },
],
});
});

afterEach(() => {
// Leaving the editor alive leaks a ProseMirror `DOMObserver`, whose
// `stop()` schedules a `flush()` 20ms later. That can outlive the test
// environment and then fail the run with `ReferenceError: document is not
// defined`, attributed to whichever test file happens to be running.
editor._tiptapEditor.destroy();
editor = undefined as any;
});

it("resolves from the position when it is valid", () => {
const doc = editor.prosemirrorState.doc;
const target = editor.document[1];
// `getBlockFromPos` resolves the *parent* of the position, so the position
// sits just inside the block container — which is what ProseMirror hands a
// node view.
const pos = getNodeById(target.id, doc)!.posBeforeNode + 1;
const block = getBlockFromNodeView(() => pos, doc.nodeAt(pos)!, doc) as any;

expect(block.id).toBe(target.id);
});

it("builds a standalone block when the node is no longer in the document", () => {
// The only way `getPos()` fails at node view construction is a re-entrant
// dispatch superseding `view.state.doc`, which takes the node with it — so
// the node genuinely isn't in the document any more. See #2937.
const doc = editor.prosemirrorState.doc;
const orphan = editor.pmSchema.nodes.paragraph.create(
{ textAlignment: "right" },
editor.pmSchema.text("orphaned content"),
);
const block = getBlockFromNodeView(
() => doc.content.size + 1000,
orphan,
doc,
) as any;

// Everything readable off the node is correct...
expect(block.type).toBe("paragraph");
expect(block.props.textAlignment).toBe("right");
expect(block.content[0].text).toBe("orphaned content");
// ...but the id belongs to no block in the document, so it must not
// collide with a real one.
expect(editor.document.map((b) => b.id)).not.toContain(block.id);
});

it("recovers from an in-range position that resolves to the wrong node", () => {
// Position 0 resolves to the doc rather than a block container — the shape
// a bounds check alone would not catch.
const doc = editor.prosemirrorState.doc;
const orphan = editor.pmSchema.nodes.paragraph.create(
null,
editor.pmSchema.text("orphaned content"),
);
expect(() => getBlockFromNodeView(() => 0, orphan, doc)).not.toThrow();
});

it("recovers when the position is undefined", () => {
const doc = editor.prosemirrorState.doc;
const orphan = editor.pmSchema.nodes.paragraph.create(
null,
editor.pmSchema.text("orphaned content"),
);
expect(() =>
getBlockFromNodeView(() => undefined, orphan, doc),
).not.toThrow();
});
});
64 changes: 64 additions & 0 deletions packages/core/src/schema/blocks/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,70 @@ export function getBlockFromPos(getPos: () => number | undefined, doc: PMNode) {
return block;
}

/**
* Resolves the block a node view should render, tolerating a `getPos()` that
* can't be trusted.
*
* ProseMirror derives `getPos()` from its view-desc tree, but
* `EditorView.updateStateInner` assigns the new state *before* it reconciles
* that tree. Anything that runs partway through reconciliation - a re-entrant
* dispatch from a node view's effect, TipTap's `flushSync` while mounting a
* node view - therefore sees positions that no longer line up with
* `view.state.doc`. The position then either lands out of range or, just as
* bad, in range but pointing at the wrong node.
*
* Node views are constructed inside that same window, so this applies at
* construction just as much as on re-render. The state is always transient:
* ProseMirror finishes reconciling and rebuilds the node view against the
* current document immediately after. So we degrade instead of throwing,
* because a stale frame is invisible where a throw is not.
*
* See issues #2937, #2682 and #2621.
*/
export function getBlockFromNodeView(
getPos: () => number | undefined,
node: PMNode,
doc: PMNode,
) {
try {
return getBlockFromPos(getPos, doc);
} catch (e) {
// Failing here means the node is not in `doc` — a re-entrant dispatch
// superseded the document ProseMirror is building node views for, and the
// node went with it. So there is no container to read an id from, and the
// block has to be built from the node alone. Deliberately silent: this is
// expected and self-correcting, and there is nothing a consumer could do
// about it in the meantime.
//
// `type`, `props` and `content` are read off the node and are correct.
// `children` is empty and `id` is freshly generated, i.e. it belongs to no
// block in the document — callers must not treat it as addressable. (It
// can't collide with a real block: ids are uuids, and the deterministic
// test-mode generator shares one monotonic counter with real ids.) This is
// short-lived; ProseMirror rebuilds the node view against the real document
// right after.
//
// The alternatives are worse. Throwing is the crash this exists to prevent.
// Returning an empty placeholder node view can leave the block
// *permanently* blank, since vanilla node views don't implement `update()`
// and so are only rebuilt when something else changes the document.
//
// `createAndFill` rather than `create` so an unexpected node shape yields
// `null` instead of throwing over the top of the original failure.
const standalone = doc.type.schema.nodes["blockContainer"]?.createAndFill(
null,
node,
);
if (standalone) {
return nodeToBlock(standalone, doc);
}

// Nothing left to render from. Surface the original failure rather than
// inventing a block that isn't grounded in anything.
throw e;
}
}

// Function that wraps the `dom` element returned from 'blockConfig.render' in a
// `blockContent` div, which contains the block type and props as HTML
// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
Expand Down
6 changes: 3 additions & 3 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@
"@emoji-mart/data": "^1.2.1",
"@floating-ui/react": "^0.27.18",
"@tanstack/react-store": "0.7.7",
"@tiptap/core": "^3.13.0",
"@tiptap/pm": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/core": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"emoji-mart": "^5.6.0",
"fast-deep-equal": "^3.1.3",
"use-sync-external-store": "1.6.0"
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export * from "./hooks/useEditorState.js";
export * from "./schema/ReactBlockSpec.js";
export * from "./schema/ReactInlineContentSpec.js";
export * from "./schema/ReactStyleSpec.js";
export * from "./schema/useNodeViewBlock.js";

export * from "./icons.js";

Expand Down
15 changes: 9 additions & 6 deletions packages/react/src/schema/ReactBlockSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
Extension,
ExtensionFactoryInstance,
ExtractBlockConfigFromConfigOrCreator,
getBlockFromPos,
mergeCSSClasses,
Props,
PropSchema,
Expand All @@ -23,6 +22,7 @@ import {
} from "@tiptap/react";
import { FC, ReactNode } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
import { useNodeViewBlock } from "./useNodeViewBlock.js";

// this file is mostly analogoues to `customBlocks.ts`, but for React blocks

Expand Down Expand Up @@ -267,17 +267,20 @@ export function createReactBlockSpec<
},
render(block, editor) {
if (this.renderType === "nodeView") {
// The block core's `addNodeView` resolved when this node view was
// constructed (itself guarded, via `getBlockFromNodeView`). Seeds
// the fallback below so there is always something to render.
const initialBlock = block;

return ReactNodeViewRenderer(
(props: NodeViewProps) => {
// Vanilla JS node views are recreated on each update. However,
// using `ReactNodeViewRenderer` makes it so the node view is
// only created once, so the block we get in the node view will
// be outdated. Therefore, we have to get the block in the
// `ReactNodeViewRenderer` instead.
const block = getBlockFromPos(
props.getPos,
props.view.state.doc,
);
// `ReactNodeViewRenderer` instead. That position can be stale,
// so resolving it is guarded (see `useNodeViewBlock`).
const block = useNodeViewBlock(props, initialBlock);

const ref = useReactNodeView().nodeViewContentRef;

Expand Down
57 changes: 57 additions & 0 deletions packages/react/src/schema/useNodeViewBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Block, getBlockFromPos } from "@blocknote/core";
import type { NodeViewProps } from "@tiptap/react";
import { useRef } from "react";

/**
* Resolves the `Block` that a React node view should render.
*
* A node view's `getPos()` is not always usable. ProseMirror computes it by
* walking the view-desc tree, but `EditorView.updateStateInner` assigns the new
* state *before* it reconciles that tree, so any render that happens partway
* through reconciliation sees positions computed against a half-updated tree
* while `view.state.doc` is already the new document. React node views get
* rendered in exactly that window: TipTap's `ReactRenderer` calls `flushSync`
* while mounting a node view, and anything that runs from the resulting commit
* (a layout effect, a re-entrant dispatch, another node view's pending update)
* renders with a position that no longer matches the document. `getPos()` can
* also return `undefined` outright once a desc has been detached.
*
* The resulting position is either out of range (`RangeError: Position N out of
* range`) or in range but pointing at the wrong node (`Node should be a
* bnBlock, but is instead: doc`) — both thrown out of the render path, which
* under React 19 tears down the consumer's tree rather than being rethrown.
*
* Upstream considers this TipTap's problem (ProseMirror/prosemirror#1532) and
* TipTap considers `flushSync` unavoidable, so we recover here instead, by
* reusing the last block we rendered. The bad position is always transient —
* ProseMirror finishes reconciling and re-renders the node view with a valid
* one immediately after — so a stale frame is invisible, whereas a throw is
* not.
*
* See BlockNote issues #2937, #2682 and #2621.
*/
export function useNodeViewBlock(
props: NodeViewProps,
/**
* The block core's `addNodeView` resolved when the node view was constructed
* — via `getBlockFromNodeView`, so it is already guarded against the same
* problem. Seeds the fallback so there is always a block to render.
*/
initialBlock: Block<any, any, any>,
): Block<any, any, any> {
const lastBlockRef = useRef(initialBlock);
const doc = props.view.state.doc;

try {
// Deliberate render-phase write: a monotonic "last good value" cache, so a
// repeated render (e.g. StrictMode's double invoke) recomputes the same
// thing.
lastBlockRef.current = getBlockFromPos(props.getPos, doc);
} catch {
// Expected and self-correcting, so deliberately silent: ProseMirror
// re-renders the node view with a usable position immediately after, and
// there is nothing a consumer could do about it in the meantime.
}

return lastBlockRef.current;
}
2 changes: 1 addition & 1 deletion packages/server-util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"dependencies": {
"@blocknote/core": "workspace:^",
"@blocknote/react": "workspace:^",
"@tiptap/pm": "^3.13.0",
"@tiptap/pm": "^3.29.2",
"jsdom": "^25.0.1",
"yjs": "^13.6.27"
},
Expand Down
Loading
Loading