Skip to content

[lexical][lexical-html] Feature: Extensible DOM create/update/export#8353

Merged
etrepum merged 47 commits intofacebook:mainfrom
etrepum:claude/remove-dom-import-code-LGqib
Apr 18, 2026
Merged

[lexical][lexical-html] Feature: Extensible DOM create/update/export#8353
etrepum merged 47 commits intofacebook:mainfrom
etrepum:claude/remove-dom-import-code-LGqib

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented Apr 13, 2026

Description

Addresses the createDOM/updateDOM/exportDOM part of #7259 by parameterizing the DOM functionality from the reconciler and @lexical/html to use the editor config via DOMRenderExtension. Part of the thinking here is that we may be able to leverage this work later to make the reconciler itself more flexible.

(Commit history is noisy because this has been collecting dust and had some failed experiments. I used claude to strip it down to what is actually worth adding soon)

Follow-up work (out of scope)

Does not yet consider the different types of export, e.g. export for clipboard vs. export for serialization.

DOMExportExtension currently only has a natural topological sort of overrides by extension, it should also do another sort to pull wildcard and predicate based matches to the highest precedence and then sort by specificity (topological sort of class inheritance) so that SubclassNode overrides have higher precedence than SuperclassNode overrides.

The same sort of approach can probably also be used for other tree based import/export (json, markdown ast, etc.).

$getDOMSlot could possibly be used for nodes other than just ElementNode. The motivating use case there would be able to inject "widget decorators" (in prosemirror terms) that sit before or after specific nodes to facilitate UI that is "outside" of the document. The sorts of things we do now with popovers.

DOMImportExtension - the importDOM code is in a much worse shape and is a lot trickier to augment, I've decided to tackle that separately since the export is in a pretty good state and would solve some current pain points.

EditorDOMRenderConfig

This data structure moves responsibility for all DOM rendering (create/update) and export to a single configuration in the editor. These $createDOM, $exportDOM, $updateDOM, etc. properties are all functions which are eventually responsible for calling their respective node methods (at least by default).

The trick here is that we can wrap these implementations with middleware style functions that can be composed to override or otherwise run code that happens "around" the default implementations. This is very difficult to do with the existing infrastructure, especially when writing code that needs to target all nodes, all nodes with some specific NodeState, etc. Since they are middleware, they have the ability to call the $next() function to get the result of the next implementation (e.g. "calling super" and then enhancing its result) or not (to completely override).

DOMRenderExtension

This provides a mechanism to compile the EditorDOMRenderConfig using composable configuration, targeting either all nodes with '*' or some subset of nodes by an array of NodeClass or $isNodeClass guards. This first pass is a relatively blunt approach but we can build more specific extensions to consolidate this wrapping in the future for optimization reasons.

Examples:

Enhancing TextNode export to remove the 'white-space' style unless it's necessary to support the encoding of the content:
domOverride([TextNode], {
  $exportDOM(node, $next) {
    const result = $next();
    if (
      $getRenderContextValue(RenderContextRoot) &&
      isHTMLElement(result.element) &&
      result.element.style.getPropertyValue('white-space') ===
        'pre-wrap' &&
      // we know there aren't tabs or newlines but if there are
      // leading, trailing, or adjacent spaces then we need the
      // pre-wrap to preserve the content
      !/^\s|\s$|\s\s/.test(result.element.textContent)
    ) {
      result.element.style.setProperty('white-space', null);
      if (result.element.style.cssText === '') {
        result.element.removeAttribute('style');
      }
    }
    return result;
  },
})
Adding an arbitrary id property to any node (e.g. for supporting deep linking)
domOverride('*', {
  $exportDOM(node, $next) {
    const result = $next();
    const id = $getState(node, idState);
    if (id && isHTMLElement(result.element)) {
      result.element.setAttribute('id', id);
    }
    return result;
  },
})

New APIs

All new APIs are marked `@experimental` and are subject to change.

lexical

Export Kind Description
EditorDOMRenderConfig interface Configuration for DOM rendering: $createDOM, $getDOMSlot, $exportDOM, $extractWithChild, $updateDOM, $shouldInclude, $shouldExclude
DEFAULT_EDITOR_DOM_CONFIG const The default config that delegates to node methods
$getEditorDOMRenderConfig(editor?) function Returns the editor's DOM render config (or default)
$isLexicalNode(node) function Type guard for LexicalNode instances
CreateEditorArgs.dom property Optional Partial<EditorDOMRenderConfig> to override rendering at editor creation

@lexical/html

Export Kind Description
DOMRenderExtension extension Compiles DOMRenderMatch overrides into an EditorDOMRenderConfig
domOverride(nodes, config) function Convenience function for constructing DOM overrides with type inference
$generateDOMFromNodes(container, selection?, editor?) function Populate a container with the editor's DOM using EditorDOMRenderConfig
$generateDOMFromRoot(container, root?) function Like above but includes the root node itself
$getRenderContextValue(cfg, editor?) function Read a value from the active render context
$withRenderContext(pairs, editor?) function Execute a callback within a render context
RenderContextRoot const Context state: true when export was initiated from the document root
RenderContextExport const Context state: true during $generateHtmlFromNodes
contextValue(cfg, value) function Create a context pair to set a value
contextUpdater(cfg, updater) function Create a context pair to transform a value
DOMRenderConfig type Configuration for DOMRenderExtension
DOMRenderMatch<T> type Override definition for node rendering/export
NodeMatch<T> type Match a node by class or guard
AnyDOMRenderMatch type Any DOMRenderMatch
DOMRenderExtensionOutput type Output of DOMRenderExtension
RenderStateConfig<V> type Context configuration for render context
AnyRenderStateConfig type Any RenderStateConfig
AnyRenderStateConfigPairOrUpdater type Any setter/updater for RenderStateConfig
ContextPairOrUpdater<Ctx, V> type Set or update a context value

Test plan

Currently it's primarily some minimal tests that show the legacy support works the same way that the legacy code does

Also ships a dev-node-state-style example that demonstrates one of the use cases (having NodeState apply styles to any node both in create and export).

claude and others added 14 commits April 13, 2026 18:48
…via EditorDOMRenderConfig, DOMImportExtension, DOMRenderExtension

Synced with main and consolidated extracted modules back into index.ts. Key changes:
- Add EditorDOMRenderConfig interface and DEFAULT_EDITOR_DOM_CONFIG for pluggable DOM rendering
- Add DOMImportExtension and DOMRenderExtension with context-based override system
- Add EmitterState for stateful block/inline handling during DOM import
- Simplify listeners from Map to Set (remove callback-return pattern)
- Remove resetOnCopyNode mechanism
- Extend DOMExportOutput with append and $getChildNodes hooks
- Remove package-lock files (npm->pnpm migration)

https://claude.ai/code/session_01AnZUp1X3Apkrqq9SDG8e3m
- Remove resetOnCopyNode from StateValueConfig interface
- Remove resetOnCopyNode usage from MarkdownTransformers
- Update LexicalNodeState tests to reflect removal of resetOnCopyNode

https://claude.ai/code/session_01AnZUp1X3Apkrqq9SDG8e3m
These features were added to main after the feature branch diverged.
The previous merge incorrectly treated them as removals. This commit
restores them while keeping the feature branch additions
(EditorDOMRenderConfig, branded types, $getEditorDOMRenderConfig, etc.)

https://claude.ai/code/session_01AnZUp1X3Apkrqq9SDG8e3m
The size check optimization is incorrect because nodes can have
equivalent state even when one has more explicit entries (with default
values) than the other.

https://claude.ai/code/session_01AnZUp1X3Apkrqq9SDG8e3m
Remove the incomplete DOMImport subsystem from @lexical/html while
keeping the DOMRenderExtension which is closer to ready. This removes
DOMImportExtension, DOMImportConfig, EmitterState, ImportContext,
compileDOMImportOverrides, compileLegacyImportDOM, importOverride,
and all associated types, constants, and tests.

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
Sync outdated dependency versions with the rest of the repository:
- shiki/shikijs: ^3.3.0 → ^4.0.2 (matching @lexical/code-shiki)
- react/react-dom: ^19.1.0 → ^19.2.5
- @types/react: ^19.1.2 → ^19.2.14
- @types/react-dom: ^19.1.2 → ^19.1.9
- prettier: ^3.5.3 → ^3.8.1
- vite: ^7.1.4 → ^7.3.2

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
Align all shared dependencies with the root workspace package.json:
- vite: ^7.1.4 → ^8.0.8
- @vitejs/plugin-react: ^5.0.2 → ^6.0.1
- typescript: ^5.9.2 → ^6.0.2
- cross-env: ^7.0.3 → ^10.1.0
- react/react-dom: ^19.1.0 → ^19.2.5
- @types/react: ^19.1.2 → ^19.2.14
- @types/react-dom: ^19.1.2 → ^19.2.3
- prettier: ^3.5.3 → ^3.8.1
- shiki/shikijs: ^3.3.0 → ^4.0.2 (matching @lexical/code-shiki)

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
- @ark-ui/react: ^5.6.0 → ^5.36.0
- lucide-react: ^0.503.0 → ^1.8.0
- inline-style-parser: ^0.2.4 → ^0.2.7
- csstype: ^3.1.3 → ^3.2.3

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 13, 2026

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Apr 17, 2026 6:48pm
lexical-playground Ready Ready Preview, Comment Apr 17, 2026 6:48pm

Request Review

lexical/flow/Lexical.js.flow:
- Complete EditorDOMRenderConfig with $getDOMSlot, $extractWithChild,
  $shouldInclude, $shouldExclude
- Add DEFAULT_EDITOR_DOM_CONFIG, $getEditorDOMRenderConfig, $isLexicalNode

lexical-html/flow/LexicalHtml.js.flow:
- Add all new DOMRender types (DOMRenderConfig, DOMRenderMatch,
  DOMRenderExtensionOutput, RenderStateConfig, NodeMatch, etc.)
- Add contextUpdater, contextValue, domOverride, DOMRenderExtension
- Add $getRenderContextValue, $withRenderContext, RenderContextExport,
  RenderContextRoot
- Add $generateDOMFromNodes, $generateDOMFromRoot, getConversionFunction,
  $unwrapArtificialNodes, $wrapContinuousInlinesInPlace,
  isDomNodeBetweenTwoInlineNodes, IGNORE_TAGS
- Remove stale FindCachedParentDOMNode types

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
claude added 2 commits April 14, 2026 00:20
The root package.json had examples/dev-* in the npm "workspaces" field,
which caused npm to treat dev examples as workspace members. This broke
integration tests because npm install would encounter workspace:*
references from sibling packages and fail with EUNSUPPORTEDPROTOCOL.

The pnpm-workspace.yaml already explicitly excludes examples, so the npm
workspaces entry was unnecessary and conflicting.

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
Generated by build-release for new error messages added in this branch.

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
- Remove extends of root tsconfig.json which caused tsc to type-check
  monorepo package sources and fail on process/global references
- Change build script from vite.config.monorepo.ts to default vite.config
  (monorepo config is for local dev only, matching other examples)

https://claude.ai/code/session_016Y5bfCbn4XZFUTjtAM1UP5
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.

3 participants