Repository navigation
fix: $config() auto-synthesized clone() loses properties when called directly (#8640) - #8864
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
left a comment
There was a problem hiding this comment.
I think a less problematic solution here is to add an optional internal argument to clone that can be used at the internal call sites that do not expect the implicit $afterCloneFrom. There are correctness issues here if a clone happens to call another node’s clone for some reason (even in another editor since this adds a module global)
…directly (meta#8640) meta#8640 ported node classes to the $config() protocol, which auto-synthesizes a zero-arg static clone() for nodes that no longer declare an explicit one. That synthesized clone only produces a correct instance when driven through the internal $cloneWithProperties / $copyNode wrappers, which call afterCloneFrom() after construction. Any caller invoking NodeClass.clone(node) directly — a documented, idiomatic pre-meta#8640 pattern — silently received a default-constructed node with lost properties (e.g. HeadingNode.__tag reverting to 'h1' regardless of the source node's tag). This is a silent data-correctness bug, not a throw. Fix the gap at the source: the synthesized clone now calls afterCloneFrom() itself unless it is told not to. afterCloneFrom is not guaranteed idempotent (some nodes accumulate state there, e.g. a version counter), so it must run exactly once per clone. The internal wrappers ($cloneWithProperties / $copyNode) pass a module-private sentinel as an optional second argument to signal that they will call afterCloneFrom themselves; the synthesized clone then skips it. Direct external callers pass no sentinel and get afterCloneFrom applied for them. The signal is passed per-call rather than tracked in a module global, so it is reentrancy-safe: a clone (or constructor) that itself clones another node — even in a different editor — does not accidentally suppress that node's own afterCloneFrom. The sentinel is not exported, so external callers cannot spoof it. Adds regression tests: direct clone() preserves properties, afterCloneFrom runs exactly once via $cloneWithProperties, and a reentrant direct clone performed during another node's clone still preserves its own properties.
1e2aeac to
0a3727e
Compare
|
Great catch — you're right on both counts, and the reentrancy issue with the module global is real. I put together a small model of exactly the case you described (a I've reworked it along the lines you suggested: the auto-synthesized Specifics:
Added a regression test for the reentrancy case (a direct clone performed from within another node's 🤖 Raised and iterated with Navi on my behalf. |
Bug
#8640 ("Port node classes to the
$config()protocol") introduced a silent backwards-compatibility break in theLexicalNodeclone contract, found while syncing the change into Meta's internalwwwcodebase (D112781184).What breaks
When a node drops its explicit
static clone()in favor of$config()(e.g.HeadingNode),getStaticNodeConfigauto-synthesizes a zero-arg clone:That synthesized clone only produces a correct instance when it is driven through the internal
$cloneWithProperties/$copyNodewrappers, because those wrappers are what callafterCloneFrom(prevNode)afterwards (which is where subclasses copy their own state). The synthesized clone itself only preserves__key(viasetPendingNodeToClone), not properties.Any caller that invokes
NodeClass.clone(node)/node.constructor.clone(node)directly — a documented, idiomatic pattern before #8640 — now silently gets a default-constructed node with lost properties. ForHeadingNode,__tagis copied only inafterCloneFrom, so a direct clone of anh2returns a node whose tag has reverted to the constructor default'h1'.This is a silent data-correctness bug, not a throw, so it's easy to miss in review. It affects the 25+ core nodes that rely on auto-synthesized clone (
ParagraphNode,TextNode,ListNode,TableNode,HeadingNode,LinkNode, …).Fix
Close the gap at the source rather than relying on every caller using the "right" entry point: the synthesized clone now calls
afterCloneFrom()itself when it is not being driven by an internal wrapper, so it is correct regardless of call path.The subtlety is that
afterCloneFromis not guaranteed idempotent — some nodes deliberately accumulate state there (the existingclone() with no boilerplateunit test uses a node that increments a__versioncounter inafterCloneFrom). SoafterCloneFrommust run exactly once per clone. AcloneWrapperDepthguard achieves this:$cloneWithPropertiesand$copyNodewrap theirconstructor.clone(node)call inrunInCloneWrapper(...)and remain solely responsible for callingafterCloneFrom(unchanged behavior for them).isInCloneWrapper(): inside a wrapper it does not self-apply (the wrapper will); called directly it self-applies.This preserves the existing contract for explicit
static clone()implementations (they still must not callafterCloneFrom; the wrappers do) and fixes direct callers without any double-application.Tests
Added two regression tests in
LexicalNode.test.ts:direct clone() of an auto-synthesized node preserves properties— asserts a directNodeClass.clone(node)on a$config()-based node keeps its custom property (fails before this change, passes after), and that$cloneWithPropertiesstill works.afterCloneFrom runs exactly once for auto-synthesized clone— a node that increments a version counter inafterCloneFromends at version1(not2) after one clone, guarding against double-application from this change.Verified locally:
LexicalNode.test.ts: 96 passedpackages/lexicalunit suite: 1204 passed (no regressions from double-afterCloneFrom)packages/lexical-rich-text(incl. 19HeadingNodetests): 112 passedprettier --checkandtsc --noEmitclean on the changed filesFollow-up (not in this PR): stricter
importJSONbase signatureWhile syncing, a second BC break was found: #8640 also tightened the base
LexicalNode.importJSONreturn type to non-nullableLexicalNode. Subclasses that intentionally returnnullfromimportJSON(signaling "this node type does not support JSON import") now fail type-checking.I deliberately left this out of this PR. Naively relaxing the base return type to
LexicalNode | nullripples into the deserializer ($parseSerializedNodeImplinLexicalUpdates.ts) and its recursive child-append path, forcing everyimportJSONconsumer to handlenulland changing deserialization semantics — a larger API decision than a clone-correctness fix should carry. The cleaner alternative is an explicit$config()opt-out (e.g.importable: false) with matching deserializer support. Happy to open a separate PR / discussion once maintainers weigh in on the preferred shape. In the meantime thewww-side flow break can be unblocked with a local flow annotation on the specific non-importable subclass.Context
Reported by the Lexical → fbsource sync oncall. Concrete internal regressions that surfaced this (Meta-internal):
CaaSLexicalInsertLineNode.jsflow break and anMLCNestingEnforcementPluginheading-nesting test failure (h1>h2>textunwrappingh2toh1) — both explained by the direct-clone property loss above. See D112781184 for the full internal analysis.