Storyie uses Lexical for rich-text editing on both web (Next.js) and mobile (Expo). Lexical does not ship an image node, so when diary entries gained photo support we designed one ourselves.
The design had a story attached to it, and the story was reasonable: web renders inside a React tree, so use DecoratorNode and return React from decorate(); mobile runs Lexical inside a "use dom" WebView, so use ElementNode and build the DOM by hand. Split the node class per platform, share the serialization format, and the wire stays compatible.
That is not what the codebase ended up looking like. By the time we audited it, ImageNode existed in three places, and two of them claimed the same node type on the same platform. This post is the correction: what the split was supposed to buy, how it actually failed, and what one shared ElementNode in packages/lexical-common looks like instead.
TL;DR
- Lexical has no built-in image node. The base-class choice —
DecoratorNode(React fromdecorate()) vsElementNode(imperativecreateDOM()) — is yours, and it is load-bearing. - We split it per platform. It drifted to three implementations: a
DecoratorNodeinpackages/lexical-editor, and two near-identicalElementNodecopies inapps/webandapps/expo. - The web editor registered its local
ElementNodewhile the web viewer/SSR path registered the package'sDecoratorNode. Two classes, onegetType()of"image", no error.DecoratorNodeis not anElementNode, so it never wrote achildrenarray — the same content serialized to two different shapes. ElementNodewon because it is the one that is actually platform-agnostic, and because production content JSON was already in its shape. The canonical node now lives inpackages/lexical-common/src/nodes/ImageNode.tsand is registered inlexicalCommonNodes;@storyie/lexical-editorre-exports it.- One piece of the
DecoratorNodesurvived: itsexportDOM(), which emitsdata-width/data-heightandaspect-ratioinstead of inline pixel dimensions — because that is the path that renders public diary pages. - What the original design got right and kept: the shared serialized shape, commands as the insert/update API,
tempIdfor upload correlation, and Markdown export by walking JSON instead of booting a headless editor.
Before | After | |
|---|---|---|
Canonical class | none — 3 implementations |
|
Web editor registered | local | shared |
Web viewer / SSR registered |
| shared |
Expo DOM editor registered | local | shared |
In | no — "platform-specific" | yes |
Why Lexical has no image node
Lexical's built-in node set is intentionally headless-safe: text, paragraph, heading, list, link, code block. These serialize to JSON and render to a DOM without pulling in a rendering framework.
An image node cannot be headless in the same sense, because you have to decide how it renders. Lexical gives you two base classes:
DecoratorNode—decorate()returns a React element (or any framework component) that Lexical mounts into its rendering tree.ElementNode— renders by constructing DOM nodes increateDOM()and diffing them inupdateDOM().
Framing this as "web is React, mobile is a WebView, therefore one of each" is where we went wrong. The real question is not which base class is most comfortable on each platform. It is which base class lets one implementation serve every place that has to understand an image — and that turns out to be a much more restrictive question.
The split we designed, and what it actually became
The intended design put a DecoratorNode on web:
// packages/lexical-editor/src/nodes/ImageNode.ts (deleted in #1280)
decorate(): React.ReactElement {
// Import is done dynamically to avoid circular dependencies
const { ImageRenderer } = require("../components/ImageRenderer.web");
return React.createElement(ImageRenderer, {
src: this.__src,
alt: this.__alt,
// ...
});
}That require() of a .web.tsx file from inside a node method, with a comment explaining it is there to dodge a circular dependency, is the design admitting something. A node class in a shared package should not need to reach sideways into a React component at call time.
Meanwhile, the web editor was not even using it. apps/web/components/editor/nodes/ImageNode.ts was its own ElementNode, and its header said so plainly:
// apps/web/components/editor/nodes/ImageNode.ts (deleted in #1280)
/**
* ImageNode for Lexical editor (Web version)
*
* This node renders images as HTML elements in the browser.
* It uses ElementNode instead of DecoratorNode for simpler DOM rendering.
*
* IMPORTANT: This mirrors the Expo implementation at
* apps/expo/components/lexical/dom/editor/nodes/ImageNode.dom.ts
* to ensure JSON serialization compatibility between platforms.
*/So the per-platform split had already been abandoned in practice on the platform it was designed for. Whoever wrote the web editor's node hit the same conclusion we are describing here — hand-copying the Expo implementation to guarantee JSON compatibility — but solved it by duplication rather than by deleting the DecoratorNode.
Three implementations, then:
Location | Base class | Registered by |
|---|---|---|
|
| web viewer + SSR ( |
|
| web editor |
|
| Expo DOM editor |
The failure mode: two classes, one type name
Lexical identifies nodes by the string returned from static getType(). All three returned "image".
Nothing detects that. Each editor instance registers its own node list, so an editor registering class A and a viewer registering class B both start up fine. They simply disagree about what "image" means.
The disagreement is not cosmetic. DecoratorNode is not an ElementNode, so it has no children — its exportJSON() never emitted a children array, while the ElementNode copies always did. Same diary, same node type, two structurally different JSON payloads depending on which code path wrote it. And on the read side, the class that renders the public page was not the class that produced the content.
The regression test we added names the bug directly:
// apps/web/tests/lib/lexical/image-node-source-of-truth.test.ts (excerpt)
describe("ImageNode source of truth (#1280)", () => {
it("@storyie/lexical-editor re-exports the same ImageNode class as @storyie/lexical-common", () => {
expect(PackageImageNode).toBe(CommonImageNode);
});
it("lexicalConfig registers exactly one node for the 'image' type", () => {
const nodes = (lexicalConfig.nodes ?? []).filter(isNodeClass);
const imageNodes = nodes.filter((Node) => Node.getType() === "image");
expect(imageNodes).toHaveLength(1);
expect(imageNodes[0]).toBe(CommonImageNode);
});
});An identity assertion — toBe, not toEqual — is the whole point. Two classes that behave similarly are exactly the bug we had.
Picking the survivor: production JSON wins
Two ElementNode copies and one DecoratorNode. Which one becomes canonical?
The tiebreaker was not elegance. The two ElementNode copies were byte-identical apart from comments and a type-alias name, and their shape is what production diary content JSON already held. Choosing the DecoratorNode would have meant migrating stored content. Choosing the ElementNode meant deleting code.
There was a second, independent reason. packages/lexical-common is one of Storyie's platform-agnostic packages — no "use client", no next/*, no expo-*, no react-native. A node can live there if it imports only lexical core and touches document lazily inside methods, which is exactly how HeadingNode and QuoteNode already work. ElementNode satisfies that:
// packages/lexical-common/src/nodes/ImageNode.ts (excerpt)
export class ImageNode extends ElementNode {
createDOM(_config: EditorConfig): HTMLElement {
const container = document.createElement("div");
container.className = "lexical-image-container";
const img = document.createElement("img");
img.src = this.__src;
img.alt = this.__alt || "";
// ...
this.applyUploadStatusStyles(container, img);
container.appendChild(img);
if (this.__uploadStatus === "uploading") {
container.appendChild(this.createProgressBar());
}
if (this.__uploadStatus === "failed") {
container.appendChild(this.createErrorOverlay());
}
return container;
}
}document is referenced inside createDOM(), which only runs when something is genuinely rendering. Importing the module in a Node process is safe. The DecoratorNode could never have satisfied this — import * as React from "react" at module scope is a platform dependency no matter how carefully you gate the rest.
So ImageNode moved into lexicalCommonNodes alongside the other shared nodes, and the comment that had justified its absence got deleted:
/**
* プラットフォーム非依存のLexicalノード
- * NOTE: ImageNodeはプラットフォーム固有のため、各パッケージで個別に実装されます
+ * NOTE: ImageNode is the shared ElementNode implementation used by web,
+ * Expo DOM components, and @storyie/lexical-editor.
*/@storyie/lexical-editor now re-exports the shared node rather than defining one, so existing imports keep working.
The one thing we kept from the DecoratorNode
Consolidation is rarely a clean "delete two, keep one". createDOM() came from the ElementNode copies unchanged, but exportDOM() — the HTML export path — kept the DecoratorNode's output:
// packages/lexical-common/src/nodes/ImageNode.ts (excerpt)
exportDOM(): DOMExportOutput {
const element = document.createElement("img");
element.setAttribute("src", this.__src);
element.className = "editor-image";
if (this.__alt) {
element.setAttribute("alt", this.__alt);
}
// Dimensions ride as data attributes plus aspect-ratio so the exported HTML
// stays responsive under `.lexical-content img { max-width: 100%; height: auto }`.
if (this.__width) element.setAttribute("data-width", String(this.__width));
if (this.__height) element.setAttribute("data-height", String(this.__height));
if (this.__width && this.__height) {
element.style.aspectRatio = `${this.__width} / ${this.__height}`;
}
return { element };
}This is the path LexicalServerViewer uses to render public diary pages, and apps/web/components/lexical/style.css sizes those images with max-width: 100%; height: auto. An inline pixel height — which the ElementNode copies emitted — overrides that and squashes the image on narrow viewports. So the responsive-friendly export won even though the rest of the class came from the other side.
Keeping dimensions in data-* attributes creates a second problem: parse that HTML back and an <img> outside a real browser has no intrinsic width/height, so the round-trip loses the dimensions. importDOM gained a matching fallback:
function readDimension(el: HTMLImageElement, intrinsic: number, attr: string): number | undefined {
if (intrinsic) return intrinsic;
const raw = el.getAttribute(attr);
if (!raw) return undefined;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}Copy an image out of the editor and paste it back and the dimensions now survive — a bug that predated the consolidation and only became visible once one class owned both directions.
Old diaries still have to open
Deleting a node class does not delete the content written under it. Diaries persisted while the DecoratorNode was live have no children key on their image nodes.
We did not migrate any rows. We made the reader tolerant and pinned it with a test:
// apps/web/tests/lib/lexical/image-node-source-of-truth.test.ts (excerpt)
it("renders legacy content JSON that has no 'children' key on the image node (back-compat read)", () => {
const legacyImage = imageJson();
delete legacyImage.children;
const html = lexicalJsonToHtml(rootJson([legacyImage]));
expect(html).toContain('class="editor-image"');
expect(html).toContain('data-width="400"');
});The general rule this taught us: when unifying duplicate implementations, the persisted shape is the contract and the class is an implementation detail. Pick the survivor by asking what production data already looks like, then make the survivor read everything the deleted classes wrote.
What the original design got right
Not all of it was wrong. The parts that survived the consolidation untouched are the parts that were never per-platform to begin with.
The serialized shape is still shared — it just got more honest about being an ElementNode. The image-specific fields live on their own, spread into Lexical's element serialization:
// packages/lexical-common/src/nodes/ImageNode.ts
export type SerializedImageNode = Spread<ImageNodeFields, SerializedElementNode>;ImageNodeFields (in packages/lexical-common/src/types.ts) carries src, alt, width, height, caption, uploadStatus, uploadProgress, and tempId; SerializedElementNode contributes type, version, children, format, indent, and direction. The previous hand-written SerializedImageNode declared type and version itself and simply omitted the element fields — which was the type system quietly agreeing with the bug.
Commands are still the insert/update API. INSERT_IMAGE_COMMAND, UPDATE_IMAGE_COMMAND, DELETE_IMAGE_COMMAND, and RETRY_IMAGE_COMMAND live in lexical-common, and callers dispatch them without knowing anything about the node class. This is why the consolidation touched the plugins only lightly: the upload code never named a node implementation.
tempId is still load-bearing. Lexical assigns node keys internally, so an upload callback has no way to name the node it needs to update. The caller generates a correlation ID at insert time, the node carries it through serialization, and the upload closes over it:
// packages/lexical-editor/src/plugins/insertImageWithUpload.ts (excerpt)
export function generateImageTempId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// ...
const id = tempId ?? generateImageTempId();
const objectUrl = URL.createObjectURL(file);
if (!options?.retry) {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
src: objectUrl,
alt: file.name,
uploadStatus: "uploading",
uploadProgress: 0,
tempId: id,
});
}The local preview appears immediately from the object URL and the upload runs behind it, with progress callbacks dispatching UPDATE_IMAGE_COMMAND and a final dispatch swapping in the CDN URL and uploadStatus: "completed". Passing the same id back with options.retry re-runs the upload against the existing node, which is how the retry button works. Without a correlation id, concurrent uploads — routine when pasting several images — have no reliable way to update the right node.
Markdown export still walks JSON directly rather than booting a headless editor. That decision is now better-founded than it was: the original comment justified it by saying ImageNode was platform-specific and therefore un-registerable, which stopped being true. The actual reason is that Markdown export runs server-side in a Lambda with no DOM, so it should not depend on editor registration at all. We updated the comment accordingly, and wrote up the full reasoning separately in Lexical JSON to Markdown Without a Browser.
The rough edge we accepted
One wart is worth admitting. lexicalJsonToHtml() runs exportDOM() in Node, which needs DOM globals — and lexical-common deliberately ships no DOM shim, because shipping one would make it non-platform-agnostic again. So the web side keeps an explicit side-effect import:
// apps/web/lib/lexical/utils.ts
// Side-effect import: @storyie/lexical-editor's server-dom-setup installs the
// happy-dom globals that ImageNode.exportDOM() needs when lexicalJsonToHtml()
// runs in Node. Kept explicit because #1280 moved ImageNode to
// @storyie/lexical-common, which deliberately ships no DOM shim.
import "@storyie/lexical-editor";An import with no binding is the kind of line a future reader deletes. The comment is the only thing keeping it alive, which is not a great mechanism — but it is honest about where the platform dependency lives, which the previous arrangement was not.
Takeaways
The original version of this post argued: split what has to be split, share what must not drift. That is still the right instinct, and we got the second half right — the serialization format, the commands, and tempId never diverged. But the first half was applied on the wrong axis.
What we would say now:
- A per-platform split needs a mechanism that makes drift impossible, not a convention. "This mirrors the Expo implementation" in a header comment is a promise, not an enforcement. Ours held for the fields and broke for the class.
- Two classes returning the same
getType()is a bug, not a design. Lexical will not warn you. If your editor and your viewer register different classes for one type name, the JSON they write and the HTML they render can diverge silently — and silent divergence between what the author wrote and what the reader sees is the worst possible failure for a diary app. - "Platform-specific" is a claim worth re-testing. Ours turned out to mean "imports React", and the moment we picked a base class that did not, one implementation covered web, Expo's WebView, and server-side rendering.
- When merging duplicates, let production data pick the survivor — then make it read everything the deleted implementations wrote, and pin that with a test against the legacy shape.
ElementNode is genuinely less pleasant to write than a React renderer. Progress bars, error overlays, and retry buttons as raw DOM operations are tedious, and updateDOM() diffing by hand is error-prone. We paid that cost deliberately, because it buys one class that every entry point can register — and that was worth more than the ergonomics of decorate().
Related Posts
- Lexical JSON to Markdown Without a Browser — why the Markdown export walks the JSON tree instead of instantiating an editor
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how the full Lexical monorepo is structured, including the"use dom"bridge for image upload - How to serialize Lexical EditorState to JSON — the serialization round-trip this post's contract is built on
- Building a Monorepo with pnpm and TypeScript — the platform-agnostic package rule that decided which base class won
Try Storyie
If you want to see the result from the user side: write a diary with photos on the web at storyie.com and open it on the iOS app. Same content, same images, same formatting — now backed by literally the same class.