Skip to content

Lexical applies no node replacement when reconstructing a node from a type string (JSON load + @lexical/yjs live path) #154

Description

@fponticelli

Upstream (Lexical) gap surfaced by PR #141 / issue #129. Not fixable from userland; tracked here so the limitation is not rediscovered.

Root cause (one)

Lexical resolves no node replacement when it reconstructs a node from a type string. A {replace, with, withKlass} registration is honoured by $createXNode and by the transform registry, but not by either path that builds a node from serialized data. Both halves below are the same defect seen from two sides.

Half 1 — the load path (JSON)

$parseSerializedNodeImpl (lexical/src/LexicalUpdates.ts:433) does:

const registeredNode = registeredNodes.get(type)   // :417
const nodeClass = registeredNode.klass             // :423
const node = nodeClass.importJSON(serializedNode)  // :433

registeredNode.replace / .replaceWithKlass are never consulted. So JSON written by a build that predates a replacement deserializes to the stock class, complete with whatever $config().$transform the replacement exists to remove.

Concretely in @llui/markdown-editor: MarkdownListNode (type string md-list) exists precisely to drop ListNode.$config().$transform's mergeNextSiblingListIfSameType, which merges any two adjacent lists of the same listType and therefore destroys CommonMark 0.31 §5.3's "a bullet/delimiter change starts a new list". Old JSON yields a genuine ListNode still carrying that merge, i.e. the bug is back for exactly the documents it was reported from.

Mitigated here, at userland cost: registerListNodeUpgrade is a registerNodeTransform(ListNode, …) that replaces a stock node with an md-list the first time it is marked dirty. The window is one microtask — registerNodeTransform itself calls markNodesWithTypesAsDirty (LexicalEditor.ts:1545), so a document already loaded when the transform registers is dirtied immediately and both registration orders converge. It also has to no-op on its own output, because registerNodeTransform binds the listener to replaceWithKlass as well (LexicalEditor.ts:1536-1543) and an unguarded upgrade trips the infinite-transform invariant.

Half 2 — the live path (CRDT), NOT mitigable

@lexical/yjs does the same lookup for nodes arriving over the wire:

const registeredNodes = binding.editor._nodes            // Utils.ts:403
const nodeInfo = registeredNodes.get(type)               // Utils.ts:409

So in a collab session mixing an old build and a new one, stock ListNodes keep arriving in live updates, not only at load. Here the userland upgrade cannot win: the stock node's own $transform runs in the same transform pass that first dirties it, before the upgrade replaces it.

Reproduced — two settled md-lists with distinct markers, then a stock ListNode inserted between them in a later update:

step1: ['md-list/mk=-(a)', 'md-list/mk=*(c)']
step2: ['md-list/mk=-(a|b|c)']        ← the '-'/'*' boundary is gone

Stock b's mergeNextSiblingListIfSameType swallows c (dropping its marker state) before the upgrade can replace b; the markerless result then joins a. This is identical to pre-#129 behaviour — not a regression — but it means the #129 guarantee does not extend to a live collab document mixing builds.

Why a same-type-string subclass is not the workaround

The obvious escape is to keep the type string list so nothing ever reconstructs the stock class. Lexical rejects it outright (LexicalNode.ts:2037):

Error: Create node: Type list in node AltListNode does not match registered node ListNode

The distinct md-list type string is therefore forced, and with it the whole reconstruction problem.

What would fix it upstream

Either of these closes both halves:

  1. Replacement resolution in the reconstruction paths. Have $parseSerializedNodeImpl (and @lexical/yjs's Utils.ts lookup) consult registeredNode.replace/replaceWithKlass the way $createXNode does, so a registered replacement applies to nodes rebuilt from a type string as well as to nodes created in code. This is the direct fix and needs no new API.
  2. A way to unregister or override a registered node's $config transform. Today there is none: getTransformSetFromKlass accumulates the config chain, so a subclass can only avoid an inherited $transform by declaring an extends that is not its real superclass — which is off-contract (Lexical documents extends as "must be the exact superclass") and rejected by TypeScript. @llui/markdown-editor carries a @ts-expect-error for exactly this. With a removal lever, no type-string change would be needed and half 2 would not exist.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-humanRequires human implementation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions