Skip to content

fix: stop stale node view positions crashing the editor - #2938

Merged
nperez0111 merged 4 commits into
mainfrom
feat/upgrade-tiptap-version
Aug 4, 2026
Merged

fix: stop stale node view positions crashing the editor#2938
nperez0111 merged 4 commits into
mainfrom
feat/upgrade-tiptap-version

Conversation

@nperez0111

@nperez0111 nperez0111 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Upgrades every @tiptap/* dependency from ^3.13.0 (resolving to 3.22.4) to ^3.29.2, and stops a node view's stale getPos() from throwing out of the render path and tearing down the consumer's React tree.

Fixes #2937

Rationale

getPos() is derived from ProseMirror's view-desc tree, but EditorView.updateStateInner assigns the new state before it reconciles that tree — so anything rendering part-way through reconciliation reads a position that no longer lines up with view.state.doc, and resolving it threw RangeError: Position N out of range (or Node should be a bnBlock, but is instead: doc when the position was in range but pointed at the wrong node). Upstream considers this TipTap's problem (ProseMirror/prosemirror#1532) and TipTap considers flushSync unavoidable, so we recover on our side instead; TipTap's own partial fix (ueberdosis/tiptap#8106, in 3.28.0) only catches the TypeError thrown inside posBeforeChild and doesn't cover either shape above.

Changes

  • Bumps all @tiptap/* ranges to ^3.29.2, including the pnpm-workspace.yaml overrides that actually pin @tiptap/core and @tiptap/pm, plus our direct prosemirror-* dependencies to match what @tiptap/pm@3.29.2 declares (without which prosemirror-model and prosemirror-view each resolved to two copies and broke type-checking).
  • Adds core getBlockFromNodeView, used by addNodeView, which falls back to building the block from the node alone — correct type/props/content, with a freshly generated id that belongs to no block, since this is only reached once a re-entrant dispatch has superseded the document.
  • Adds react useNodeViewBlock, used by createReactBlockSpec, which falls back to the last block it resolved, seeded from the one core resolved at construction.

Impact

No API change: block stays a plain Block for consumers of createReactBlockSpec, never undefined. Both fallbacks are silent and transient — ProseMirror rebuilds the node view against the current document immediately after, so a stale frame is invisible where a throw is not. Neither fallback is reached during normal editing (0 hits across the 828-test e2e suite), so there is no cost on the happy path.

Testing

  • tests/src/unit/react/staleNodeViewPos.test.tsx reproduces the crash end-to-end by dispatching re-entrantly from a node view's mount effect. Verified as a real regression test: bypassing the fallback makes both recovery cases fail with Node should be a bnBlock, but is instead: doc, while the normal-case test still passes.
  • internal.test.ts and useNodeViewBlock.test.tsx cover each branch, including that the standalone block's synthetic id never collides with a real one.
  • Full suite green on 3.29.2: lint, build, 882 unit tests, and all six E2E shards in CI. (Four E2E failures I saw locally turned out to be environment-specific and do not reproduce in CI.)

Checklist

  • Code follows the project's coding standards.
  • Unit tests covering the new feature have been added.
  • All existing tests pass.
  • The documentation has been updated to reflect the new feature

Additional Notes

The reproduction only fires because it sets isEditorContentInitialized = true by hand. BlockNote never sets it, having replaced TipTap's PureEditorContent with its own mounting, so on @tiptap/react >= 3.22 it takes the deferred-microtask path and avoids the mount-time flushSync by accident rather than by design — worth revisiting alongside EditorContent.tsx, which still notifies portal subscribers synchronously rather than adopting upstream's 3.28.0 batching.

Summary by CodeRabbit

  • Bug Fixes

    • Improved React node view stability when document changes cause temporarily stale or invalid positions.
    • Preserved the last valid block during position resolution failures, helping prevent rendering errors and keeping the editor usable.
  • New Features

    • Added a public hook for resolving the current block associated with a React node view.
  • Chores

    • Updated Tiptap and ProseMirror packages to newer versions.
  • Tests

    • Added coverage for stale positions, fallback recovery, and block resolution behavior.

Bumps every `@tiptap/*` range from `^3.13.0` (resolving to 3.22.4) to
`^3.29.2`, including the `pnpm-workspace.yaml` overrides that actually
pin `@tiptap/core` and `@tiptap/pm`.

Also bumps our direct `prosemirror-*` dependencies to match the ranges
`@tiptap/pm@3.29.2` declares. Without this, `prosemirror-model` and
`prosemirror-view` each resolved to two copies, which broke type-checking
across the packages that import them directly.
`getPos()` is derived from ProseMirror's view-desc tree, but
`updateStateInner` assigns the new state before it reconciles that tree.
Anything rendering part-way through reconciliation - a re-entrant dispatch
from a node view effect, TipTap's `flushSync` while mounting a node view -
therefore reads a position that no longer lines up with `view.state.doc`.
Resolving it threw `RangeError: Position N out of range`, or
`Node should be a bnBlock, but is instead: doc` when the position was in
range but pointed at the wrong node. Under React 19 that 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 recover on our side:

- core `getBlockFromNodeView`: falls back to building the block from the
  node alone. Reached only when a re-entrant dispatch superseded the
  document, so the node is gone from it and there is no container to read
  an id from - `type`, `props` and `content` are correct, `id` is freshly
  generated and belongs to no block.
- react `useNodeViewBlock`: falls back to the last block it resolved,
  seeded from the one core resolved at construction.

Both are silent and transient; ProseMirror rebuilds the node view against
the current document immediately after, so a stale frame is invisible
where a throw is not.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Aug 4, 2026 8:09am
blocknote-website Ready Ready Preview Aug 4, 2026 8:09am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: acfa8537-3616-4bad-912f-139e64b9a40d

📥 Commits

Reviewing files that changed from the base of the PR and between 6e0e136 and 1504161.

📒 Files selected for processing (1)
  • packages/core/src/schema/blocks/internal.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/schema/blocks/internal.ts

📝 Walkthrough

Walkthrough

The change updates Tiptap and ProseMirror versions. Core and React node views now recover from stale or invalid positions. Tests cover fallback resolution, block retention, reconciliation, and document changes.

Changes

Node-view block recovery

Layer / File(s) Summary
Core node-view resolution
packages/core/src/schema/blocks/internal.ts, packages/core/src/schema/blocks/createSpec.ts, packages/core/src/schema/blocks/internal.test.ts
getBlockFromNodeView resolves blocks from node views and creates a standalone block when positions are invalid. Core tests cover valid, detached, incorrect, and undefined positions.
React node-view integration
packages/react/src/schema/useNodeViewBlock.ts, packages/react/src/schema/ReactBlockSpec.tsx, packages/react/src/index.ts
React node views use useNodeViewBlock, which retains the last successful block when position resolution fails.
Reconciliation regression coverage
tests/src/unit/react/useNodeViewBlock.test.tsx, tests/src/unit/react/staleNodeViewPos.test.tsx
Tests cover valid resolution, invalid positions, block retention, document shrinking, mount-time transactions, and continued editor operation.
Package version alignment
package.json, pnpm-workspace.yaml, packages/*/package.json, docs/package.json, examples/.../package.json, tests/package.json
Package manifests align Tiptap with version 3.29.2 and update the specified ProseMirror releases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReactBlockSpec
  participant useNodeViewBlock
  participant getBlockFromNodeView
  participant ProseMirrorDoc
  ReactBlockSpec->>useNodeViewBlock: provide node-view props and initial block
  useNodeViewBlock->>getBlockFromNodeView: resolve block from getPos, node, and doc
  getBlockFromNodeView->>ProseMirrorDoc: inspect current node position
  ProseMirrorDoc-->>getBlockFromNodeView: return block or position failure
  getBlockFromNodeView-->>useNodeViewBlock: return resolved or fallback block
  useNodeViewBlock-->>ReactBlockSpec: render with current block
Loading

Possibly related PRs

Poem

A rabbit checks each node-view trail,
Keeps the last good block when positions fail.
Core builds a fallback path,
React avoids a stale-node crash.
Tests hop through every case.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the dependency upgrade and stale node-view recovery, which are the main changes.
Description check ✅ Passed The description covers the rationale, changes, impact, testing, checklist, and additional notes, with only documentation updates left unchecked.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/upgrade-tiptap-version

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@2938

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@2938

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@2938

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@2938

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@2938

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@2938

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@2938

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@2938

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@2938

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@2938

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@2938

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@2938

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@2938

commit: 1504161

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-2938/

Built to branch gh-pages at 2026-08-04 10:00 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/schema/blocks/internal.ts (1)

79-94: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Route the React node-view hook through getBlockFromNodeView.

packages/core/src/schema/blocks/createSpec.ts already uses getBlockFromNodeView, but packages/react/src/schema/useNodeViewBlock.ts still calls getBlockFromPos directly during rendering. A stale getPos() in that render path can throw from nodeToBlock; use the tolerant wrapper with the React node so the fallback construction applies consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/schema/blocks/internal.ts` around lines 79 - 94, Update the
React node-view hook to use getBlockFromNodeView with the React node instead of
calling getBlockFromPos directly during rendering. Preserve the existing
fallback behavior so stale getPos() values do not propagate into nodeToBlock;
use the existing getBlockFromNodeView implementation and symbols in
useNodeViewBlock.
🧹 Nitpick comments (3)
packages/core/src/schema/blocks/internal.test.ts (1)

56-65: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Strengthen the "wrong node" test with content assertions.

This test only checks that getBlockFromNodeView does not throw. The sibling test at Line 32 checks type, props, and content after the fallback fires. Apply the same assertions here to confirm the fallback produces a correct block, not just a non-throwing one, when the resolved position lands on the wrong node.

✅ Proposed stronger assertions
   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();
+    const block = getBlockFromNodeView(() => 0, orphan, doc) as any;
+    expect(block.type).toBe("paragraph");
+    expect(block.content[0].text).toBe("orphaned content");
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/schema/blocks/internal.test.ts` around lines 56 - 65,
Strengthen the “recovers from an in-range position that resolves to the wrong
node” test by capturing the result of getBlockFromNodeView and asserting its
type, props, and content, matching the sibling fallback test. Preserve the
existing setup and verify the fallback returns a correct block rather than only
confirming that no exception is thrown.
tests/src/unit/react/useNodeViewBlock.test.tsx (1)

61-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a StrictMode regression test for the double-invoke claim.

useNodeViewBlock.ts justifies the render-phase ref write partly by noting it tolerates "StrictMode's double invoke". None of the tests here mount the probe inside <React.StrictMode>. Add one test that wraps <Probe /> in <React.StrictMode> and asserts the resolved block is still correct after the double render, to lock in that specific claim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/unit/react/useNodeViewBlock.test.tsx` around lines 61 - 79, Add a
regression test for renderHook’s Probe mounted inside React.StrictMode,
asserting useNodeViewBlock still resolves the expected block after StrictMode’s
double render. Keep the existing non-StrictMode helper behavior unchanged and
target the hook resolution returned by renderHook.
packages/react/src/schema/useNodeViewBlock.ts (1)

42-54: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Verify render-phase ref mutation and avoid silently swallowing unrelated errors.

lastBlockRef.current is written directly in the render body, not in an effect. React's own guidance treats ref writes during render as unsafe unless idempotent for a given render pass, and does not guarantee this is safe if React discards or restarts the render before commit under concurrent scheduling. Confirm that BlockNote's node-view rendering paths never hit that scenario, or document why it cannot.

Separately, the bare catch {} on Line 50 discards every exception from getBlockFromPos, not only the two documented shapes (out-of-range position, wrong node type). A genuine, unrelated bug in props.getPos or doc resolution would also be swallowed silently, with no visibility for debugging.

♻️ Optional: surface unexpected errors without changing the fallback behavior
   try {
     lastBlockRef.current = getBlockFromPos(props.getPos, doc);
-  } catch {
+  } catch (error) {
+    if (process.env.NODE_ENV !== "production") {
+      // eslint-disable-next-line no-console
+      console.debug("useNodeViewBlock: falling back to last known block", error);
+    }
     // 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.
   }

Please confirm with the web whether React guarantees ref writes performed synchronously during a function component's render body are preserved correctly across a render that gets discarded/restarted by a concurrent feature (e.g., a Suspense-triggered restart), or whether that remains formally unsupported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/schema/useNodeViewBlock.ts` around lines 42 - 54, Update
the lastBlockRef/getBlockFromPos handling to avoid relying on unsupported
render-phase ref mutation under concurrent React, or document and verify the
node-view lifecycle invariant that makes this safe. Replace the bare catch with
handling only the documented invalid-position or wrong-node failures, while
rethrowing or surfacing unexpected errors from props.getPos or document
resolution. Preserve the existing last-good-value fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/core/src/schema/blocks/internal.ts`:
- Around line 79-94: Update the React node-view hook to use getBlockFromNodeView
with the React node instead of calling getBlockFromPos directly during
rendering. Preserve the existing fallback behavior so stale getPos() values do
not propagate into nodeToBlock; use the existing getBlockFromNodeView
implementation and symbols in useNodeViewBlock.

---

Nitpick comments:
In `@packages/core/src/schema/blocks/internal.test.ts`:
- Around line 56-65: Strengthen the “recovers from an in-range position that
resolves to the wrong node” test by capturing the result of getBlockFromNodeView
and asserting its type, props, and content, matching the sibling fallback test.
Preserve the existing setup and verify the fallback returns a correct block
rather than only confirming that no exception is thrown.

In `@packages/react/src/schema/useNodeViewBlock.ts`:
- Around line 42-54: Update the lastBlockRef/getBlockFromPos handling to avoid
relying on unsupported render-phase ref mutation under concurrent React, or
document and verify the node-view lifecycle invariant that makes this safe.
Replace the bare catch with handling only the documented invalid-position or
wrong-node failures, while rethrowing or surfacing unexpected errors from
props.getPos or document resolution. Preserve the existing last-good-value
fallback behavior.

In `@tests/src/unit/react/useNodeViewBlock.test.tsx`:
- Around line 61-79: Add a regression test for renderHook’s Probe mounted inside
React.StrictMode, asserting useNodeViewBlock still resolves the expected block
after StrictMode’s double render. Keep the existing non-StrictMode helper
behavior unchanged and target the hook resolution returned by renderHook.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 02d6ae3f-fa70-4ac3-8fa7-6ff9fbbcd4ee

📥 Commits

Reviewing files that changed from the base of the PR and between 831656c and e8c0831.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • docs/package.json
  • examples/08-extensions/01-tiptap-arrow-conversion/package.json
  • package.json
  • packages/core/package.json
  • packages/core/src/schema/blocks/createSpec.ts
  • packages/core/src/schema/blocks/internal.test.ts
  • packages/core/src/schema/blocks/internal.ts
  • packages/react/package.json
  • packages/react/src/index.ts
  • packages/react/src/schema/ReactBlockSpec.tsx
  • packages/react/src/schema/useNodeViewBlock.ts
  • packages/server-util/package.json
  • packages/xl-ai/package.json
  • packages/xl-multi-column/package.json
  • pnpm-workspace.yaml
  • tests/package.json
  • tests/src/unit/react/staleNodeViewPos.test.tsx
  • tests/src/unit/react/useNodeViewBlock.test.tsx

Each test created a `BlockNoteEditor` and never destroyed it, leaking a
ProseMirror `DOMObserver` per test. Its `stop()` schedules a `flush()`
20ms later, which could outlive the test environment and fail the run
with `ReferenceError: document is not defined` - reported against
whichever test file happened to be running at the time.

Destroying the editor nulls `view.docView`, so the late `flush()` hits
its early-return guard.
Building the standalone block inside the `catch` lets it rethrow the
original error directly, dropping the `positionError` local. Behaviour is
unchanged - a `throw` inside a `catch` is not caught by its own `try`, so
an error from `createAndFill`/`nodeToBlock` still propagates as before.
@nperez0111 nperez0111 changed the title Upgrade TipTap to 3.29.2 and stop stale node view positions crashing the editor fix: stop stale node view positions crashing the editor Aug 4, 2026
@nperez0111
nperez0111 merged commit 58d43ff into main Aug 4, 2026
19 checks passed
@nperez0111
nperez0111 deleted the feat/upgrade-tiptap-version branch August 4, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RangeError: Position N out of range — getBlockFromPos resolves an unvalidated getPos() during nodeView mount

1 participant