Repository navigation
[lexical-y][lexical-website] Bug Fix: Keep $initialEditorState out of documents that have content - #9508
Merged
Conversation
zurfyx
requested review from
acywatson,
ivailop7 and
potatowagon
as code owners
October 29, 2026 01:05
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
This was referenced Oct 29, 2026
zurfyx
force-pushed
the
zurfyx/y-initial-state-binding
branch
from
October 29, 2026 05:32
421e0ba to
cda2d59
Compare
This was referenced Oct 29, 2026
This was referenced Oct 29, 2026
Merged
This was referenced Oct 29, 2026
zurfyx
force-pushed
the
zurfyx/y-initial-state-binding
branch
from
October 29, 2026 10:47
cda2d59 to
70830bd
Compare
vjeux
approved these changes
Oct 29, 2026
etrepum
reviewed
Oct 29, 2026
Comment on lines
+112
to
+115
| const update = () => { | ||
| editor.dispatchCommand(CAN_UNDO_COMMAND, um.canUndo()); | ||
| editor.dispatchCommand(CAN_REDO_COMMAND, um.canRedo()); | ||
| }; |
Collaborator
There was a problem hiding this comment.
CAN_UNDO_COMMAND and CAN_REDO_COMMAND were deprecated in favor of HistoryExtension's canUndo/canRedo signals since following the commands easily leads to incoherent state. Probably the right course of action here is to move those signals up to a HistoryStateExtension that both YCollabExtension and HistoryExtension both write to.
zurfyx
force-pushed
the
zurfyx/y-undo-member-closer
branch
from
October 29, 2026 18:12
e3bc5a3 to
603812b
Compare
… documents that have content ## Description An editor built with `$initialEditorState` (a template) and `YCollabExtension` wrote the template into a `Y.Doc` that already had content: an editor built again on the same doc (React StrictMode, or dispose and build again), a doc loaded from a local cache before the editor, or `synced: true`. A template that replaces the root wiped everyone's content; one that appends added a copy on every mount. `YCollabExtension.register` started the binding, which reads a loaded doc and goes active at once. `InitialStateExtension` applies `$initialEditorState` later, in `afterRegistration`, so the binding wrote it as an edit, and `initialize()` then did nothing. `synced: true` initialized in `register` too: on a new doc it seeded an empty root and then wrote the template as an edit, so two clients opening an empty room got two copies, and an undo step. The binding now starts in `afterRegistration`, after `InitialStateExtension`'s, which becomes an explicit dependency so the order holds by construction. The extension API documents `register` as running before the editor's initial state is set and `afterRegistration` as the place for code that must run after it. What the editor holds when the binding starts is its initial state: a doc with content replaces it and nothing is written, and only `initialize()` writes it, into an empty doc, through the content-hashed seed, so clients initializing the same content write one copy, now also with `synced: true`. This covers every form of `$initialEditorState` (a function, JSON, an `EditorState`), the default empty paragraph, and content any extension writes in `register`. Also: - `initialize()` called before the binding has started (from an extension's `register`, or a listener that runs while it starts, for example on a provider that has already synced) runs once it has, after the initial state is applied and the binding has checked the editor's configuration, instead of seeding an empty root at once. - `initialize()` commits a pending update before it looks at the document: a remote change that a listener of that commit applies is read (or, for an older layout's root, refused), not written over. - `HMRExtension` is declared as a peer dependency (ordering only). Its development reload restores the previous editor's state in `afterRegistration`; listed after `YCollabExtension`, that restore overwrote the shared document and deleted a peer's edit. The other option was to let the binding take over `$initialEditorState` (suppress `InitialStateExtension`'s write and pass the initializer to `initialize()`). That needs new API on `InitialStateExtension`, covers only that one source of initial content, and leaves the editor blank until the first sync. Starting the binding later applies its existing rule for the content it finds when it starts, with no new API. An extension that depends on `YCollabExtension` and changes content in its own `afterRegistration` still makes an edit: it runs after the binding starts, and such a change cannot be told apart from an intentional edit made right after the build. The docs say so. Unchanged: `initialize()` on an empty root that held items or attributes before, or that sits inside another shared type, still writes under the client's own id (the docs list it under Limits), so clients doing that at once still write a copy each. Behavior changes (the first three are in the changeset's `Verify:`): - Another extension's `register` sees the binding before it starts (`status` is `'pending'`, `undoManager` is `null`); on dispose, the binding stops before the other extensions' `register` cleanups run. - `YBinding.initialize()` called before `start()` runs when the binding starts, instead of writing at once. - An editor opened on an initialized document whose root is empty shows it empty instead of writing an empty paragraph as an edit; typing creates the paragraph. - The binding's own node transforms are registered after those that other extensions register in `register`, as they already were for extensions listed before it. ## Changeset `.changeset/lexical-y-initial-state.md`: `@lexical/y` patch, `[fix]`, with a `Verify:` paragraph. ## Test plan New `packages/lexical-y/src/__tests__/unit/regressions/InitialState.test.tsx` (finding S7-23, 43 tests): every form of `$initialEditorState` on a remount, a doc loaded from a cache, `synced: true`, and two editors bound at once; a doc synced after the build; content written in `register`; a state `HMRExtension` restores; five remounts with edits in between; React StrictMode with `LexicalExtensionComposer` mounted five times; a doc whose root is empty; `initialize()` called while extensions register or from a listener while the binding starts (loaded doc, empty doc with two clients, `synced` false and true, an option the binding cannot write with); a remote change, or an older layout's root, applied during `initialize()`'s commit; an undo step made during initialization reaching `CAN_UNDO_COMMAND` listeners; two clients on an empty doc with `synced` false and true; a `seedYState` doc opened by a client with a template; a `seedYState` seed of the template merging with a client's initialization. `pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/InitialState.test.tsx` ### Before On main (92d6af7): ``` × a function that appends, remount on the same document: the editor shows the document and writes nothing × a function that appends, a document loaded from a cache before the editor: the editor shows the document and writes nothing × a function that appends, a loaded document with synced: true: the editor shows the document and writes nothing × a function that appends, two editors on the same document at once: the editor shows the document and writes nothing × a function that replaces, remount on the same document: the editor shows the document and writes nothing × a function that replaces, a document loaded from a cache before the editor: the editor shows the document and writes nothing × a function that replaces, a loaded document with synced: true: the editor shows the document and writes nothing × a function that replaces, two editors on the same document at once: the editor shows the document and writes nothing × JSON, remount on the same document: the editor shows the document and writes nothing × JSON, a document loaded from a cache before the editor: the editor shows the document and writes nothing × JSON, a loaded document with synced: true: the editor shows the document and writes nothing × JSON, two editors on the same document at once: the editor shows the document and writes nothing × an EditorState, remount on the same document: the editor shows the document and writes nothing × an EditorState, a document loaded from a cache before the editor: the editor shows the document and writes nothing × an EditorState, a loaded document with synced: true: the editor shows the document and writes nothing × an EditorState, two editors on the same document at once: the editor shows the document and writes nothing ✓ the document synced after the editor is built: the editor shows it and writes nothing × initialize() called while extensions register (a provider that has synced): the editor shows the document and writes nothing × a remote change applied while initialize() commits a pending update is read, not written × content of an older layout root that arrives while initialize() commits a pending update is refused, not written next to × content an extension writes while it registers is initial state too × a state HMRExtension restores is initial state too, wherever it is listed × remounted five times: nothing is duplicated or lost, and edits made in between are written × React StrictMode: LexicalExtensionComposer mounted five times on a loaded document × a document seeded with seedYState, synced before the editor is built: the editor shows the seed and writes nothing ✓ a document seeded with seedYState, synced after the editor is built: the editor shows the seed and writes nothing × a document whose root is empty: the editor shows it empty and writes nothing until an edit ✓ a function that appends, synced: false: two clients that open an empty document at once write it once × a function that appends, synced: true: two clients that open an empty document at once write it once ✓ a function that replaces, synced: false: two clients that open an empty document at once write it once × a function that replaces, synced: true: two clients that open an empty document at once write it once ✓ JSON, synced: false: two clients that open an empty document at once write it once × JSON, synced: true: two clients that open an empty document at once write it once ✓ an EditorState, synced: false: two clients that open an empty document at once write it once × an EditorState, synced: true: two clients that open an empty document at once write it once × initialize() called from an editable listener while the binding starts (a provider that syncs at once), synced: false: the initial state is written once, as a seed × initialize() called from an editable listener while the binding starts (a provider that syncs at once), synced: true: the initial state is written once, as a seed × initialize() called while extensions register (a provider that syncs at once), synced: false: the initial state is written once, as a seed × initialize() called while extensions register (a provider that syncs at once), synced: true: the initial state is written once, as a seed × an edit a listener makes in reply to the initialization is announced to CAN_UNDO_COMMAND listeners ✓ initialize() called while extensions register, with an option the binding cannot write with: read only, nothing written ✓ synced: false: a client that initializes an empty document with its template, and a seedYState seed of that template, merge into one copy × synced: true: a client that initializes an empty document with its template, and a seedYState seed of that template, merge into one copy AssertionError: expected 'paragraph("real content") / paragraph…' to be 'paragraph("real content")' // Object.is equality Expected: "paragraph("real content")" Received: "paragraph("real content") / paragraph("Untitled")" AssertionError: expected 'paragraph("Untitled")' to be 'paragraph("real content")' // Object.is equality Expected: "paragraph("real content")" Received: "paragraph("Untitled")" AssertionError: expected 'paragraph("Untitled") / paragraph("Un…' to be 'paragraph("Untitled")' // Object.is equality Expected: "paragraph("Untitled")" Received: "paragraph("Untitled") / paragraph("Untitled")" Tests 35 failed | 8 passed (43) ``` ### After ``` ✓ |unit| packages/lexical-y/src/__tests__/unit/regressions/InitialState.test.tsx (43 tests) 1026ms Tests 43 passed (43) ``` The whole `@lexical/y` unit suite, `pnpm exec vitest run --project unit packages/lexical-y`: ``` Test Files 149 passed | 2 skipped (151) Tests 4956 passed | 16 expected fail | 2 skipped (4974) ``` `pnpm run ci-check` exits 0 (TypeScript, Flow `Found 0 errors`, Prettier, ESLint, `Changeset check passed: 6 pending changeset(s) valid.`), and `scripts/__tests__/unit/treeShakingSource.test.ts` passes for `@lexical/y`. The collab Y e2e specs, `pnpm run test-e2e-collab-y-chromium` with `pnpm run start:y`: `25 passed`, `6 flaky`. The flaky ones failed their first attempt on a page or frame load timeout and passed on retry; main on the same machine gives the same first-attempt timeouts (`27 passed`, `4 flaky`). `CollabYSync.spec.mjs` with `--repeat-each=3 --retries=0`: `33 passed`. Firefox and WebKit were not run.
zurfyx
force-pushed
the
zurfyx/y-initial-state-binding
branch
from
October 29, 2026 18:30
70830bd to
de27a67
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Stack, part 5 of 10. Based on #9505 (
zurfyx/y-undo-member-closer). Review only this PR's own commits.The
@lexical/yfixes touch the same writer, cursor, reader and binding internals, so they are one linear stack: each PR is based on the one below it, each conflict is resolved once, in the PR whose change causes it, and every CI run tests the real combination.Merge order: bottom first, #9509 → #9523 → #9522 → #9505 → #9508 → #9502 → #9504 → #9527 → #9507 → #9510. With squash merges, rebase the next PR onto
mainbefore merging it (git rebase --onto main <old base branch>).The stack
zurfyx/update-error-rollbackmainzurfyx/write-before-unread-changezurfyx/y-keep-chars-at-format-boundaryzurfyx/y-undo-member-closerzurfyx/y-initial-state-bindingzurfyx/y-reorder-intentzurfyx/y-preserve-unknown-attrszurfyx/y-move-keeps-unknown-fieldszurfyx/y-composition-holdbackzurfyx/y-incremental-read-marksFollow-ups for after the stack (not in it):
Unread(changes Yjs applied that the binding has not read) and [lexical-playground][lexical-website][lexical-y] Bug Fix: Hold back remote changes to the line being composed until the composition ends #9507'sHidden(changes held back while a line is composed) answer one question, and call sites combine them by hand. One owner module (isGone,isShown,attrOf/attrsOfand the write primitives), an item-level held overlay instead of thewas/nowmaps, and a lint or grep test that allows raw.deleted/getAttr(s)only in the Reader and in named provenance scans. [lexical-website][lexical-y] Bug Fix: Stop a format, comment or link from spreading to the end of the document after an undo or delete races an edit inside it #9505's run-opener scans (liveItemBefore,runOpener,restatedEnd) still read raw deletedness and move there too.$groupReference,$priorGroupItem, the nearest neighbour's marker before the write,priorGroupMarker), with separate predecessor stores for markers and nodes. One stored-predecessor lookup would replace it.Description
An editor built with
$initialEditorState(a template) andYCollabExtensionwrote the template into a
Y.Docthat already had content: an editor builtagain on the same doc (React StrictMode, or dispose and build again), a doc
loaded from a local cache before the editor, or
synced: true. A templatethat replaces the root wiped everyone's content; one that appends added a
copy on every mount.
YCollabExtension.registerstarted the binding, which reads a loaded doc andgoes active at once.
InitialStateExtensionapplies$initialEditorStatelater, in
afterRegistration, so the binding wrote it as an edit, andinitialize()then did nothing.synced: trueinitialized inregistertoo:on a new doc it seeded an empty root and then wrote the template as an edit,
so two clients opening an empty room got two copies, and an undo step.
The binding now starts in
afterRegistration, afterInitialStateExtension's,which becomes an explicit dependency so the order holds by construction. The
extension API documents
registeras running before the editor's initialstate is set and
afterRegistrationas the place for code that must runafter it. What the editor holds when the binding starts is its initial
state: a doc with content replaces it and nothing is written, and only
initialize()writes it, into an empty doc, through the content-hashed seed,so clients initializing the same content write one copy, now also with
synced: true. This covers every form of$initialEditorState(a function,JSON, an
EditorState), the default empty paragraph, and content anyextension writes in
register.Also:
initialize()called before the binding has started (from an extension'sregister, or a listener that runs while it starts, for example on aprovider that has already synced) runs once it has, after the initial
state is applied and the binding has checked the editor's configuration,
instead of seeding an empty root at once.
initialize()commits a pending update before it looks at the document: aremote change that a listener of that commit applies is read (or, for an
older layout's root, refused), not written over.
HMRExtensionis declared as a peer dependency (ordering only). Itsdevelopment reload restores the previous editor's state in
afterRegistration; listed afterYCollabExtension, that restoreoverwrote the shared document and deleted a peer's edit.
The other option was to let the binding take over
$initialEditorState(suppress
InitialStateExtension's write and pass the initializer toinitialize()). That needs new API onInitialStateExtension, covers onlythat one source of initial content, and leaves the editor blank until the
first sync. Starting the binding later applies its existing rule for the
content it finds when it starts, with no new API.
An extension that depends on
YCollabExtensionand changes content in itsown
afterRegistrationstill makes an edit: it runs after the bindingstarts, and such a change cannot be told apart from an intentional edit
made right after the build. The docs say so.
Unchanged:
initialize()on an empty root that held items or attributesbefore, or that sits inside another shared type, still writes under the
client's own id (the docs list it under Limits), so clients doing that at
once still write a copy each.
Behavior changes (the first three are in the changeset's
Verify:):registersees the binding before it starts(
statusis'pending',undoManagerisnull); on dispose, the bindingstops before the other extensions'
registercleanups run.YBinding.initialize()called beforestart()runs when the bindingstarts, instead of writing at once.
empty instead of writing an empty paragraph as an edit; typing creates the
paragraph.
extensions register in
register, as they already were for extensionslisted before it.
Restack (October 29)
Rebased onto #9505 with no conflicts. #9510, higher in the stack, brands bound documents (
createYDoc) and migrates this PR'sregressions/InitialState.test.tsxto it in the same commit; this PR keepsnew Y.Doc(), which is what binds below #9510.Changeset
.changeset/lexical-y-initial-state.md:@lexical/ypatch,[fix], with aVerify:paragraph.Test plan
New
packages/lexical-y/src/__tests__/unit/regressions/InitialState.test.tsx(finding S7-23, 43 tests): every form of
$initialEditorStateon a remount,a doc loaded from a cache,
synced: true, and two editors bound at once; adoc synced after the build; content written in
register; a stateHMRExtensionrestores; five remounts with edits in between; ReactStrictMode with
LexicalExtensionComposermounted five times; a doc whoseroot is empty;
initialize()called while extensions register or from alistener while the binding starts (loaded doc, empty doc with two clients,
syncedfalse and true, an option the binding cannot write with); a remotechange, or an older layout's root, applied during
initialize()'s commit;an undo step made during initialization reaching
CAN_UNDO_COMMANDlisteners; two clients on an empty doc with
syncedfalse and true; aseedYStatedoc opened by a client with a template; aseedYStateseed ofthe template merging with a client's initialization.
pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/regressions/InitialState.test.tsxBefore
On main (92d6af7):
All 43 tests on main
After
The whole
@lexical/yunit suite,pnpm exec vitest run --project unit packages/lexical-y:pnpm run ci-checkexits 0 (TypeScript, FlowFound 0 errors, Prettier,ESLint,
Changeset check passed: 6 pending changeset(s) valid.), andscripts/__tests__/unit/treeShakingSource.test.tspasses for@lexical/y.The collab Y e2e specs,
pnpm run test-e2e-collab-y-chromiumwithpnpm run start:y:25 passed,6 flaky. The flaky ones failed their firstattempt on a page or frame load timeout and passed on retry; main on the same
machine gives the same first-attempt timeouts (
27 passed,4 flaky).CollabYSync.spec.mjswith--repeat-each=3 --retries=0:33 passed.Firefox and WebKit were not run.