Skip to content

Keep links when Backspace or Delete joins two blocks - #24

Merged
samuelstroschein merged 2 commits into
mainfrom
fix/keep-links-on-block-join
Sep 24, 2026
Merged

samuelstroschein merged 2 commits into
mainfrom
fix/keep-links-on-block-join

Conversation

@samuelstroschein

Copy link
Copy Markdown
Member

Problem

In @opral/zettel-lexical, Backspace at the start of a block or Delete at the end of one joins the two blocks, and the links of the block that is merged in are lost. exportDocument then throws Unresolved mark '<key>'. Deleting a range from one block into a link in the next one does the same. This reproduces on Lexical 0.30 (main) and 0.51.

Zettel text blocks keep their link definitions in the block's markDefs (zettel_link defs keyed by _key), and spans point at them through marks. When Lexical joins two blocks it moves the second block's children into the first. The spans keep their mark keys, but the definitions stay behind in the removed block.

Fix

A node transform on ZettelTextBlockNode and ZettelTableCellNode, $resolveLinkMarks, runs before the update commits. For each inline node whose link mark does not resolve in its block:

  • It takes the href from the node's cached link href (spans and images). Nodes restored from serialized Lexical state have no cached href, so in that case it reads the href from the block that held the node in the previous editor state.
  • It reuses a definition in the new block with the same href, or adds one with a fresh _key. _keys are unique across the whole document, so a copied key would be invalid.
  • If it can't recover an href, it drops the mark. No edit can leave a document that exportDocument rejects.

This is the helper from #20 ($resolveLinkMarks, same name, same registration and same ZettelSpanNode.getLinkHref()). On top of that version this one also handles:

Whichever PR merges second only has to keep this version of the function.

Tests

  • New link-join.test.ts (jsdom, Lexical headless) has 12 cases. 11 fail on main; the undo case is a regression guard.

    • Backspace and Delete joins
    • both blocks linked (different hrefs)
    • the same href in both blocks (one definition)
    • a link meeting another link at the join
    • a paragraph joining into a list item and a list item joining into a paragraph
    • a join across a quote boundary
    • a range delete from block A into block B's link
    • an editor restored from Lexical JSON
    • a linked image
    • undo
    • a raw node move with an unresolvable mark, which is dropped rather than exported

    Each case checks assertDocument, a lossless export → load → export round trip, unique markDefs keys, and where relevant the rendered <a href>.

  • The cross-block range delete does not hit the Lexical crash that Upgrade Lexical to 0.51 #23 works around on this path, so it is covered here.

  • New Chromium playground check: real Backspace and Delete keypresses join two linked paragraphs. It fails on main with Unresolved mark.

  • pnpm run ci passes (lexical: 47 tests). The playground reports 27/27 checks passing.

🤖 Generated with Claude Code

Zettel text blocks keep their link definitions in markDefs. When Lexical
joins two blocks it moves the second block's spans into the first, but the
definitions stay behind in the removed block, so the moved spans reference
marks that no longer resolve: the links disappear and exportDocument throws
"Unresolved mark".

A node transform on text blocks and table cells now gives a moved linked
node a definition in its new block (reusing one with the same href), taking
the href from the node or, for nodes restored from Lexical JSON, from the
block that held it before the update. Marks that cannot be resolved are
dropped, so no edit can leave a document exportDocument rejects.

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-24T02:58:55.173148Z 579eff1 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.

@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: 579eff1984

ℹ️ 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".

if (!unresolved.length) continue;
const next = marks.filter((mark) => !unresolved.includes(mark));
const href = (child instanceof ZettelSpanNode || child instanceof ZettelImageNode ? child.getLinkHref() : undefined) ?? $previousLinkHref(child, unresolved);
if (href && !next.some((mark) => markDefs.some((definition) => definition._key === mark))) {

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 Accept empty hrefs when rebuilding link definitions

When a valid link definition has href: ""—for example, Markdown [text]()—the cached href reaches this branch as an empty string, so the truthiness check skips recreating the definition and the transform removes the unresolved mark. Joining the blocks therefore silently converts the link to plain text; test for href !== undefined instead.

Useful? React with 👍 / 👎.

Comment on lines +292 to +294
let definition = markDefs.find((candidate) => candidate.href === href);
if (!definition) {
definition = { _type: "zettel_link", _key: generateKey(), href };

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 Preserve link titles when recreating definitions

When the moved link definition includes the supported optional title, this reconstruction retains only its href, so a block join silently drops title metadata from the exported document. Reusing a destination definition solely by href can likewise replace a source title with a different one; the source definition's title needs to participate in reconstruction and matching.

Useful? React with 👍 / 👎.

@samuelstroschein
samuelstroschein merged commit b4f6a06 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