TypeCellOS / TypeCellOS/BlockNote
When collaboration is enabled, headlines on the first line are broken when the editor is opened again
Personne n'a encore pris cette issue.
- Langage dominant
- TypeScript
- Étoiles
- 10.2k
- Forks
- 772
- Merge moyen
- 3 j 11 h
- PR mergées (30 j)
- 17
Description
What’s broken?
https://github.com/user-attachments/assets/a6db7b33-24fd-4289-8b24-ea41aadd56b3
First of: I found a workaround for this on our side, so this is not a pressing problem!
I noticed this weird behaviour was linked to the headline being in a div with data-id="initialBlockId". When this was missing, the headline would render correctly right from the start. I let claude chew on this problem for a bit and here is what it came up with:
Summary
When a BlockNote editor is created with Yjs collaboration enabled (no initialContent) and
the underlying document's first block is a heading, that heading renders at the wrong
font-size on the very first paint — it looks like bold body text instead of a heading — and only
snaps to the correct size after any subsequent transaction (e.g. the selection change from
clicking anywhere in the document). This affects only the block occupying the position of
BlockNote's internal Yjs-bootstrap placeholder (in practice: the first block of the document),
and only on the very first render after the editor mounts. It reproduces reliably with a plain
@blocknote/core/@blocknote/react + Hocuspocus/Yjs setup — no custom extensions or blocks are
involved.
Reproduced against @blocknote/core/@blocknote/react 0.51.3/0.51.4. Traced through to
BlockNote's main branch (current head as of this report, latest release v0.54.0,
2026-08-13) — the relevant code is unchanged there, so this is not yet fixed.
Root cause
This requires four pieces to line up in one synchronous sequence, all specific to the
collaboration bootstrap path:
1. The editor always seeds a placeholder doc first, even when Yjs content is already synced.
packages/core/src/editor/BlockNoteEditor.ts (~line 540-545):
const initialContent =
newOptions.initialContent ||
(collaborationEnabled
? [{ type: "paragraph", id: "initialBlockId" }]
: [{ type: "paragraph", id: UniqueID.options.generateID() }]);
Whenever collaboration is set and no initialContent is given, the editor's ProseMirror doc
always starts as a single empty paragraph with id "initialBlockId" — unconditionally, even if
the Yjs document already has real, synced content ready to go.
2. y-prosemirror immediately discards that whole placeholder in one wholesale replace.
y-prosemirror's ySyncPlugin view() initializer calls _forceRerender()
(src/plugins/sync-plugin.js, ~lines 190-196 and 433-465), which builds the entire document
fresh from the Y.XmlFragment and dispatches a single
tr.replace(0, view.state.doc.content.size, realContentFromYjs). This happens synchronously
during editor mount, before anything is painted to the screen — the placeholder and the real
first block are never reconciled node-by-node; the whole doc is thrown away and replaced.
3. The real heading keeps the placeholder's literal id — confirmed live, not just assumed.
We originally assumed the id-repair plugin below would always reassign a fresh random id to
real content (since a real, non-empty document should fail the "is this still the pristine
placeholder" check). Live DOM inspection while building an automated regression spec for this bug
disproved that: the assumption only applies to UniqueID's handling of a node whose id arrives
as null — which is only true when y-prosemirror synthesizes a genuinely brand-new, empty
document (no blockContainer has ever been persisted with a real id yet). Once a user's very
first edit converts that placeholder into a heading in place (e.g. by typing # ), ProseMirror
does not change the node's identity — the block keeps its id, literally the string
"initialBlockId", and that id is what gets persisted. Every later reload simply reconstructs
that already-persisted id faithfully from the Y.XmlFragment; the id === null / generateID()
path in UniqueID's appendTransaction
(packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts, ~lines 171-211, shown
below for reference) is simply never exercised again for that block:
if (id === null) {
const initialDoc = oldState.doc.type.createAndFill()!.content;
const wasInitial = oldState.doc.content.findDiffStart(initialDoc) === null;
if (wasInitial) {
const jsonNode = JSON.parse(JSON.stringify(newState.doc.toJSON()));
jsonNode.content[0].content[0].attrs.id = "initialBlockId";
if (JSON.stringify(jsonNode.content) === JSON.stringify(initialDoc.toJSON())) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: "initialBlockId" });
return;
}
}
tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: generateID() });
return;
}
So on every subsequent reload, the real heading block's blockContainer wrapper carries
data-id="initialBlockId" — the exact same id the placeholder had. Confirmed via a live capture
right after a fresh reload, before any click:
<div class="bn-block-outer" data-node-type="blockOuter" data-id="initialBlockId"
data-prev-index="none" data-prev-level="none" data-prev-type="paragraph"
data-prev-depth="1" data-prev-depth-change="none">
<div class="bn-block" data-node-type="blockContainer" data-id="initialBlockId">
<div class="bn-block-content" data-content-type="heading">...
4. That id collision is exactly what breaks the CSS.
PreviousBlockTypeExtension (packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts)
exists purely to let CSS transitions survive attribute changes that would otherwise force
ProseMirror to fully recreate a node's DOM (per its own doc-comment). It matches "the old
version" and "the new version" of a block by id equality:
const oldNodesById = new Map(oldNodes.map((node) => [node.node.attrs.id, node]));
for (const node of newNodes) {
const oldNode = oldNodesById.get(node.node.attrs.id); // id-keyed match
...
}
Because the outgoing placeholder (a paragraph) and the incoming real content (a heading)
share the exact same id "initialBlockId", this plugin treats the wholesale bootstrap swap as if
it were "the same block, in-place, just changing type from paragraph to heading" — exactly the
case it's designed to smooth over with a CSS transition — and sets data-prev-type="paragraph"
on the new node accordingly. Block.css (~lines 133-188) then gates the heading's font-size
entirely on this plugin's output, with exactly two cases:
.bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="heading"] {
font-size: var(--level); font-weight: bold;
}
.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content {
font-size: var(--prev-level); font-weight: bold;
}
data-prev-type="paragraph" satisfies neither selector (it's present, so the first rule's
:not([data-prev-type]) fails; its value isn't "heading", so the second rule fails too) — so
font-weight: bold still applies (the browser's native <h1>-<h6> default, unrelated to either
rule) but the font-size: var(--level)) override does not, leaving the heading at inherited
body-text size. Confirmed live: 14px before a click, jumping to 42px after. The very next real
transaction (e.g. the selection change from a click) causes PreviousBlockTypeExtension to
recompute updatedBlocks from a state where nothing changed, clearing the stale decoration, and
the correct rule takes over immediately — this is the observed "any click fixes it."
This also explains why the bug is specific to a document's first block: "initialBlockId" is a
sentinel that can only ever be assigned to the one block that was live in the editor at the very
moment a fresh, never-before-synced Yjs document first got created, and it stays attached to
whatever that block became (here: a heading) for as long as that block's node identity survives —
i.e. indefinitely across reloads, until that specific block is later deleted outright.
were not able to attach a live debugger/DOM inspector without the click itself becoming the
"fix" partway through the investigation (this is what makes the bug hard to inspect: any
interaction used to observe it also repairs it), so we're reporting the mechanism as far as
static analysis confirms it and flagging this last link as needing a maintainer's or a live,
non-interactive DOM-capture's confirmation (e.g. a Puppeteer/CDP snapshot taken immediately after
load, before any synthetic input event).
Related, but distinct, existing work
- PR #2153 (
fix(unique-id): do not attempt to append to y-sync plugin transactions, merged)
fixes a selection-restoration desync from this same bootstrap sequence (the
createAndFillmonkey-patch quoted implicitly above is that fix) — it does not address this
rendering symptom. - PR #2981 (
perf(core): resolve block changes from changed range only, merged 2026-08-20,
not yet in a release) fixesPreviousBlockTypeExtensionmissing attribute-only changes
(e.g. a heading'slevelchanging with no content edit). This bug is a type change
(implicit paragraph → heading) combined with a changed id, which is a different code path
from what that PR touches —oldNodesById.get(newId)still can't find a match when the id
itself changed, irrespective of that fix.
Suggested fix direction (for maintainers to evaluate)
Options that would address this at the source, roughly in order of how surgical they are:
- Have
PreviousBlockTypeExtension(orUniqueID) recognize the collaboration-bootstrap
transaction specifically (it's already special-cased inUniqueIDvia thewasInitial
check) and skip/suppress transition-tracking decorations for it entirely, since there is no
real "previous" state worth animating from for a block that's only existed as an empty
placeholder. - Ensure
_forceRerender()'s replacement transaction andUniqueID's id-repair transaction are
coalesced (or ordered) such thatPreviousBlockTypeExtensiononly ever observes the
final old-state → new-state transition once, rather than any possible intermediate state. - Add a third CSS rule (or equivalent JS-side normalization) for "no matching old block was
found and this is the initial bootstrap" that falls back to the plain, no-transition
[data-content-type="heading"][data-level="N"]sizing rather than requiring
:not([data-prev-type]).
What did you expect to happen?
The headline should be rendered correctly right from the start when the editor is opened again (not only after the first user interaction).
Steps to reproduce
- Create a
BlockNoteEditorwithcollaboration: { provider, fragment, user }and no
initialContent, backed by a Yjs document (e.g. viay-websocket/Hocuspocus) that already
has synced, persisted content whose first block is a heading. - Load the page fresh (i.e. the client has to sync the document from the provider, or the
provider is alreadysyncedby the time the editor mounts — both trigger it). - Observe the heading on first paint: it renders bold, but at the same font-size as body/
paragraph text, not as a heading. - Click anywhere inside the editor (or open devtools and inspect the heading element). The
heading immediately snaps to the correct, full heading size.
BlockNote version
Reproduced against @blocknote/core/@blocknote/react 0.51.3/0.51.4. Traced through to BlockNote's main branch (current head as of this report, latest release v0.54.0, 2026-08-13)
Environment
Chrome 151.0, Firefox 153.0.3
Additional context
No response
Contribution
- I'd be interested in contributing a fix for this issue
Sponsor
- I'm a sponsor and would appreciate if you could look into this sooner than later 💖
Guide de contribution
Ouvrir le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Piste de recherche
Reproduce the collaboration case with a persisted document whose first block is a heading, then read packages/core/src/editor/BlockNoteEditor.ts, packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts, packages/core/src/extensions/UniqueID/UniqueID.ts, and Block.css. Confirm the first paint has incorrect heading sizing and that interaction repairs it; done means the heading renders at the correct size immediately after reload without interaction.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- react, typescript
- Domaine
- frontend, web-dev
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- Active
- Clarté
- Plutôt claire
- Accessibilité débutants
- 48/100