Skip to content

[Breaking Changes][lexical][lexical-yjs][lexical-clipboard][lexical-html][lexical-playground] Feature: Named slots#8603

Merged
etrepum merged 145 commits into
facebook:mainfrom
mayrang:feat/5930-element-decorate
Jun 16, 2026
Merged

[Breaking Changes][lexical][lexical-yjs][lexical-clipboard][lexical-html][lexical-playground] Feature: Named slots#8603
etrepum merged 145 commits into
facebook:mainfrom
mayrang:feat/5930-element-decorate

Conversation

@mayrang

@mayrang mayrang commented May 31, 2026

Copy link
Copy Markdown
Contributor

Breaking Changes

All slot machinery is opt-in and gated (an editor latches _slotsUsed on the first $setSlot), so editors that never use slots take identical code paths to before. The items below are the complete set of changes observable by existing consumers. Unless you hit one of these two specific scenarios there are no expected breaking changes:

  • syncLexicalUpdateToYjsV2__EXPERIMENTAL takes a new dirtyLeaves parameter, inserted between dirtyElements and normalizedNodes. A DecoratorNode host's slot values surface as dirty leaves, so the recursive dirty walk needs the union of both sets.
  • A NodeSelection containing an ElementNode now includes its children in copy / export on both clipboard channels. The previous behavior serialized a childless element shell, which made cut of an element NodeSelection silently lossy — this is really a bug fix, but the output of $getHtmlContent / $generateJSONFromSelectedNodes changes for any consumer copying element NodeSelections. Partial RangeSelections keep per-child slicing, and excludeFromCopy children remain excluded.

Description

Lexical's nested editors put each region in its own editorState, so moving nodes between regions and keeping history and collab in sync all need serialization and extra editor.update passes (#5930). Named slots keep those regions in the host's editorState as a second child channel rendered into the host's own DOM, so editing a slot is just editing the one tree.

Named slots let a single host node (ElementNode or DecoratorNode) own several isolated editable regions addressed by name (a Card's title, a PullQuote's quote and attribution) instead of sharing one child linked list. issue #5930 asks for exactly this: regions that each take their own caret and formatting, never merge across the boundary, and don't let Cmd+A spill into the rest of the document. The existing node kinds can't express it — plain ElementNode children are one undivided linked list, so backspace at a region start merges it into the previous region, and a DecoratorNode is atomic, so Lexical can't own selection / collab / serialization inside it.

The model: a host keeps a second child channel, __slots: Map<slotName, NodeKey>, separate from its __first/__last linked list. A slot value has __slotHost set and __parent === null, with exactly one of the two non-null, so getParent() stops at the slot boundary and you climb out only through $getSlotHost(). The slot link itself acts as a virtual invisible shadow root between the host and the value — isolation is structural rather than a convention: an accidental boundary crossing surfaces as a thrown invariant, not silent corruption. Slots render synchronously into keyless <div data-lexical-slot="<name>"> containers parked slots-first in the host DOM as hidden placeholders, in a canonical order derived from the host class (see below); nothing is visible until the host explicitly attaches a container somewhere.

This is @experimental. Three playground demos cover the host shapes, value shapes, and attachment means: Card (an ElementNode host with a single-line title slot — a bare ParagraphNode value — alongside ordinary linked-list body children, attached synchronously in-lexical via the render config), PullQuote (a DecoratorNode host with a multi-block quote slot wrapped in a shadow-root container and a single-line bare-paragraph attribution, attached from React decorate() chrome via useLexicalSlotRef), and Review (an ElementNode whose React presentation goes beyond static chrome: a contentEditable=false shell that portals in an interactive star-rating widget persisted to NodeState alongside two editable islands — the single-line named author slot attached with useLexicalSlotRef, and the linked-list body children attached through the same render-hidden-then-attach technique applied to its getDOMSlot element). All three round-trip through HTML copy/paste and behave like first-class blocks in the playground (arrow-key escape, empty-box deletion, chrome-click selection — see Playground host ergonomics below). The devtools tree view renders slot channels ([slot: name]) including in-slot selection annotations, and a new Named Slots concept page (docs/concepts/named-slots.md) documents the model, APIs (with examples for all three attachment means, linked to the API docs), and reserved names — including disambiguation from the unrelated DOMSlot rendering concept.

Design notes

  • Map<string, NodeKey>, not a plain object. Slot names are arbitrary strings, and a plain object lets "__proto__" or "constructor" produce a false has(). Map has no prototype keys. ($setSlot additionally rejects the three reserved names outright, and the collab observers validate-and-skip them so a hostile peer can't crash clients.)
  • Store only the __slotHost pointer; recover the name from the host's Map. One source of truth removes a whole sync-bug class, and slot counts are tiny so the reverse lookup is free ($getSlotNameWithinHost). Worth revisiting at hundreds of slots, not for cards.
  • Slot values are any non-inline block (ElementNode or DecoratorNode) — the slot link is the shadow boundary, so the value itself doesn't have to be one. A bare ParagraphNode subclass works as a single-line field with no wrapper in the model, the JSON, or the DOM: its virtual scope holds exactly one block, so Enter is a no-op (hosts may map it to focus movement) and multi-block paste flattens to inline content the way an <input> sanitizes its value (line breaks stripped, block-only decorators dropped). Multi-block regions use a shadow-root container value (SlotContainerNode) and keep normal block editing inside. The rule lives at the single $setSlot chokepoint, so every create / restore path conforms, and inserting a node's own slot ancestor as a child is guarded in both directions (no isAttached/GC cycles).
  • Slot order is canonical and derived, never stored. A host class declares its order in $config() (slots: ['quote', 'attribution']); undeclared names sort after in UTF16 code-unit order. $setSlot re-canonicalizes on every write, so documents normalize on load and concurrent collaborative additions converge to the same order on every client. Declared hosts also get their collab Y.Map eagerly at creation, so each name's first set merges per-entry instead of racing on attribute creation.
  • The slot map is copy-on-write, mirroring NodeState: afterCloneFrom shares the map reference across versions and the mutators clone once per writable version (symbol owner mark stamped at construction), so a host cloned for any non-slot change pays no per-version Map copy.
  • Slots are hidden placeholders until explicitly attached — the named-slot analog of getDOMSlot's control over where children render. The reconciler always renders every slot subtree synchronously, but into a display: none container parked slots-first in the host DOM; its ordering pass only manages placeholders still parked there, so an explicit mount is never yanked back. Three attachment means share one contract (move to the target if needed, reveal): a $getSlotTargetElement DOM render-config override (DOMRenderMatch via DOMRenderExtension/domOverride) for hosts implemented entirely in-lexical — consulted by the reconciler, synchronous within the commit (Card); the framework-independent mountSlotContainer/unmountSlotContainer (mountSlotContainer resolves the container against the committed editor state via editor.read('latest', ...), so the model it looks up matches the reconciled DOM it moves; unmountSlotContainer is DOM-only); and lexical-react's useLexicalSlotRef wrapping them (PullQuote). The revealed state is a normal block, deliberately not display: contents — Chromium cannot reliably edit inside a boxless contenteditable subtree (caret hit-testing resolves clicks into a neighboring box and native text insertion is dropped). A contentEditable=false ElementNode shell can host React chrome for both channels (the Review demo): slot containers opt into contentEditable=true inside any non-editable host DOM, such a shell marks itself setDOMUnmanaged in createDOM for decorator-parity with the mutation observer, and the observer's unknown-DOM eviction now skips keyless [data-lexical-slot] containers — they are reconciler scaffolding, and a flush observing one being parked or relocated must not remove it.
  • Editable islands follow the editor's editable state. A slot rendered inside a non-editable host (a DecoratorNode, or a contentEditable=false element shell) becomes its own contentEditable island so it stays editable there, and it would not track editor.isEditable() on its own — so the reconciler sets the container's contentEditable explicitly from the editor's state and re-applies it on every reconcile. The reconciler owns this (no DOM-walking extension), so setEditable schedules a $fullReconcile — gated on _slotsUsed like the rest of the slot machinery — to flip every island on a read-only toggle, and a nested editor's islands stay correct because each editor reconciles its own tree. The shared helper $markSlotEditable(element, editor) applies the same rule to a non-slot editable island an app attaches itself (e.g. the Review demo's getDOMSlot children element inside its contentEditable=false shell), re-applied from updateDOM so a toggle reaches it. Slots always follow the editor; a per-slot editable override is intentionally out of scope for now — event handling isn't yet set up for editable islands inside a non-editable editor — and can return as a follow-up.
  • isAttached is the GC linchpin. It now follows __slotHost when __parent is null, so a live slot is no longer seen as detached and reaped; fixing this one function carries slot-safety through GC. A second guard throws if a filled slot is dropped on export, as a data-loss tripwire.
  • Traversal is intentionally asymmetric. Content reads include slots, slots-first (getTextContent, getAllTextNodes, $dfsWithSlots): "what is in this subtree" has to count slot content for search, copy, and accessibility. Navigation excludes them (getFirstDescendant and friends feed selectStart / selectEnd, so including slots would walk the caret into a slot and break isolation). getChildren() stays linked-list-only for backwards compatibility.
  • isEmpty() is slot-aware (childrenSize === 0 && slotNames.length === 0). $removeNode cascade-prunes empty parents, so a slots-only host that lost its last linked-list child would otherwise be reaped and orphan its slots.
  • Selection clamps at all three entry points: the DOM read ($internalResolveSelectionPoints), the API ($setSelection), and in-place point mutation. They share one helper with the direction test injected per caller. The clamp is anchor-frame, so a cross-boundary shift+arrow and a mouse drag land on the same result. The selection-driven exporters walk the anchor's slot frame ($getSlotFrame), so copy/cut of a selection inside a slot serializes correctly on both clipboard channels.
  • SELECT_ALL scopes only inside slot frames by default. The rich-text/plain-text handlers keep the legacy whole-document behavior everywhere else (including table cells); progressive scoping is SelectBlockExtension's job, which is slot-aware ($isBlockFullySelected never compares across frames) and expands block → enclosing slot frame → document on repeated presses, with no dead press when the value is its own block. The playground enables it by default.
  • Collab rides a reserved __slots Y.Map. __slots and __slotHost join the synced-property exclusions in both directions, and slots serialize through a per-slot-diffed Y.Map on the host attribute channel on both the V1 (_xmlText) and V2 (XmlElement) paths. (The attribute key reuses the host's own __slots field name: it can't be $-prefixed like the $slots JSON key below — $ breaks XmlElement.toDOM — and node properties sync under their __-prefixed names, so reusing the already-excluded __slots field name lets that single exclusion reserve the channel too, while a user field literally named slots keeps syncing.) The observers route first-set/undo transaction shapes (which surface as a changed __slots key, not a YMapEvent) to the slot reconcile, validate-and-skip malformed remote entries, and sweep deleted structs so remote host deletion doesn't leak collab-node map entries. Root-host slots sync like any other host's.
  • JSON serializes slots under a reserved $slots key (declared on SerializedLexicalNode). The $ prefix mirrors the NodeState '$' key and keeps the framework-owned channel out of the namespace a third-party subclass may already use for a serialized slots property of its own. NodeKeys aren't serialized, so the name is the identity, and a dedicated key is the natural place for it.
  • Whole-host copy carries the body. An ElementNode in a NodeSelection (e.g. a Card promoted whole-host from a chrome click) serializes with all of its children even though they aren't in the selection themselves — see Breaking Changes; the old shell-only output made cut silently lossy. The promotion only applies to NodeSelection — a partial RangeSelection over the host keeps per-child slicing, so drags never over-export unselected content.
  • HTML slot serialization is opt-in per host, with no orphan heuristics on import. Like NodeState, the HTML exporter never descends into slots on its own; a host emits each slot as a <div data-lexical-slot="<name>"> inside its own wrapper from exportDOM (via $appendNodeToHTML), and its import rule maps those wrappers back through $setSlot with the slot names whitelisted per rule, so hostile markup can't reach $setSlot. Each demo host's import rule is collocated in the node's own extension (defineImportRule via DOMImportExtension, depending on CoreImportExtension so the paragraph/text rules exist and the host rule is ordered ahead of the generic block rules); the playground's always-on ClipboardDOMImportExtension routes pastes through it, so Card / Review / PullQuote round-trip through HTML copy/paste with their slots and per-host state (e.g. the Review's data-rating) intact. The exporter structurally can't emit a slot wrapper without its host (when a partially-selected host is dropped, its slot wrappers are dropped with it), so a bare orphaned wrapper only arises from browser-native serialization of an interior selection on surfaces Lexical doesn't control (e.g. copying from a published rendering of exported HTML); those degrade to content-only paste, the same convention as interior copies of any other container. Single-line slots import through the same inline projection as the paste path.
  • Playground host ergonomics live entirely in lexical-playground. Small per-host helpers make the three boxes feel like first-class blocks without touching core. ArrowDown/Up at a host's bottom/top edge steps between its editable regions, or — when the host is the last/first block — inserts a paragraph so it is never a navigational dead end; the region order is read from the rendered DOM (a chrome may render children above, below, or between the slots). The browser performs those moves natively except across a contentEditable island boundary, which Firefox will not cross, so the step between islanded regions (Review body↔author, PullQuote quote↔attribution) is done with a programmatic selectStart/selectEnd there. Because arrow-escape re-creates a paragraph on demand, insertion no longer seeds a blank paragraph before the host. Backspace deletes an empty host (from the start of its first region, or from the block immediately after it), and a non-collapsed select-all that spans a first-block host re-anchors its start to before the host so the whole node is replaced by a paragraph in one press instead of only clearing its contents. A chrome click (the padding outside the slots) promotes Card / PullQuote to a whole-host NodeSelection; PullQuote additionally maps Escape from either slot → NodeSelection and Enter on the selected host → its quote.

Three gaps remain. First, the caret / NodeCaret APIs throw across a slot boundary ("no common ancestor"), so the clamp resolves its direction with a model comparator instead of the caret system. Second, nested slots (a slot whose host is itself slotted) are out of scope at the runtime layer here: the model comparator reduces a slot-internal point to its host only one level deep, and neither demo nests slots, so the deeper case is left for a follow-up. The static lookup side (e.g. $getSlotNameWithinHost) is already covered. Third, mixed-version collab: pre-slots V1 clients receiving slot data from upgraded peers will error rather than render it, so enabling slots in long-lived shared documents should be gated on all participants running this version (new clients tolerate unknown slot data going forward).

Closes #5930
Closes #6613

Test plan

  • pnpm tsc --noEmit -p tsconfig.json / -p tsconfig.test.json clean
  • pnpm flow — no errors
  • pnpm vitest run --project unit — full unit suite green (slot suites + reconciler / selection / node / html / yjs-sync regression), including:
    • canonical slot order (declared / mixed / undeclared / subclass redeclaration, DOM + JSON order), copy-on-write slot maps, block-shaped slot values (Enter no-op, <input>-style paste flattening, boundary backspace, scope-root resolution)
    • hidden-placeholder rendering and explicit attachment: default-hidden containers for both host kinds, mountSlotContainer/unmountSlotContainer reveal/park round-trip, the $getSlotTargetElement render-config override (in-place reveal; reconciles keep mounted containers in their targets), and the mutation-observer scaffolding guard
    • editable-island state: a decorator-host island follows setEditable, the reconcile re-applying its contentEditable on a read-only toggle with no extension
    • collab two-doc relay tests through the real observer path: first slot set on a synced element / decorator host, Y.UndoManager undo convergence, concurrent first-sets and declared+undeclared adds converging in canonical order, hostile remote entries skipped, remote-deletion collab-map cleanup, root-host slots
    • clipboard: in-slot selection copy on both channels, whole-host NodeSelection child inclusion, partial-range over an element host never over-exports, excluded-slot-value guards
    • SelectBlockExtension × slots compose suite (block → slot → document escalation, frame-aware $isBlockFullySelected, disabled fallback to the scoped default)
  • e2e (chromium / firefox / webkit × rich-text / plain-text / collab v1 / collab v2 as applicable):
    • CardSlot.spec.mjs / PullQuoteSlot.spec.mjs / SlotCollabConvergence.spec.mjs / SelectBlock.spec.mjs green; the Linux Firefox selectAll helper now presses the real shortcut (the E2E keyboardShortcuts/index.mjs -> selectAll(page) Linux selection should be normalized #4665 emulation is removed), so no firefox skips remain
    • ReviewSlot.spec.mjs (the React-chromed ElementNode demo — rating widget click/hover/persist, both editable islands isolated, HTML export→import round-trip, empty-box and rated-box backspace deletion, select-all replace) green on chromium and firefox
    • SlotHostArrowEscape.spec.mjs (ArrowDown/Up escape + cross-island field navigation across Card / Review / PullQuote, wrapped-line offset behavior) green on chromium and firefox
  • Manual scenarios across Chrome / Firefox / Safari covering:
    • Card: typing in the title slot and body children, slot rendering order stays (title before body), <div data-lexical-slot> wrappers stable after re-render; title serializes as a bare paragraph ($slots.title.type === 'paragraph', no container level)
    • Card click / select: chrome click → whole-host NodeSelection; slot-text click → caret in that slot; Tab / Shift+Tab bridge title ↔ body
    • Card delete boundary: Backspace at title start of a non-empty Card → no-op; Delete (forward) at title end → no-op; Backspace / Delete inside a slot deletes only slot-internal characters; Enter in the title is a no-op (single-line slot)
    • PullQuote: typing in quote / attribution slots, slot isolation; Enter in attribution is a no-op; Backspace at the start of the quote's second paragraph merges within the quote slot, doesn't leak out
    • PullQuote click / select: chrome click → whole-host NodeSelection; slot click → caret in that slot; arrow keys from neighboring paragraphs → whole-host NodeSelection; Backspace on a selected host removes the host; Escape from a slot → whole-host NodeSelection, Enter on the selected host → caret in quote
    • Review: click/hover the star widget sets / previews the rating (persists to NodeState, rides undo / copy / collab); body children and author slot edit in isolation; the author prefix shows in CSS; HTML export → external editor → paste back restores both regions and data-rating
    • Host ergonomics (Card / Review / PullQuote): inserting /card /review /pull seeds no leading blank paragraph; ArrowDown/Up at the bottom/top edge inserts a paragraph when the host is the last/first block and otherwise steps between regions (including across the Review/PullQuote contentEditable islands in Firefox); Backspace at the start of an empty host (or the block right after it) deletes it; a select-all spanning a first-block Review is replaced by a single paragraph in one press
    • Read-only: toggling the editor read-only flips the slot islands non-editable (Review author / body, PullQuote quote / attribution) and back; each editor reconciles its own islands, so a nested editor's islands are unaffected by the outer editor's state
    • HTML round-trip: Card / PullQuote / Review export → external editor → paste back restores slot text (single-line slots flatten multi-block input like an <input>); internal clipboard round-trips custom slot content, not the defaults; cut/copy of a selection inside a slot round-trips
    • Collab (both V1 and V2): per-slot typing replicates both ways for Card + PullQuote + Review; the first slot set on an already-synced host reaches peers; host delete propagates; both clients converge on host + slots and on slot order
    • Regression: existing rich-text features (LinkNode / CodeNode / Markdown / list / table / mention / hashtag / bold / inline-code) work inside and outside a slot
    • Equation: the root-level atomic decorator still works (a separate demo from PullQuote, not a slot host)
    • deleteLine + SELECT_ALL: Cmd+Backspace inside a slot deletes only slot-internal characters, on a NodeSelection deletes the host; Cmd+A inside a slot stays scoped to that slot (default), table cells keep legacy whole-document Cmd+A; with SelectBlockExtension (playground default) repeated Cmd+A expands block → slot → document
    • Edge cases: empty root Cmd+A; paste into an empty slot; nested host; undo / redo across host insert / delete and slot-internal typing; collab delete during IME composition; Korean composition inside a slot — no leak across slot boundary
  • No invariant or console warnings throughout

@vercel

vercel Bot commented May 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 15, 2026 9:33pm
lexical-playground Ready Ready Preview, Comment Jun 15, 2026 9:33pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 31, 2026
@mayrang mayrang changed the title [lexical][lexical-html][lexical-playground] Feature: $decorate hook for ElementNode (#5930) [lexical][lexical-html][lexical-playground] Feature: $decorate hook for ElementNode May 31, 2026
@potatowagon

Copy link
Copy Markdown
Contributor

Review: $decorate hook for ElementNode + Named Roots

Assessment: Impressive work, needs maintainer sign-off on API surface ⚠️ (code quality is high)

What I verified:

  1. Architecture: This adds three new entries to EditorDOMRenderConfig$decorate, $namedRoots, $resolveNamedRoot — enabling any ElementNode to carry a React decorator without subclassing DecoratorNode. The named-root system cleanly separates external view-layer ownership (React portals) from lexical child routing. The design is well-documented via JSDoc and aligns with the discussion in PR [Breaking Change][lexical][lexical-html][lexical-selection][lexical-utils][lexical-playground] Feature: Generalize DOMSlot and add DOMRenderExtension override surface #8519.

  2. Reconciler correctness: Both the create ($createNode) and reconcile ($reconcileNode) paths mirror each other with matching capture-guard symmetry. Named-root children mount into either the announced container (from notifyNamedRootMounted) or a hidden deferred placeholder. Text content caching is properly accumulated per-child then summed at the host level — this prevents stale __lexicalTextContent caches.

  3. GC / memory safety: LexicalGC.ts correctly cleans up _pendingNamedRoots entries when the host node is GC'd. The notifyNamedRootMounted(_, _, null) unmount path deletes the registration. The deferred placeholder is removed once the real container arrives. No leak paths I can identify.

  4. React strict-mode: notifyNamedRootMounted has element-identity dedup (same container ref → no-op), so the mount/unmount/remount cycle strict-mode fires does not trigger redundant updates. The selection re-anchor uses queueMicrotask to defer past React's ref callback batch.

  5. Playground demo: The CardNode + CardExtension demonstrate the pattern clearly — title/body named roots with proper arrow-key navigation (range→NodeSelection promotion at boundaries). INSERT_CARD_COMMAND registers at COMMAND_PRIORITY_BEFORE_EDITOR which is correct for intercepting before the rich-text default handlers.

  6. CI: All core tests, integrity, and e2e canary pass green. CLA signed.

Observations:

  • The code went through 6 audit rounds (commits visible in history) which suggests thorough self-review by the author.
  • The invariant in $mountNamedRootChildren requiring explicit $resolveNamedRoot when there are 2+ named roots is a good guardrail.
  • The setDOMUnmanaged(dom) call on element-decorated hosts without captureSelection: true is the correct choice — editable named-root regions need normal selection resolution.

Why this needs maintainer sign-off: This introduces new public API surface ($decorate, $namedRoots, $resolveNamedRoot on DOMRenderMatch, notifyNamedRootMounted on LexicalEditor, _pendingNamedRoots). The implementation quality is high, but API shape decisions (naming, signature, composability) are project-owner decisions.


Reviewed by Navi (automated review assistant for @potatowagon)

@etrepum

etrepum commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

I haven't done a close look at this yet but I think we should consider two things:

  1. No reason to write new code with old conventions, this should be using $config - I don't see the logic for "mixing node class refactors" when these are all new classes
  2. Lexical is framework independent so we should consider how this fits in with that model, we should be able to implement $decorate (or something like it) without any React-specific code so that this would be compatible with anything else (no framework "vanilla" js, svelte, solid, vue, etc.). Ideally in a way where these conventions could be mixed (the most conventional case would be mixing vanilla with react or vice versa).

The current DecoratorNode decorator method model is not ideal (decorate method returns some unknown value that the editor is supposed to know how to handle) because it doesn't support mixing frameworks. Being able to configure it on a per-node basis is ideal (e.g. a mutation listener would be one way).

@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

On (1): tried $config({extends: ElementNode}) on Card / CardTitle / CardBody — paste regression, clicking the pasted card does nothing.

Walked it back across isolation rounds (manual statics one-by-one, unit tests, runtime expando trace). It isn't $config — the gap is in clipboard / NodeSelection:

  • NodeSelection over an element-decorator host: selection.getNodes() returns the host only, so $appendNodesToJSON sees child.isSelected = false for every named-root descendant and drops them. The exported JSON ends up with card.children = [].
  • The manual factory static importJSON(s) { return $createCardNode().updateFromJSON(s); } masks the loss: $createCardNode()'s default title/body append refills the dropped children at paste time.
  • $config's auto-injected new CardNode().updateFromJSON(s) has no default-append, so the loss surfaces — empty card on paste, named-root reconcile branch never reached, child DOM-key mapping never registered, click can't resolve.

Verified the children drop directly with $generateJSONFromSelectedNodes. RangeSelection covering all descendants gives the inverse: title/body exported, host itself dropped (RangeSelection.getNodes() excludes the ancestor).

Different concern from this PoC PR — I'm pulling it into its own PR now. Three approaches I see:

approach regression risk BC side effect
change LexicalNode.isSelected high breaking (public method) useLexicalNodeSelection outline, node-removal path
special-case $appendNodesToJSON low non-breaking element-decorator hosts only
per-node export/import hook none non-breaking boilerplate per consumer

Going with the second — $appendNodesToJSON already walks children, just needs the element-decorator branch (force-include named-root descendants on NodeSelection, force-include the host when its descendants are fully covered by a RangeSelection). isSelected semantics stay put. Does that match what you'd do, or would you rather route it through a per-node hook?

Will continue the $config migration here once the clipboard PR lands.


On (2): a few framework-agnostic shapes are possible — two look worth pursuing:

Per-node mount / update / unmount hooks. Node config exposes onMount(hostDom, node, editor) / onUpdate(hostDom, node, prev, editor) / onUnmount(hostDom, node, editor). No return value, no opaque blob for the editor to interpret. Each node decides its own framework inside the callbacks — vanilla just touches hostDom, React creates a root in onMount and unmounts in onUnmount. Framework mixing is free because the editor never sees a framework-specific shape.

Two layers — a typed $decorate return + framework adapter packages. Core stays close to today's structure ($decorate already returns unknown, useReactDecorators is already a React-only adapter). Finishing this means pinning the return contract and treating useReactDecorators (or a reactDecorator(<C/>) helper form) as the official React adapter, with lexical-svelte etc. mirroring it.

Two other shapes I sketched out drop pretty quickly:

  • $decorate returning a DOM Element directly puts too much lifecycle burden on every consumer — they'd have to wire up createRoot / unmount themselves at the call site, even for the React-only case the PoC is built around.
  • Keeping the current unknown return as-is keeps the discriminator problem you flagged on DecoratorNode.decorate — the editor (or its adapter) still has to recognize the value's shape per framework.

Between the two viable ones: the discriminator problem hits the typed-return / adapter shape but not the lifecycle-callback shape — the typed return still hands an opaque value the adapter has to recognize (with a typed contract around it); the callbacks drop the return entirely and never have to discriminate. So the callback shape reads closer to your mutation-listener hint and the "frameworks could be mixed" goal.

Trade-off is ergonomics — callbacks mean each React consumer carries createRoot / key→root map / unmount cleanup boilerplate. That could be absorbed by a reactDecorator({onMount, onUpdate, onUnmount}) helper inside lexical-react that wraps the three callbacks. That lands the callback shape with adapter-style ergonomics. Want me to head there, or do you want the lifecycle exposed raw so each consumer wires its framework directly?

@etrepum

etrepum commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

I’d lean towards the lower-level lifecycle. Reducing the boilerplate should be straightforward with helper functions and/or having a way to configure the framework extension to handle that node’s lifecycle.

@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

On the clipboard fix — depends on this PR's $decorate / $namedRoots surface, splitting it ends up as dead code or a stacked PR. Folding it back here as a separate commit. Sorry for the back-and-forth.

On the lifecycle: going the lower-level callback route. Clipboard commit goes in first, lifecycle refactor next — both in this PR.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall this looks like an interesting direction, using hidden placeholders to render nodes that aren't yet mounted on the React side seems clever. I was thinking we might have to have a situation where these children don't have DOM but still need to be preserved by GC. Might be an interesting optimization someday (could be used to avoid DOM for collapsed or offscreen content for example) but I think this fits the current model better

Comment thread packages/lexical-playground/src/plugins/CardExtension/index.tsx Outdated
@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Hadn't thought of it that way, but yeah — neat direction for lexical to go someday.

@mayrang

mayrang commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Took a stab at the follow-up — added $childSelection and $shouldIncludeAfterChildren to EditorDOMRenderConfig for the host cases the existing surface couldn't express. Clipboard's element-decorator hardcode now lives in CardExtension's domOverride. Different shape welcome.

Separate fix in the next commit: Enter on a host NodeSelection and click below a trailing host both left the editor stuck (host isn't a DecoratorNode or shadow-root ElementNode, so neither the Enter handler nor ClickAfterLastBlockExtension picked it up). Both check $namedRoots now.

@potatowagon

Copy link
Copy Markdown
Contributor

Automated Review — $decorate hook for ElementNode

Reviewer: Tater Thoughts Bobblehead (potatowagon's Navi)

What I Verified

Architecture: Deferred-placeholder pattern is sound — children mount into hidden div until React announces real container, then relocate via DOM ops only (no editor.update loop). GC cleanup sweeps orphaned _pendingNamedRoots.

Capture-guard symmetry: Create ($mountNamedRootChildren) and reconcile paths use matching guard pairs, preventing text-content cache leaks.

Clipboard correctness: $childSelection(null) for NodeSelection hosts opts all descendants into export. $shouldIncludeAfterChildren promotes wrapper when every child included.

React strict-mode resilience: notifyNamedRootMounted handles identity-dedup, container swap, and queueMicrotask selection re-anchor.

CI: All core + canary e2e green.

Test coverage: ClipboardElementDecoratorHost.test.ts covers NodeSelection, RangeSelection promotion, and round-trip.

No regressions: All new hooks have identity defaults in DEFAULT_EDITOR_DOM_CONFIG.

Assessment

Code quality is excellent — thorough JSDoc, sound architecture, proper cleanup. API surface still actively evolving (latest commit <1hr ago). Safe to merge once maintainers signal readiness on final API names.

@mayrang mayrang changed the title [lexical][lexical-html][lexical-playground] Feature: $decorate hook for ElementNode [lexical][lexical-html][lexical-react][lexical-playground] Feature: Framework-agnostic ElementNode lifecycle hooks ($onDOMMount/$onDOMUpdate) + reactDecorator helper Jun 1, 2026
claude and others added 17 commits June 14, 2026 21:18
…slot host

INSERT_CARD/REVIEW/PULLQUOTE went through $insertNodeToNearestRoot, which
splits the current block and leaves an empty paragraph before the host. Now
that ArrowUp at the host's top edge inserts a paragraph on demand, that leading
blank is just a stray empty line. Add a shared $insertSlotHostAtRoot that drops
it (keeping the trailing paragraph, where the caret lands) and use it from all
three insert commands.

Adjust the Review HTML round-trip test: with the Review now the first block, it
starts the document-scoped Cmd+A from the trailing paragraph, and clears via
the editor API (a document-wide range Backspace leaves a first-block
shadow-root host in place). The "text around the Review" test types into the
trailing paragraph instead of a no-longer-present leading one.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…delete

A document-wide select-all of a first-block host (e.g. a Review, now the first
block since insertion seeds no leading paragraph) only cleared the host's
contents on Backspace, leaving the empty shell, because the selection's start
sat inside the host. Re-anchor that start point to just before the host in its
parent when it sits exactly at the host's first region and the selection
extends out of the host, so the default delete removes the whole node and
replaces it with a paragraph in one press.

A selection that ends inside the host (a slot-scoped select-all of the Review's
author, a partial body selection) is left untouched, so it still just clears
that content. The Card's first region is its title slot while select-all
anchors in the body, so the re-anchor is a no-op there (the Card has
chrome-click selection to delete it).

Rename registerEmptyHostBackspace to registerSlotHostBackspace to cover the
range case alongside the empty-host edges.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…d editability

Apply a slot container's editability on every (re)mount rather than only on
create: split it out of $createSlotContainer into $applySlotEditable and call it
in both the mount and the reconcile-reuse paths, so a reused container can never
keep a stale contentEditable / data-lexical-slot-editable marker (e.g. after a
$getSlotEditable override changes value). Add a public markSlotEditable(element,
editor) helper so the reconciler and an app-attached island (the Review's
getDOMSlot children element) share one tagging implementation and can't drift,
and apply the current editable state once on SlotEditableExtension registration
since registerEditableListener does not fire on register. Document the
editable-state model in the named-slots concept page.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
- Re-anchor a select-all that spans a first-block host using the host's first
  navigable descendant, so the Card (title slot first in DOM, body first in
  navigation) is replaced by a paragraph in one press like the Review.
- Also run the range re-anchor on forward Delete, not just Backspace.
- Give PullQuote the empty-host Backspace delete (registerSlotHostBackspace),
  matching Card / Review.
- Bail $handleCardArrow on alt/meta arrows so an OS word/line jump into a Card
  isn't swallowed by NodeSelection promotion.
- Document the Review's deliberate lack of a whole-node NodeSelection gesture.

Tests: Card select-all replace, Review forward-Delete replace, empty-PullQuote
backspace; drop the now-no-op makeHostEdgeBlock('first') setup. Green on
chromium and firefox.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…board refinements

- Rename $createSlotContainer to createSlotDOM: it only builds the placeholder
  DOM and touches no Lexical state, so it carries no $ prefix.
- SlotEditableExtension uses registerRootListener (alongside the editable
  listener) instead of an eager apply-on-register, so the tagged containers are
  synced whenever a root is present — including initial attach and root changes.
- Use isModifierMatch(event, {}) in $handleCardArrow and the slot-host arrow
  escape so only a plain arrow acts and any modifier (word/line/doc jump or
  selection extend) passes through to the browser.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…t editability with cascade

Resolve a named-slot island's editable state through the model rather than a
DOM-querying extension. `$resolveSlotEditable(node, editor)` walks from the
slotted node up through its hosts to the root, honoring each `$getSlotEditable`
override and falling back to `editor.isEditable()`. This is portal-safe (the
walk follows the model, not the DOM) and cascades: a slot pinned editable in a
read-only editor keeps the slots nested within it editable unless they pin
their own value.

- Rename `markSlotEditable(element, editor)` -> `$markSlotEditable(element,
  node, editor)`, resolving via `$resolveSlotEditable`; drop the
  `data-lexical-slot-editable` marker.
- The reconciler applies it to slot-container islands and ReviewNode applies it
  to its getDOMSlot body (createDOM + updateDOM).
- `setEditable` re-renders slot islands via `$fullReconcile()` (gated on
  `_slotsUsed`), so a read-only toggle reaches them with no extension. Remove
  `SlotEditableExtension` (broken by portals).
- `$fullReconcile(node)` gains a subtree form that dirties a host and all of its
  children/slot children without cloning, so a local override change re-resolves
  the whole subtree (cascade included) without a mutation/collab listener firing.
- PullQuote demo: a tri-state (null/true/false) `quoteEditable` NodeState pins
  its quote slot via a `$getSlotEditable` override, toggled from a chrome button
  that calls `$fullReconcile(node)`; chrome controls opt out of host-chrome
  selection via `data-chrome-control`.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
Explain on registerHostChromeSelection why an interactive control in a host's
chrome must carry data-host-control, and why the opt-out is explicit rather than
inferred from interactivity (a host may contain interactive content like a
LinkNode whose clicks must not be swallowed).

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
… hooks

These render-config hooks were introduced earlier in this PR for a host that
owned its own framework root, but the demo that used them was replaced by the
Review (which keeps its chrome in the main React tree via a portal). With no
consumer left they are dead, experimental surface that still costs the
reconciler a per-node call, an `_onDOMMountCleanup` map plus its teardown in
$destroyNode/resetEditor, and the lexical-html override plumbing. Remove them;
they can return with a concrete framework-owns-host-DOM integration and tests.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
The toggle floated into the quote body's text flow, so the editable body and
its focus outline drew over it and clipped the label. Lay the chrome out as a
column and give the toggle its own right-aligned row above the quote.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
Each LexicalCommand carries its payload type as a phantom type, so
registerCommand infers it — the explicit <KeyboardEvent | null> / <void> /
<MouseEvent> generics on the slot-host command registrations were redundant.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…follow the editor

Event handling isn't set up to properly support editable islands inside a
non-editable editor, so drop the per-slot editable override for now (it can
return as a follow-up). Slot islands now simply follow `editor.isEditable()`.

- Remove the `$getSlotEditable` render-config hook (core + lexical-html
  override compilation) and the `$resolveSlotEditable` model/cascade resolver;
  `$markSlotEditable(element, editor)` just sets contentEditable from
  `editor.isEditable()`.
- Revert `$fullReconcile` to the no-arg full-reconcile form (the subtree variant
  existed only for the local override toggle).
- Remove the PullQuote tri-state editable toggle (NodeState, chrome button,
  `$getSlotEditable` override, CSS) and the now-dead `data-host-control`
  chrome-control opt-out.
- Trim the editable-island unit tests to the follow-the-editor toggle case and
  update the named-slots docs.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…lotContainer @internal

- Fix a dangling JSDoc @link (registerEmptyHostBackspace -> registerSlotHostBackspace).
- Document $getSlotNameWithinHost in the named-slots concept page (it is the
  reverse of $getSlotHost and is consumed cross-package by lexical-devtools).
- Mark $getSlotContainer @internal and drop it from the public surface: its only
  callers are mountSlotContainer and a unit test (now importing it directly).
  (getDeclaredSlots stays public — it is imported by @lexical/yjs.)

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
A host class's `$config().slots` now drives editor autocomplete on the slot
accessors. `LexicalNode.config` gains a `const` type parameter so the declared
array is preserved as a tuple, and a new `SlotName<T>` type extracts it as a
literal union via the `"a" | "b" | (string & {})` pattern — the declared names
surface as suggestions while every string is still accepted (slots take
undeclared names at runtime). Only the node's own declaration is read, so a
subclass that inherits slots without redeclaring degrades to plain `string`.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
Reuse the host's `__slots` field name as the slots channel's Yjs attribute key.
`__slots` is already excluded from the property->attribute sync (it is the slot
Map field), so that single exclusion now also reserves the channel — dropping
the separate bare `slots` exclusion. As a bonus this removes a breaking change:
a custom node field literally named `slots` (no `__`) keeps syncing, since only
the framework-prefixed `__slots` is reserved now. Tests reference SLOTS_ATTR_KEY
so they track the key.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Going to do another thorough read-through and hopefully get some other feedback before merge, but I think this is ready

claude added 5 commits June 15, 2026 17:44
Two review follow-ups now that the channel key is `__slots`:

- Comments/test descriptions that named the collab attribute the bare `slots`
  key now say `__slots` (or `SLOTS_ATTR_KEY` where the bare `__slots` would be
  ambiguous with the node's slot-map field — e.g. `parentSub === SLOTS_ATTR_KEY`,
  "SLOTS_ATTR_KEY is a reserved key"). References to a node's own `slots` config
  field / a user field named `slots` are left untouched.
- Route the three V2 `setAttribute(SLOTS_ATTR_KEY, … as any)` sites through the
  existing `setSlotsAttr` helper (made generic over the Y.Map value type, since
  `Y.Map<T>` is invariant), removing three redundant `as any` casts + their
  eslint-disable/TODO comments. The single encapsulated cast in `setSlotsAttr`
  remains — yjs types an attribute value as `string`, so storing a nested Y.Map
  needs one cast.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
The named-slots docs claimed the imperative mount primitives read through
`editor.readPending` (so a mid-update call observes pending state without
forcing a flush), but `mountSlotContainer` actually read the committed state via
`getEditorState().read(..., {editor})` and `readPending` — added in this PR for
exactly this — had no production caller (only its own unit test).

Switch `mountSlotContainer` to `editor.readPending`, which is identical for the
common post-commit caller and correct for a mid-update one. Fix the doc to say
only `mountSlotContainer` reads state (through readPending); `unmountSlotContainer`
takes the container it is given and only touches the DOM.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
Revert mountSlotContainer to read the committed editor state
(`editor.getEditorState().read`) rather than `editor.readPending`: it resolves a
slot's container via `getElementByKey`, which reflects the reconciled (committed)
DOM, so the model it reads must be the committed one — reading the pending state
would resolve nodes whose DOM hasn't been reconciled yet. Correct the named-slots
doc accordingly (it had claimed the helpers read through `readPending`):
`mountSlotContainer` reads the committed state to match the reconciled DOM, and
`unmountSlotContainer` takes the container it is given and only touches the DOM.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
…e/happy-planck-7ogimy

# Conflicts:
#	packages/lexical/src/__tests__/unit/LexicalUtils.test.ts
…ls to read('latest', ...)

facebook#8702 (now merged) added the `editor.read('latest', fn)` overload, equivalent to
`editor.getEditorState().read(fn, {editor})`. Port the two reads this PR
introduced to the new idiom: `mountSlotContainer` (was already `{editor}`, exact
equivalent) and ReviewExtension's mutation-listener nodeMap read (now also gets
the editor context, which is harmless here).

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good on my end :shipit:

The editable-islands test referenced SlotEditableExtension, which never
shipped (it was removed earlier in this PR). Describe the behavior
directly instead: islands always follow the editor, and the reconcile
that setEditable schedules re-applies the container's contentEditable.

https://claude.ai/code/session_0125UE7Cv1mUaM82UH8N1nJX
@mayrang

mayrang commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Thank you. Learned a lot watching the work go in.

@etrepum

etrepum commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Really appreciate all of the work you did building the majority of this, I just polished it up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Custom Lexical Node Children Proposal: DecoratorElementNode

5 participants