Skip to content

Upgrade Lexical to 0.51 - #23

Merged
samuelstroschein merged 3 commits into
mainfrom
chore/lexical-0.51
Sep 24, 2026
Merged

samuelstroschein merged 3 commits into
mainfrom
chore/lexical-0.51

Conversation

@samuelstroschein

@samuelstroschein samuelstroschein commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Upgrades @opral/zettel-lexical from Lexical 0.30 to 0.51 (latest). lexical, @lexical/history and @lexical/utils move to ^0.51.0 in dependencies, peer dependencies and dev dependencies. @lexical/extension is added. The playground and lix-comments examples use lexical@^0.51.0.

The changeset is a major bump for @opral/zettel-lexical. The new peer range excludes 0.30, so an app has to upgrade lexical together with this release. On a ^1.x range it would otherwise pick this up, end up with two Lexical copies and fail at runtime.

Lexical breaking changes and how Zettel adapts

I read the release notes for 0.31 through 0.51 and checked each breaking change against the Zettel nodes and plugin.

Lexical change Effect on Zettel Adaptation
0.49 Node constructors need zero-argument defaults (#8640). 0.50 importJSON goes through $create for registered replacements (#9061) Importing a plain text node constructs new ZettelSpanNode() with no arguments. fromLexicalState threw Cannot read properties of undefined Every Zettel node constructor has a default first argument. Test: plain paragraph/text JSON loads, and formats become marks
0.49 LexicalCommand<T> is invariant (#8877) registerCommand<KeyboardEvent>(KEY_ENTER_COMMAND, …) failed to compile (the payload is KeyboardEvent | null) The redundant explicit generics are dropped
0.47 formatText toggles a range by selection.format (#8807) A range selected in code keeps the caret's stale format, so Cmd+U or Cmd+B on fully underlined or bold text applied the format again instead of removing it (the existing unit test failed) FORMAT_TEXT_COMMAND works out which formats every selected span has, then aligns to that. Test added
0.45 Triple-click normalization moved out of core into NormalizeTripleClickSelectionExtension (#8520) Zettel uses neither rich-text nor plain-text. A triple click left the focus at the start of the next block, so typing merged it (["XSecond paragraph"] in Chromium) The plugin detects triple clicks and runs the extension's $fixFocusOverselection. Chromium check added
0.50 Select-all delete removes the blocks and leaves a fresh ParagraphNode (#8884) The editor was left with a plain <p> that has no Zettel key: a new _key on every export, and Enter could not split it A ParagraphNode transform replaces any Lexical paragraph with a Zettel text block. This also covers inline content that Lexical wraps at the root. Unit and Chromium checks added
0.46 Unsafe type parameters on traversal methods are deprecated (#8661) getParent<ZettelTableRowNode>() Replaced with instanceof narrowing
0.42 A root listener can return a cleanup (#8219) Listeners were removed from the previous root only, never on unregister The root listener returns its cleanup

The remaining breaking changes don't affect Zettel:

  • Markup and HTML: ltr/rtl classes and $isParentElementRTL (0.35); managed <br data-lexical-managed-linebreak> and the extra trailing <br> in exportDOM (0.46, 0.50). Zettel's HTML comes from @opral/zettel-html, and the Chromium suite passes.
  • Behaviour that lives in the rich-text extension, which Zettel does not register: Backspace preserving the current block (0.45, block merges are unchanged) and NormalizeInlineElementsExtension (0.45). Zettel's image and inline HTML nodes are empty inline elements on purpose.
  • Features Zettel doesn't use: $copyNode/afterCloneFrom (0.33; Zettel nodes copy their state in clone), textFormat/textStyle serialization (0.39), exportJSON via withField (0.51), named slots, shadow DOM, the clipboard excludeFromCopy channel, @lexical/code, and React.
  • ESM-only packages (0.51). Zettel is already ESM.

Cross-block delete bug: still present in 0.51

The bug is in Lexical and still present in 0.51. It reproduces with a plain ParagraphNode/TextNode editor and no Zettel code:

  • Select from the start of a block into a text run of a later block that has more runs after it, e.g. |one two / fou|r five six.
  • Backspace, Delete, typing, Enter, paste and cut all throw $getTextNodeOffset: invalid offset 3 for size 1, and the edit does nothing.

What happens: $removeTextFromCaretRange trims the last text run. It then merges the blocks while the live selection still holds the untrimmed focus offset, and insertBefore reads that selection.

Zettel now does every non-collapsed removal with the live selection detached (src/selection.ts). The removal rebuilds the selection from its own carets. This is also exported as $removeSelectedText(selection). Typing over a range keeps Lexical's rule that the new text takes the format of the first selected character. Paste removes the range first and then pastes at the caret.

src/selection.test.ts covers Backspace, Delete, deleteContentBackward, Alt+Backspace, Cmd+Backspace, typing, Enter, paste, cut and drag-move, with forward and backward selections. Without the guard, 21 of its 22 tests fail. A Chromium check makes a real DOM selection and presses Backspace.

Tests

  • pnpm run ci passes: lint, build, and ast 11, markdown 19, html 17, lexical 59 tests (up from 35).
  • The Chromium playground suite (examples/playground, pnpm test) passes 29 checks, 3 of them new: select-all delete leaves an editable Zettel block, a range delete across blocks works, and typing over a triple-click selection keeps the next block.
  • Before the fixes, the new Chromium checks reproduced each problem on 0.51 (triple-click merge, <p> without a Zettel key, the cross-block throw). The triple-click and select-all problems did not happen on 0.30; the cross-block throw did.

Compatibility with the open PRs (#18–#22)

I merged this branch into main + #18–#22. After the fixes below, pnpm run ci passes (lexical 87 tests) and the Chromium playground passes 34 checks.

Not covered here

  • The examples/lix-comments tests fail on main too (lix_create_checkpoint not found, a Lix API change). Only its lexical range changed here.
  • Found while testing, and also broken on 0.30: when Backspace or Delete merges two blocks, the links (markDefs) of the block that is merged in are lost. exportDocument then throws Unresolved mark. This needs its own fix.

🤖 Generated with Claude Code

Bump lexical, @lexical/history and @lexical/utils from ^0.30.0 to ^0.51.0
(peer and dev ranges too) and add @lexical/extension, then adapt to the
breaking changes that reach Zettel:

- 0.49: node constructors need zero-argument defaults; importJSON of a
  replaced TextNode now constructs ZettelSpanNode with no arguments.
- 0.49: LexicalCommand is invariant; drop explicit registerCommand generics.
- 0.47: formatText toggles a range by selection.format; derive it from the
  selected spans so ranges selected in code toggle off correctly.
- 0.45: triple-click normalization left core; reuse the extension's fix.
- 0.50: select-all delete leaves a ParagraphNode; transform it into a
  Zettel text block.
- 0.46: stop using the deprecated traversal type parameters.
- 0.42: return the root listener's cleanup instead of tracking the previous
  root, so listeners are also removed on unregister.

Lexical still throws when a range from the start of a block ends inside a
text run of a later block. Remove non-collapsed ranges with the live
selection detached, and cover every editing path in tests and in Chromium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T01:36:48.409473Z 26b1b91 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Wrap each deletion in $editRange instead of a separate $deleteSelection
helper, so other editing code can run its own Lexical edit of a range
through the same guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26b1b911ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +202 to +204
const first = (selection.isBackward() ? selection.focus : selection.anchor).getNode();
const format = $isTextNode(first) ? first.getFormat() : selection.format;
const style = $isTextNode(first) ? first.getStyle() : selection.style;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve the first selected character at span boundaries

When a forward range starts at the end of one span and continues into an adjacent span with different formatting, first is still the outgoing span even though it contributes no selected characters. The replacement therefore inherits that span's format/style instead of the first actually selected character—for example, replacing a bold word selected from the end of a preceding plain span inserts plain text. Resolve an endpoint at getTextContentSize() to the next selected text node (and handle the equivalent backward boundary) before saving the replacement formatting.

Useful? React with 👍 / 👎.

@samuelstroschein
samuelstroschein merged commit 557d8fa into main Sep 24, 2026
1 check passed
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.

2 participants