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:
- 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.
- 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
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$createXNodeand 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:registeredNode.replace/.replaceWithKlassare never consulted. So JSON written by a build that predates a replacement deserializes to the stock class, complete with whatever$config().$transformthe replacement exists to remove.Concretely in
@llui/markdown-editor:MarkdownListNode(type stringmd-list) exists precisely to dropListNode.$config().$transform'smergeNextSiblingListIfSameType, which merges any two adjacent lists of the samelistTypeand therefore destroys CommonMark 0.31 §5.3's "a bullet/delimiter change starts a new list". Old JSON yields a genuineListNodestill carrying that merge, i.e. the bug is back for exactly the documents it was reported from.Mitigated here, at userland cost:
registerListNodeUpgradeis aregisterNodeTransform(ListNode, …)that replaces a stock node with anmd-listthe first time it is marked dirty. The window is one microtask —registerNodeTransformitself callsmarkNodesWithTypesAsDirty(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, becauseregisterNodeTransformbinds the listener toreplaceWithKlassas well (LexicalEditor.ts:1536-1543) and an unguarded upgrade trips the infinite-transform invariant.Half 2 — the live path (CRDT), NOT mitigable
@lexical/yjsdoes the same lookup for nodes arriving over the wire: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$transformruns in the same transform pass that first dirties it, before the upgrade replaces it.Reproduced — two settled
md-lists with distinct markers, then a stockListNodeinserted between them in a later update:Stock
b'smergeNextSiblingListIfSameTypeswallowsc(dropping its marker state) before the upgrade can replaceb; the markerless result then joinsa. 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
listso nothing ever reconstructs the stock class. Lexical rejects it outright (LexicalNode.ts:2037):The distinct
md-listtype string is therefore forced, and with it the whole reconstruction problem.What would fix it upstream
Either of these closes both halves:
$parseSerializedNodeImpl(and@lexical/yjs'sUtils.tslookup) consultregisteredNode.replace/replaceWithKlassthe way$createXNodedoes, 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.$configtransform. Today there is none:getTransformSetFromKlassaccumulates the config chain, so a subclass can only avoid an inherited$transformby declaring anextendsthat is not its real superclass — which is off-contract (Lexical documentsextendsas "must be the exact superclass") and rejected by TypeScript.@llui/markdown-editorcarries a@ts-expect-errorfor exactly this. With a removal lever, no type-string change would be needed and half 2 would not exist.References
packages/markdown-editor/src/nodes/list.ts—MarkdownListNode,registerListNodeUpgrade, and the header comment recording both windows