Skip to main content

Legacy HTML and JSON Serialization

This page covers serialization for nodes and editors that don't use the newer APIs described in Serialization: HTML conversion defined on each node class with exportDOM() and importDOM(), and JSON methods written by hand. It all still works, and you will see it in existing code, but new code should use $config serialization schemas for JSON, and DOMRenderExtension and DOMImportExtension for HTML.

Legacy HTML export​

$generateHtmlFromNodes(editor, selection) asks each node for its HTML by calling its exportDOM() method, unless a DOMRenderExtension override handles the node first.

LexicalNode.exportDOM()​

You can control how a LexicalNode is represented as HTML by adding an exportDOM() method.

exportDOM(editor: LexicalEditor): DOMExportOutput

When transforming an editor state into HTML, we simply traverse the current editor state (or the selected subset thereof) and call the exportDOM method for each Node in order to convert it to an HTMLElement.

Sometimes, it's necessary or useful to do some post-processing after a node has been converted to HTML. For this, we expose the "after" API on DOMExportOutput, which allows exportDOM to specify a function that should be run after the conversion to an HTMLElement has happened.

export interface DOMExportOutput {
after?: (
generatedElement: HTMLElement | DocumentFragment | Text | null | undefined,
) => HTMLElement | DocumentFragment | Text | null | undefined;
element: HTMLElement | DocumentFragment | Text | null;
}

If the element property is null in the return value of exportDOM, that Node will not be represented in the serialized output.

Legacy HTML import​

The legacy import entry point is $generateNodesFromDOM(editor, dom) from @lexical/html. It converts each DOM node using the static importDOM() methods of the editor's registered node classes, plus the editor's html config.

import {$generateNodesFromDOM} from '@lexical/html';

editor.update(() => {
// In the browser you can use the native DOMParser API to parse the HTML string.
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString, 'text/html');

// Once you have the DOM instance it's easy to generate LexicalNodes.
const nodes = $generateNodesFromDOM(editor, dom);

// Select the root
$getRoot().select();

// Insert them at a selection.
$insertNodes(nodes);
});

LexicalNode.importDOM()​

You can control how an HTMLElement is represented in Lexical by adding an importDOM() method to your LexicalNode.

static importDOM(): DOMConversionMap | null;

The return value of importDOM is a map of the lower case (DOM) Node.nodeName property to an object that specifies a conversion function and a priority for that conversion. This allows LexicalNodes to specify which type of DOM nodes they can convert and what the relative priority of their conversion should be. This is useful in cases where a DOM Node with specific attributes should be interpreted as one type of LexicalNode, and otherwise it should be represented as another type of LexicalNode.

type DOMConversionMap = Record<
string,
(node: HTMLElement) => DOMConversion | null
>;

type DOMConversion = {
conversion: DOMConversionFn;
priority?: 0 | 1 | 2 | 3 | 4;
};

type DOMConversionFn = (element: HTMLElement) => DOMConversionOutput | null;

type DOMConversionOutput = {
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
forChild?: DOMChildConversion;
node: null | LexicalNode | Array<LexicalNode>;
};

type DOMChildConversion = (
lexicalNode: LexicalNode,
parentLexicalNode: LexicalNode | null | undefined,
) => LexicalNode | null | undefined;

@lexical/code provides a good example of the usefulness of this design. GitHub uses HTML <table> elements to represent the structure of copied code in HTML. If we interpreted all HTML <table> elements as literal tables, then code pasted from GitHub would appear in Lexical as a Lexical TableNode. Instead, CodeNode specifies that it can handle <table> elements too:

class CodeNode extends ElementNode {
...
static importDOM(): DOMConversionMap | null {
return {
...
table: (node: Node) => {
if (isGitHubCodeTable(node as HTMLTableElement)) {
return {
conversion: convertTableElement,
priority: 3,
};
}
return null;
},
...
};
}
...
}

If the imported <table> doesn't align with the expected GitHub code HTML, then we return null and allow the node to be handled by lower priority conversions.

Much like exportDOM, importDOM exposes APIs to allow for post-processing of converted Nodes. The conversion function returns a DOMConversionOutput which can specify a function to run for each converted child (forChild) or on all the child nodes after the conversion is complete (after). The key difference here is that forChild runs for every deeply nested child node of the current node, whereas after will run only once after the transformation of the node and all its children is complete.

The html editor config​

The html property in CreateEditorArgs provides an alternate way to configure HTML import and export behavior in Lexical without subclassing or node replacement. It includes two properties:

  • import - Similar to importDOM, it controls how HTML elements are transformed into LexicalNodes. However, instead of defining conversions directly on each LexicalNode, html.import provides a configuration that can be overridden easily in the editor setup.

  • export - Similar to exportDOM, this property customizes how LexicalNodes are serialized into HTML. With html.export, users can specify transformations for various nodes collectively, offering a flexible override mechanism that can adapt without needing to extend or replace specific LexicalNodes.

Key Differences from importDOM and exportDOM​

While importDOM and exportDOM allow for highly customized, node-specific conversions by defining them directly within the LexicalNode class, the html property enables broader, editor-wide configurations. This setup benefits situations where:

  • Consistent Transformations: You want uniform import/export behavior across different nodes without adjusting each node individually.
  • No Subclassing Required: Overrides to import and export logic are applied at the editor configuration level, simplifying customization and reducing the need for extensive subclassing.

Type Definitions​

type HTMLConfig = {
export?: DOMExportOutputMap; // Optional map defining how nodes are exported to HTML.
import?: DOMConversionMap; // Optional record defining how HTML is converted into nodes.
};

Legacy JSON methods​

Before serialization schemas, which need Lexical v0.51.0 or later, each node class wrote its JSON conversion by hand with the methods below. A node that doesn't declare a schema still works this way.

LexicalNode.exportJSON()​

You can control how a LexicalNode is represented as JSON by adding an exportJSON() method. It's important that you extend the serialization of the superclass by invoking super: e.g. { ...super.exportJSON(), /* your other properties */ }.

export type SerializedLexicalNode = {
type: string;
version: number;
};

exportJSON(): SerializedLexicalNode

When transforming an editor state into JSON, we simply traverse the current editor state and call the exportJSON method for each Node in order to convert it to a SerializedLexicalNode object that represents the JSON object for the given node. The built-in nodes from Lexical already have a JSON representation defined, but you'll need to define ones for your own custom nodes.

Here's what an exportJSON for a node like the HeadingNode looks like:

export type SerializedHeadingNode = Spread<
{
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
},
SerializedElementNode
>;

exportJSON(): SerializedHeadingNode {
return {
...super.exportJSON(),
tag: this.getTag(),
};
}

LexicalNode.importJSON()​

You can control how a LexicalNode is deserialized back into a node from JSON by adding an importJSON() method.

export type SerializedLexicalNode = {
type: string;
version: number;
};

importJSON(jsonNode: SerializedLexicalNode): LexicalNode

This method works in the opposite way to how exportJSON works. Lexical uses the type field on the JSON object to determine what Lexical node class it needs to map to, so keeping the type field consistent with the getType() of the LexicalNode is essential.

You should use the updateFromJSON method in your importJSON to simplify the implementation and allow for future extension by the base classes.

And the matching importJSON:

static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
return $createHeadingNode().updateFromJSON(serializedNode);
}

updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedHeadingNode>,
): this {
return super.updateFromJSON(serializedNode).setTag(serializedNode.tag);
}
note

The shipped HeadingNode doesn't write any of these three methods by hand. It declares its tag property once in a serialization schema and the implementations are generated from it.

LexicalNode.updateFromJSON()​

updateFromJSON is a method introduced in Lexical 0.23 to simplify the implementation of importJSON, so that a base class can expose the code that it is using to set all of the node's properties based on the JSON to any subclass.

note

The input type used in this method is not sound in the general case, but it is safe if subclasses only add optional properties to the JSON. Even though it is not sound, the usage in this library is safe as long as your importJSON method does not upcast the node before calling updateFromJSON.

export type SerializedExtendedTextNode = Spread<
// UNSAFE. This property is not optional
{ newProperty: string },
SerializedTextNode
>;
export type SerializedExtendedTextNode = Spread<
// SAFE. This property is not optional
{ newProperty?: string },
SerializedTextNode
>;

This is because it's possible to cast to a more general type, e.g.

const serializedNode: SerializedTextNode = { /* ... */ };
const newNode: TextNode = $createExtendedTextNode();
// This passes the type check, but would fail at runtime if the updateFromJSON method required newProperty
newNode.updateFromJSON(serializedNode);

Dangers of a flat version property​

The updateFromJSON method should ignore type and version, to support subclassing and code re-use. Ideally, you should only evolve your types in a backwards compatible way (new fields are optional), and/or have a uniquely named property to store the version in your class. Generally speaking, it's best if nearly all properties are optional and the node provides defaults for each property. This allows you to write less boilerplate code and produce smaller JSON.

The reason that version is no longer recommended is that it does not compose with subclasses. Consider this hierarchy:

class TextNode {
exportJSON() {
return { /* ... */, version: 1 };
}
}
class ExtendedTextNode extends TextNode {
exportJSON() {
return { ...super.exportJSON() };
}
}

If TextNode is updated to version: 2 then this version and new serialization will propagate to ExtendedTextNode via the super.exportJSON() call, but this leaves nowhere to store a version for ExtendedTextNode or vice versa. If the ExtendedTextNode explicitly specified a version, then the version of the base class will be ignored even though the representation of the JSON from the base class may change:

class TextNode {
exportJSON() {
return { /* ... */, version: 2 };
}
}
class ExtendedTextNode extends TextNode {
exportJSON() {
// The super's layout has changed, but the version information is lost
return { ...super.exportJSON(), version: 1 };
}
}

So then you have a situation where there are possibly two JSON layouts for ExtendedTextNode with the same version, because the base class version changed due to a package upgrade.

If you do have incompatible representations, it's probably best to choose a new type. This is basically the only way that will force old configurations to fail, as importJSON implementations often don't do runtime validation and dangerously assume that the values are the correct type.

There are other schemes that would allow for composable versions, such as nesting the superclass data, or choosing a different name for a version property in each subclass. In practice, explicit versioning is generally redundant if the serialization is properly parsed, so it is recommended that you use the simpler approach with a flat representation with mostly optional properties.