[lexical-playground][lexical-html][lexical-extension] Refactor: Migrate playground HTML import/export to the DOMImportExtension pipeline#8590
Merged
Conversation
…mportExtension Replace the legacy static importDOM machinery with the new DOMImportExtension pipeline across every playground node and remove the html config from the editor: - Drop every `static importDOM` / `$config().importDOM` on playground nodes and export an equivalent `defineImportRule(...)` from each node file (Image, Tweet, YouTube, Figma-via-rendered-iframe, Equation, Mention, Poll, DateTime, PageBreak, Excalidraw, LayoutContainer, LayoutItem, CollapsibleContainer/Content/Title). - Bundle the rules into a new `PlaygroundImportExtension` together with a playground-specific inline-style overlay that mirrors the legacy `buildHTMLConfig`'s span-style wrapping for font-size, color, and background-color. - Move the legacy `<p>` -> `<div role="paragraph">` export fixup for ParagraphNode out of `html.export` and into a `PlaygroundDOMRenderExtension` using `DOMRenderExtension` overrides. - Wire the per-package import extensions (Core, RichText, List, Link, Table, Code, HorizontalRule) and `ClipboardDOMImportExtension` into the AppExtension dependency graph, and delete `buildHTMLConfig.tsx` along with the `html: buildHTMLConfig()` field on the editor. - Update unit tests that exercised the legacy `$generateNodesFromDOM` path to drive the new `$generateNodesFromDOMViaExtension` pipeline. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…port tests Restore CollapsibleExtension as a dependency of the test extension and move the structural assertions into the same editor.update() block as the import. Inside the update, registered node transforms haven't run yet, so we observe the structure the importer produced without the container-unwrap transform interfering. The no-summary case now asserts the synthesized empty Title is still present pre-transform. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
- Add two tests that read the final state after transforms have run:
a well-formed <details> survives intact, and a <details> with no
<summary> ends up as a bare paragraph after the empty Title,
one-child Container, and orphaned Content all unwrap in turn.
- Rephrase the PlaygroundInlineStyleRule ordering comment to
explain the actual hazard (post-processing styles onto specialized
node descendants) rather than the misleading "intercept" wording.
- Rename the layout-container negative case to describe the new
pipeline ("does not import a div without ...") instead of the
legacy DOMConversion "returns null" terminology.
https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…ceOf Switch LayoutContainerNode import assertions from \`expect(container).toBeInstanceOf(LayoutContainerNode)\` (plus a trailing \`as LayoutContainerNode\` cast) to \`assert(\$isLayoutContainerNode(container), '...')\`, which narrows the type for the follow-up \`.getTemplateColumns()\` call and matches the \$is-guard convention used by the other tests in this file. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…sion
Co-locate every playground node importer with the feature extension
that owns the node, and migrate the remaining React-shaped plugins to
extensions so each registered node has an extension carrying its
importer:
- Rename PollPlugin, EquationsPlugin, LayoutPlugin, MentionsPlugin,
ExcalidrawPlugin to *Extension and turn each one into a
defineExtension (with nodes, commands, transforms, and rule). Plugins
whose work was only registerCommand-via-useEffect (Poll, Equations,
Layout) are gone from Editor.tsx entirely; Mentions and Excalidraw
keep their React UI but the node + rule live on the extension.
- Move every per-node import rule (Page-break, Tweet, YouTube, Image,
Equation, DateTime, Layout, Mention, Excalidraw, Poll, Collapsible)
out of the node files and into the matching feature extension's
configExtension(DOMImportExtension, {rules: ...}). Node files no
longer import defineImportRule.
- Simplify the rules with the new API:
- Use regex captures (Tweet/YouTube) and selector predicates
(DateTime's Google-Docs branch keys off a regex on data-rich-links)
so $convertXxxElement helpers and explicit $next() escape hatches
go away.
- Rewrite the <details> importer on top of BlockSchema +
ImportChildrenOpts.$onChild: BlockSchema wraps inline runs into
paragraphs (no more hand-rolled flushPending), and $onChild siphons
the CollapsibleTitleNode out so the rest stays in `bodyNodes` for
the content split.
- Inline `<details>.open` directly instead of round-tripping through
the always-true undefined check.
- Shrink PlaygroundImportExtension to a single rule (the
font-size/color/background-color span overlay that the legacy
`buildHTMLConfig` carried); everything else now ships from the
feature extensions.
- Switch the LayoutContainerNode test to use $isLayoutContainerNode +
assert (instead of toBeInstanceOf + as cast) and depend on the new
LayoutExtension; also point ComponentPicker, Toolbar, Editor, and
ImageNode at the renamed paths.
https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
The example install step in scripts/__tests__/integration/utils.mjs used `npm install --no-save <tarball>...` to materialize each freshly built monorepo tarball into the example. npm ignores pnpm.overrides, so examples that stub heavy native deps that way (agent-example uses `link:./stubs/empty` for onnxruntime-node and sharp via pnpm.overrides to avoid downloading large native binaries) had those stubs bypassed and the real packages were installed, with their postinstall scripts making outbound HTTP calls that time out in sandboxed CI. Switch buildExample to pnpm: layer the tarball paths onto the example's existing pnpm.overrides (file:<abs-tarball-path>) and run `pnpm install --ignore-workspace` + `pnpm run build`. The example's package.json is restored from the original bytes in a finally block so the working tree stays clean regardless of test outcome. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
Switching the example install step to pnpm dropped transitive monorepo deps out of the example's top-level node_modules (pnpm's default isolated layout puts them under .pnpm/), so the \`installed lexical X.Y.Z\` assertion in describeExample which checks \`node_modules/<dep>/package.json\` for every entry in depsMap (e.g. \`@lexical/internal\`) flipped to false across every example. Pass \`--shamefully-hoist\` (== node-linker=hoisted) so pnpm materializes a flat node_modules tree like npm does, restoring those assertions while keeping the pnpm.overrides for the agent-example onnxruntime / sharp stubs in effect. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
Reverting the \`--shamefully-hoist\` workaround in favor of teaching the assertion about pnpm's default isolated layout. The \`installed lexical X.Y.Z\` check previously poked at \`<exampleDir>/node_modules/<name>/package.json\` for every dep in depsMap, which only happens to work when transitive deps are hoisted to the top of node_modules (npm-style, or pnpm with node-linker=hoisted). Switch to globbing both shapes: direct/symlinked entries at \`node_modules/<name>/\` and isolated copies under \`node_modules/.pnpm/*/node_modules/<name>/\`. The first package.json whose \`name\` + \`version\` matches the freshly built monorepo version wins, so the test asserts what pnpm actually produces instead of pinning a layout. Direct + transitive deps now pass under both npm and pnpm without the shamefully-hoist crutch. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…errides The example uses pnpm.overrides to replace onnxruntime-node and sharp (transitive deps of @huggingface/transformers) with a tiny local stub so npm-style postinstall scripts don't try to download the real ~hundreds-of-MB native binaries. npm honors neither pnpm.overrides nor relative file: paths placed directly in top-level overrides (it resolves them relative to whichever package brings the dep in, not the project root). Add the equivalent shape that npm does understand: - declare onnxruntime-node and sharp as devDependencies pointing at the same `./stubs/empty` directory (npm resolves these from the project root just like any normal file: dependency), - mirror them into top-level `overrides` via the `$onnxruntime-node` and `$sharp` self-references so every transitive use gets the same stub. pnpm continues to use pnpm.overrides (which it already understood) and is unaffected — verified empirically that both pnpm and npm resolve qs/accepts-style transitive deps to the stub when the same dual-field pattern is applied. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…ableExtension The DOM import migration pulled in TableImportExtension, which depends on TableExtension and registers the same observers (registerTablePlugin / registerTableSelectionObserver) that the React `<TablePlugin>` was already registering from Editor.tsx. The two stacks of observers raced on every keystroke and selection change, swallowing typed text inside table cells, hiding the cell action button, and dropping the text-align format set via the table-selection-aware FORMAT_ELEMENT_COMMAND. Drop the React `<TablePlugin>` (and its imports) and instead configure the underlying TableExtension from buildExtensionFromSettings with the same options the React plugin was forwarding (cell merge, background color, horizontal scroll, nested tables). The extension now owns the table observer wiring, so it's only registered once. Also switch the playground's HTML action button from the legacy $generateNodesFromDOM to $generateNodesFromDOMViaExtension so the import side of the HTML round-trip exercises the same DOMImportExtension rules the rest of the app uses — including the playground node rules (DateTime, etc.) that no longer expose static importDOM after the migration. https://claude.ai/code/session_01QRiVwbqog5CGzuGmexcvjG
…l] Bug Fix: Restore legacy block-container import semantics The DOMImportExtension rules dropped four behaviors that the legacy $generateNodesFromDOM provided via wrapContinuousInlines / $unwrapArtificialNodes / LineBreakNode.importDOM, breaking the playground paste e2e suite once it migrated off the legacy importer: - text-align on a block DOM parent (<td>, <ol>, <div>, …) is now propagated onto each block-level Lexical child that doesn't already carry a format. <th> is intentionally excluded (legacy `BLOCK_TAG_RE` omits it), so <th style="text-align: start"><p>…</p></th> still imports as a plain paragraph. - A new ImportInBlockContext flag mirrors legacy `hasBlockAncestorLexicalNode`. ListItemRule, TableCellRule, HeadingRule, QuoteRule, and ParagraphRule set it; the new <div> rule consults it to either wrap children in an ArtificialNode (when nested) or run them through BlockSchema (at the root). - $insertLineBreaksBetweenBlockArtificials reproduces the legacy "insert a <br> between adjacent transparent blocks" behavior for list items. The table cell variant ($packageCellChildren) instead lifts each artificial into its own sibling paragraph, matching the legacy <td>123<div>456</div></td> → <p>123</p><p>456</p> shape. - The <br> import rule now defers to isOnlyChildInBlockNode / isLastChildInBlockNode (now exported from `lexical`) so stray Apple clipboard / trailing-<br> artifacts are dropped before they can survive as LineBreakNodes. - $paragraphPackageRun collapses a sole-LineBreakNode rejected run to an empty paragraph, matching the legacy `selection.insertNodes` shortcut that clipboard pastes ending in <br> rely on.
This file is generated locally by the remote execution environment to point Playwright at the installed chromium binary; it should not be checked into the repo.
Missing space after comma in import statement.
…div> The block-container import rule only matched <div>, so other unconverted block-level elements (<section>, <article>, <header>, <figure>, ...) still fell through to the inline-hoisting fallback and lost their block boundary: sibling <section>s collapsed into one paragraph and any text-align was dropped. The legacy $generateNodesFromDOM applied wrapContinuousInlines to every isBlockDomNode (the BLOCK_TAG_RE set), not just <div>. Replace the <div>-only rule with a sel.any() rule gated on isBlockDomNode that defers (via $next()) for inline elements, so all block-level tags get the same ArtificialNode / ImportInBlockContext / text-align treatment. Higher-priority tag rules (<p>, <li>, <td>, headings, ...) still dispatch first and never reach it. Adds regression coverage for sibling <section>/<article> block separation and text-align propagation on a non-<div> block.
…-table][lexical-playground] Refactor: drop CoreImportExtension from leaf importers Leaf importer extensions (RichTextImportExtension, ListImportExtension, LinkImportExtension, TableImportExtension, HorizontalRuleImportExtension, plus every playground node/plugin extension) used to each re-declare CoreImportExtension as a dependency. That was wasted code size — any application using more than one of them only needs CoreImportExtension once. Move the responsibility for adding it up to PlaygroundImportExtension (which now also aggregates every per-package import extension and the ClipboardDOMImportExtension paste handler), so App.tsx just depends on PlaygroundImportExtension. Unit tests that built bare editors from a single leaf importer now have to add CoreImportExtension explicitly, matching the new contract. Also drop two stray `default` exports on playground React components (MentionsPlugin, ExcalidrawPlugin) and switch their importers to named imports — playground extension modules should export their pieces by name.
…or: drop ArtificialNode__DO_NOT_USE from the new importer The original port of `wrapContinuousInlines` reused the legacy `ArtificialNode__DO_NOT_USE` marker to distinguish "this block came from a transparent block element" from "this block came from a real `<p>`/`<h1>`/`<blockquote>`/…", but that distinction never really mattered to the consumers — every container that cared (list items and table cells) just wanted to know "this is a block boundary". Lower `<div>`/`<section>`/`<article>`/`<header>`/… into a regular `ParagraphNode` in `TransparentBlockRule` (running its children through `BlockSchema` so each inline run is wrapped in its own paragraph, then propagating the element's `text-align`). The enclosing rules then operate on `ParagraphNode` directly: - `ListItemRule` calls a new `$flattenParagraphChildren` helper that unwraps each paragraph and inserts a `LineBreakNode` between adjacent runs, reproducing the legacy `<li>1<div>2</div>3</li>` → `<li>1<br>2<br>3</li>` shape. - `$packageCellChildren` in `TableImportExtension` now treats any non-inline child the same way — `<td>789<div>000</div></td>` still surfaces as `<p>789</p><p>000</p>` without the artificial-node detour. `$insertLineBreaksBetweenBlockArtificials` and `ImportInBlockContext` are gone with the marker — neither has any remaining caller, and the overall change is a net deletion of ~160 lines. The legacy `$generateNodesFromDOM` in `@lexical/html` still uses `ArtificialNode__DO_NOT_USE` for its own pre-existing flow; that is unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…rters Each playground node whose static importDOM was replaced by a defineImportRule now has a DOMImportExtension round-trip test (create → exportDOM → $generateNodesFromDOMViaExtension → assert): Image, Poll, Equation, Mention, DateTime, PageBreak, Tweet, and YouTube. Tests compose each feature extension with PlaygroundImportExtension so the rule-dispatch ordering matches App.tsx (the per-node <span> rules must out-prioritize the core inline-format <span> rule). ExcalidrawNode is left to the e2e suite — importing its extension pulls the @excalidraw/excalidraw UI bundle, which does not resolve under jsdom.
…e core inline-span rule DateTimeExtension and MentionsExtension are listed in AppExtension *before* PlaygroundImportExtension (which brings CoreImportExtension). Because DOMImportExtension rules merged later win dispatch, the core inline-format `<span>` rule was out-prioritizing the more specific `<span data-lexical-datetime>` / `<span data-lexical-mention>` rules — so a re-imported DateTime collapsed into bold/italic text and pasting over a mention misbehaved (HTML "export/import" and Mentions e2e regressions). Give each extension an explicit CoreImportExtension dependency so its rules are always merged after the core rules, independent of where the app lists the extension. (The feature extensions in PlaygroundRichTextExtension already resolve after the import baseline, so this just makes the ordering robust for the two that don't.)
58b058f to
9741e3c
Compare
zurfyx
approved these changes
May 29, 2026
…mode
PlaygroundImportExtension (always in AppExtension) depended on
RichTextImportExtension, which pulls RichTextExtension. That put
RichTextExtension in the graph in *every* mode, so in plain-text mode it
conflicted with PlainTextExtension ("@lexical/plain-text conflicts with
@lexical/rich-text"), the editor failed to build, and every plain-text e2e
test timed out at initialize() waiting for the tree view — which, with CI
retries, ran for hours.
Split the importers so they mirror the node set per mode: the always-on
PlaygroundImportExtension now only carries the plain-text-safe baseline
(Core, Link, Clipboard, the playground inline-style overlay), and a new
PlaygroundRichTextImportExtension (rich-text/list/table/code/horizontal-rule)
is added to PlaygroundRichTextExtension. List and Table are therefore absent
in plain-text, so useSynchronizeSettings now resolves them with the optional
peerOutput (alongside CheckList/CodeHighlight).
Verified: the editor builds in plain-text, previously-failing plain-text specs
(CharacterLimit, …) pass, and rich-text paste is unaffected.
… load The e2e harness waited for the editor's tree-view selector with no explicit timeout, so when the app threw an uncaught error during the editor build (e.g. a conflicting extension set), every test in that mode silently hung until the long per-test timeout -- multiplied across retries and all tests, that could stall a CI shard for hours with no useful signal. Listen for "pageerror" before navigating and race it against the editor-ready wait, so a broken load rejects in milliseconds with the actual uncaught error instead of timing out.
potatowagon
reviewed
Jun 14, 2026
potatowagon
left a comment
Contributor
There was a problem hiding this comment.
Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.
LGTM ✅ — Large refactor migrating playground HTML import/export to DOMImportExtension pipeline.
What this does: Migrates the playground's HTML import/export from the legacy inline approach to the new DOMImportExtension pipeline architecture. This is a substantial refactor (5493 lines across many files) that:
- Moves HTML import/export logic into proper extension-based architecture
- Exports
WatchEditableExtensionfrom@lexical/extension(was previously defined inline in examples) - Removes the dead
flaky:CI job (nowif: false) - Updates the agent-example and svelte-kit examples to use the exported
WatchEditableExtension - Adds onnxruntime/sharp stubs to agent-example (fixing npm/pnpm resolution issues)
What I checked:
- ✅ The
WatchEditableExtensionexport from@lexical/extensionis a new public API surface — good for reuse, well-defined behavior (watches editor editable state via signal). - ✅ Example updates correctly import from the new location instead of defining inline.
- ✅ CI infrastructure change (removing flaky job) is a clean no-op removal.
- ✅ www compat: The new
WatchEditableExtensionexport is additive. Existing imports are unaffected. The DOMImportExtension pipeline is the new recommended approach for custom HTML import — existing legacy approaches still work. - ✅ CI: All checks green.
Safe to land. This is foundational work for the extension-based architecture. The large diff is primarily playground/example restructuring, not library behavior changes.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[lexical-playground] Refactor: migrate playground HTML import to DOMImportExtension
Summary
Completes #8578. Migrates the playground's HTML import from the imperative
buildHTMLConfig/ per-nodeimportDOMapproach to the extension-basedDOMImportExtensionpipeline in@lexical/html, with each feature owning its own import rules. Along the way this restores full parity with the legacy$generateNodesFromDOMoutput, splits the importer set so it mirrors the node set per editor mode, tightens the settings→signals wiring, and makes the e2e harness fail fast when the editor can't build.@lexical/htmlimport pipeline (legacy parity)$generateNodesFromDOMViaExtensionproduces the same tree as the legacy$generateNodesFromDOM.ArtificialNode__DO_NOT_USEfrom the new importer path; nested block/inline grouping is handled by the schema instead.$propagateTextAlignToBlockChildren(@lexical/html) andisLastChildInBlockNode/isOnlyChildInBlockNode(lexicalcore).Per-package importer extensions
RichTextImportExtension,ListImportExtension,TableImportExtension,CodeImportExtension(@lexical/code-core),HorizontalRuleImportExtension, andLinkImportExtensioneach define their own DOM import rules next to the node they import.CoreImportExtension— the app/aggregator configures it once (it will be on by default in the future).@lexical/list: flatten any block-level child of an<li>(not just paragraphs) and preserve nested lists; renamedisBoundary→$isBoundarysince it calls$-functions.Playground feature extensions (named exports)
default/*Pluginexports) and co-locate each feature's import rules with its extension: Images, Poll, Layout, DateTime, PageBreak, Twitter, YouTube,Collapsible, Mention, Excalidraw.
buildHTMLConfig.tsx; addPlaygroundDOMRenderExtension,PlaygroundImportExtension, and round-trip import coverage for the migrated node importers.Plain-text vs rich-text importer split
PlaygroundImportExtensionis the plain-text-safe baseline (core / link / clipboard / playground inline-style overlay), added in every mode.PlaygroundRichTextImportExtensioncarries the rich-text-only importers (rich-text / list / table / code / horizontal-rule) and is added only in rich-text mode. This keeps the importer set aligned with the node set and avoids pullingRichTextExtension(which conflicts withPlainTextExtension) into plain-text editors.Settings → signals synchronization
DynamicSettingsis limited to settings that genuinely require the editor to be re-created (changing the extension set); live-reconfigurable settings (table, list, link, code-highlight, …) no longer rebuild the editor.useSynchronizeSettings()hook, withregisterSettingsSynchronization()applyingINITIAL_SETTINGSsynchronously at editor build (no first-render flash). Optional rich-text-only extensions are resolved with the peer API; always-present ones assert their presence.WatchEditableExtensionto@lexical/extension: editability is now a reactive signal, so "clickable links only when read-only" is a signalseffectinstead ofuseLexicalEditable. The SvelteKit example imports it directly.e2e harness fail-fast + CI config
pageerrorlistener before navigating and races it against the editor-ready wait, so a broken load rejects in milliseconds with the actual error and a hint to check the extension configuration.webServerURL, and enablefullyParallel.Test plan
pnpm run tsc,pnpm run lint, and the unit suites (import-pipeline tests, full@lexical/htmlsuite, new importer round-trip tests) all pass.CharacterLimit(16 passed), rich-textCopyAndPaste(34 passed) andHorizontalRule(7 passed).initialize()reject in ~0.5s with the diagnostic message instead of timing out. Full e2e matrix runs in CI.