[Breaking Changes][lexical][lexical-yjs][lexical-clipboard][lexical-html][lexical-playground] Feature: Named slots#8603
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Review: $decorate hook for ElementNode + Named RootsAssessment: Impressive work, needs maintainer sign-off on API surface What I verified:
Observations:
Why this needs maintainer sign-off: This introduces new public API surface ( Reviewed by Navi (automated review assistant for @potatowagon) |
|
I haven't done a close look at this yet but I think we should consider two things:
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). |
|
On (1): tried Walked it back across isolation rounds (manual statics one-by-one, unit tests, runtime expando trace). It isn't
Verified the children drop directly with Different concern from this PoC PR — I'm pulling it into its own PR now. Three approaches I see:
Going with the second — Will continue the On (2): a few framework-agnostic shapes are possible — two look worth pursuing: Per-node mount / update / unmount hooks. Node config exposes Two layers — a typed Two other shapes I sketched out drop pretty quickly:
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 |
|
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. |
|
On the clipboard fix — depends on this PR's On the lifecycle: going the lower-level callback route. Clipboard commit goes in first, lifecycle refactor next — both in this PR. |
etrepum
left a comment
There was a problem hiding this comment.
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
|
Hadn't thought of it that way, but yeah — neat direction for lexical to go someday. |
|
Took a stab at the follow-up — added 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 |
Automated Review — $decorate hook for ElementNodeReviewer: 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. AssessmentCode 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. |
…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
"chrome" on a bare DOM attribute reads as browser chrome; the slot-host terminology this helper already uses is clearer. 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
left a comment
There was a problem hiding this comment.
Going to do another thorough read-through and hopefully get some other feedback before merge, but I think this is ready
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
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
|
Thank you. Learned a lot watching the work go in. |
|
Really appreciate all of the work you did building the majority of this, I just polished it up! |
Breaking Changes
All slot machinery is opt-in and gated (an editor latches
_slotsUsedon 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__EXPERIMENTALtakes a newdirtyLeavesparameter, inserted betweendirtyElementsandnormalizedNodes. A DecoratorNode host's slot values surface as dirty leaves, so the recursive dirty walk needs the union of both sets.$getHtmlContent/$generateJSONFromSelectedNodeschanges for any consumer copying element NodeSelections. Partial RangeSelections keep per-child slicing, andexcludeFromCopychildren 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.updatepasses (#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'squoteandattribution) 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 letCmd+Aspill 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/__lastlinked list. A slot value has__slotHostset and__parent === null, with exactly one of the two non-null, sogetParent()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-linetitleslot — 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-blockquoteslot wrapped in a shadow-root container and a single-line bare-paragraphattribution, attached from Reactdecorate()chrome viauseLexicalSlotRef), and Review (an ElementNode whose React presentation goes beyond static chrome: acontentEditable=falseshell that portals in an interactive star-rating widget persisted to NodeState alongside two editable islands — the single-line namedauthorslot attached withuseLexicalSlotRef, and the linked-list body children attached through the same render-hidden-then-attach technique applied to itsgetDOMSlotelement). 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 unrelatedDOMSlotrendering 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 falsehas().Maphas no prototype keys. ($setSlotadditionally rejects the three reserved names outright, and the collab observers validate-and-skip them so a hostile peer can't crash clients.)__slotHostpointer; 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.<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$setSlotchokepoint, so every create / restore path conforms, and inserting a node's own slot ancestor as a child is guarded in both directions (noisAttached/GC cycles).$config()(slots: ['quote', 'attribution']); undeclared names sort after in UTF16 code-unit order.$setSlotre-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 collabY.Mapeagerly at creation, so each name's first set merges per-entry instead of racing on attribute creation.afterCloneFromshares 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.getDOMSlot's control over where children render. The reconciler always renders every slot subtree synchronously, but into adisplay: nonecontainer 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$getSlotTargetElementDOM render-config override (DOMRenderMatchviaDOMRenderExtension/domOverride) for hosts implemented entirely in-lexical — consulted by the reconciler, synchronous within the commit (Card); the framework-independentmountSlotContainer/unmountSlotContainer(mountSlotContainerresolves the container against the committed editor state viaeditor.read('latest', ...), so the model it looks up matches the reconciled DOM it moves;unmountSlotContaineris DOM-only); and lexical-react'suseLexicalSlotRefwrapping them (PullQuote). The revealed state is a normal block, deliberately notdisplay: 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). AcontentEditable=falseElementNode shell can host React chrome for both channels (the Review demo): slot containers opt intocontentEditable=trueinside any non-editable host DOM, such a shell marks itselfsetDOMUnmanagedincreateDOMfor 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.contentEditable=falseelement shell) becomes its owncontentEditableisland so it stays editable there, and it would not trackeditor.isEditable()on its own — so the reconciler sets the container'scontentEditableexplicitly from the editor's state and re-applies it on every reconcile. The reconciler owns this (no DOM-walking extension), sosetEditableschedules a$fullReconcile— gated on_slotsUsedlike 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'sgetDOMSlotchildren element inside itscontentEditable=falseshell), re-applied fromupdateDOMso 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.isAttachedis the GC linchpin. It now follows__slotHostwhen__parentis 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.getTextContent,getAllTextNodes,$dfsWithSlots): "what is in this subtree" has to count slot content for search, copy, and accessibility. Navigation excludes them (getFirstDescendantand friends feedselectStart/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).$removeNodecascade-prunes empty parents, so a slots-only host that lost its last linked-list child would otherwise be reaped and orphan its slots.$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-boundaryshift+arrowand 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_ALLscopes 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 isSelectBlockExtension's job, which is slot-aware ($isBlockFullySelectednever 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.__slotsY.Map.__slotsand__slotHostjoin the synced-property exclusions in both directions, and slots serialize through a per-slot-diffedY.Mapon the host attribute channel on both the V1 (_xmlText) and V2 (XmlElement) paths. (The attribute key reuses the host's own__slotsfield name: it can't be$-prefixed like the$slotsJSON key below —$breaksXmlElement.toDOM— and node properties sync under their__-prefixed names, so reusing the already-excluded__slotsfield name lets that single exclusion reserve the channel too, while a user field literally namedslotskeeps syncing.) The observers route first-set/undo transaction shapes (which surface as a changed__slotskey, not aYMapEvent) 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.$slotskey (declared onSerializedLexicalNode). 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 serializedslotsproperty of its own. NodeKeys aren't serialized, so the name is the identity, and a dedicated key is the natural place for it.<div data-lexical-slot="<name>">inside its own wrapper fromexportDOM(via$appendNodeToHTML), and its import rule maps those wrappers back through$setSlotwith 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 (defineImportRuleviaDOMImportExtension, depending onCoreImportExtensionso the paragraph/text rules exist and the host rule is ordered ahead of the generic block rules); the playground's always-onClipboardDOMImportExtensionroutes pastes through it, so Card / Review / PullQuote round-trip through HTML copy/paste with their slots and per-host state (e.g. the Review'sdata-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.contentEditableisland boundary, which Firefox will not cross, so the step between islanded regions (Review body↔author, PullQuote quote↔attribution) is done with a programmaticselectStart/selectEndthere. 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 → itsquote.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.jsoncleanpnpm flow— no errorspnpm vitest run --project unit— full unit suite green (slot suites + reconciler / selection / node / html / yjs-sync regression), including:<input>-style paste flattening, boundary backspace, scope-root resolution)mountSlotContainer/unmountSlotContainerreveal/park round-trip, the$getSlotTargetElementrender-config override (in-place reveal; reconciles keep mounted containers in their targets), and the mutation-observer scaffolding guardsetEditable, the reconcile re-applying itscontentEditableon a read-only toggle with no extensionY.UndoManagerundo convergence, concurrent first-sets and declared+undeclared adds converging in canonical order, hostile remote entries skipped, remote-deletion collab-map cleanup, root-host slotsSelectBlockExtension× slots compose suite (block → slot → document escalation, frame-aware$isBlockFullySelected, disabled fallback to the scoped default)CardSlot.spec.mjs/PullQuoteSlot.spec.mjs/SlotCollabConvergence.spec.mjs/SelectBlock.spec.mjsgreen; the Linux FirefoxselectAllhelper 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 remainReviewSlot.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 firefoxSlotHostArrowEscape.spec.mjs(ArrowDown/Up escape + cross-island field navigation across Card / Review / PullQuote, wrapped-line offset behavior) green on chromium and firefox<div data-lexical-slot>wrappers stable after re-render; title serializes as a bare paragraph ($slots.title.type === 'paragraph', no container level)quote/attributionslots, slot isolation; Enter inattributionis a no-op; Backspace at the start of the quote's second paragraph merges within the quote slot, doesn't leak outquoteauthorslot edit in isolation; the—author prefix shows in CSS; HTML export → external editor → paste back restores both regions anddata-rating/card/review/pullseeds 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/PullQuotecontentEditableislands 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<input>); internal clipboard round-trips custom slot content, not the defaults; cut/copy of a selection inside a slot round-tripsCmd+Backspaceinside a slot deletes only slot-internal characters, on a NodeSelection deletes the host;Cmd+Ainside a slot stays scoped to that slot (default), table cells keep legacy whole-documentCmd+A; withSelectBlockExtension(playground default) repeatedCmd+Aexpands block → slot → documentCmd+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