Hacktoberfest 2026:維護者為十月標記出來的 issue,仍然開放、適合新手。 瀏覽 Hacktoberfest issue

Custom block React state silently resets when ProseMirror recreates the node view (external DOM writes read as content changes)

未關閉
#3,150 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視

維護者通常 2 天內回覆

還沒有人認領這個 Issue。

評估

難度
5/5
預估耗時
一週以上
新手友好度
25/100
Issue 類型
缺陷
描述清晰度
需要釐清
活躍度
活躍
技術堆疊
react, typescript
領域
frontend

研究方向

No repository file or test is named. Start with the framework-agnostic replay pattern, then trace ProseMirror's DOMObserver.flush/readDOMChange path and the ViewTreeUpdater/NodeViewDesc reuse decision. Done requires an agreed upstream fix or API, plus a regression test showing a custom block keeps its React state after the triggering DOM or selection changes.

由索引模型根據 Issue 內容生成。

描述

Upstream BlockNote issue draft: PM node-view recreation discards React state in custom blocks

Reported from a production OSS editor (Windlass) built on BlockNote 0.55.

Proposed title

Custom block React state silently resets/unmounts when ProseMirror recreates the node view (DOMObserver treats external DOM writes as content changes)

Environment

  • @blocknote/core / @blocknote/react / @blocknote/shadcn: 0.55.0
  • react / react-dom: 19.3.0
  • yjs: 13.6.33, y-prosemirror: 1.3.7
  • ProseMirror (resolved): prosemirror-view 1.42.6, prosemirror-state 1.4.4, prosemirror-model 1.25.12, prosemirror-transform 1.12.2
  • Radix stack under the block's chrome: @radix-ui/react-dialog 1.1.23, vaul 1.1.2
  • Browser: Chromium (Chrome 141-class); reproduced in dev and in jsdom-based replay tests.

Minimal repro

  1. Render a BlockNote doc containing a custom block whose React component holds
    useState (e.g. an open/closed flag for an in-block dialog, or any form draft).
  2. Open the dialog / focus a form field inside the custom block.
  3. Trigger either of the two recreation classes below (easiest: open a Radix modal
    dialog anywhere in the app so react-remove-scroll writes aria-hidden onto
    elements outside the dialog, including the editor's node-view DOM).
  4. Observe: within one flush cycle (~25 ms in our traces) the custom block's React
    subtree is destroyed and recreated — every useState resets. An open sheet closes
    itself; a half-filled form resets. The editor content is unchanged; only the block's
    local UI state is lost.

Forensics (what we measured)

  • The loop: PM's DOMObserver.flush() → readDOMChange sees DOM mutations inside
    the node view's DOM, fails to attribute them to a known transaction, and the view's
    update cycle ends in ViewTreeUpdater/NodeViewDesc deciding the container
    "fails matchesNode" and recreates the node view. React then unmounts the
    custom block's subtree and mounts a fresh one — all local state gone.
  • Trigger class 1 — selection transactions: every selection change moves widget
    decorations between DOM containers. Our block's widget DOM lands in a new parent;
    if the move races the observer flush, the container diff is misread as a content
    change and the block is recreated.
  • Trigger class 2 — Radix modal chrome: Radix (and vaul's drawer, built on it)
    writes aria-hidden (via react-remove-scroll) onto elements outside the dialog —
    including BlockNote's node-view DOM inside the editor contenteditable. PM's
    DOMObserver does not ignore those attribute mutations, so the containers get marked
    NODE_DIRTY and the next updateState recreates the affected blocks.
  • Both classes share the root cause: PM cannot distinguish "DOM changed by the app
    around my node views" from "content changed", and its fallback is destruction.

    Any React state living inside the node view is collateral damage.

Our fixes (shipped, tested)

  1. Stop the modal-chrome trigger: render the block's sheets/dialogs with
    modal={false} (Radix) so react-remove-scroll never writes aria-hidden into
    the editor DOM. This removes one of the two recreation triggers entirely.
  2. Survive the remaining trigger (state hoisting): move the block's UI open-state
    (and any state that must survive) OUT of the block's React subtree:
    • a module-level store keyed by block id, read through useSyncExternalStore;
    • the sheet rendered through a STABLE module-level React createRoot on a detached
      host div outside the editor DOM, so a recreation of the owning block cannot
      unmount it;
    • handler indirection ("bridge") so the stable-root-rendered sheet always calls the
      current instance's callbacks after a recreation re-syncs them.
      Regression tests replay the destruction (unmount + fresh mount of the block while
      the sheet is open) and assert the sheet stays open, keeps its draft, and completes
      its flow through the recreated instance.

Fix 2 works but is a lot of ceremony for "a checkbox that survives a repaint"; we'd
rather not keep building it per block.

Proposed upstream directions (pick one, or propose better)

  1. Node-view reuse stability guarantee: make DOMObserver ignore attribute-only
    mutations that PM itself did not observe as content (or at least aria-hidden /
    data-* writes by known UI libraries), and make ViewTreeUpdater prefer updating
    an existing node view over recreate when only the container's position moved.
    Concretely: treat container moves caused by widget decoration redraws as non-content
    events.
  2. Official external-store escape hatch: a first-class API for custom blocks to
    declare state that must survive node-view recreation (e.g. a useBlockStableState
    hook or a per-block "state root" the host mounts outside the node view), so apps
    don't hand-roll module-level stores + stable roots + handler bridges.
  3. Documentation escape: if neither is feasible short-term, document the hazard
    (state inside custom blocks is ephemeral under recreation; modal chrome writes into
    the editor DOM are a trigger) prominently, with the modal={false} recommendation
    for any overlay mounted near editor content.

Reproduction pointers for maintainers

  • Our replay pattern is framework-agnostic: mount a custom block with a controlled
    Radix/vaul sheet inside it, open the sheet, dispatch a selection transaction or open
    a second modal elsewhere, and watch the block's React subtree remount (React DevTools
    or a mount counter shows the reset).
  • Happy to provide a standalone repro repo if useful.
主要語言
TypeScript
星號
10.3k
分支
778
平均合併
2 天 12 小時
30 天內合併 PR
27

環境準備

從這裡開始

  1. 先讀完整個 Issue,再讀專案的貢獻指南。
  2. 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
  3. Fork 儲存庫,在一個分支上完成修改。
  4. 送出 Pull Request,並在描述裡引用這個 Issue 編號。

TypeCellOS/BlockNote 的其他 Issue

查看 TypeCellOS/BlockNote 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。