From 97e7d225f283f57d8e139195d8a61546fbb39af6 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 23 Aug 2026 14:00:55 +0000 Subject: [PATCH 1/3] [lexical] Feature: build the node with $create when importing JSON (#9090) Co-authored-by: Claude --- packages/lexical/src/LexicalUtils.ts | 24 +++++--- .../src/__tests__/unit/LexicalEditor.test.tsx | 56 +++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index d6eecffb514..49accea0cc1 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -3273,11 +3273,9 @@ export function getStaticNodeConfig( String(klass.length), ); } - // TODO: replace $applyNodeReplacement with $create once `withKlass` is required. klass.importJSON = (ownNodeConfig && ownNodeConfig.$importJSON) || - (serializedNode => - $applyNodeReplacement(new klass()).updateFromJSON(serializedNode)); + (serializedNode => $create(klass).updateFromJSON(serializedNode)); } if (!hasOwnStaticMethod(klass, 'importDOM') && ownNodeConfig) { const {importDOM} = ownNodeConfig; @@ -3349,10 +3347,14 @@ export function getRegisteredSubtypeMap( /** * Create an node from its class. * - * Note that this will directly construct the final `withKlass` node type, - * and will ignore the deprecated `with` functions. This allows `$create` to - * skip any intermediate steps where the replaced node would be created and - * then immediately discarded (once per configured replacement of that node). + * This directly constructs the final `withKlass` node type, skipping the + * intermediate steps where each replaced node would be created and then + * immediately discarded — once per configured replacement of that node. + * + * A deprecated `replace` given without a `withKlass` is the one case that + * cannot be resolved ahead of construction, since only its `with` function + * knows what to build. Such a replacement is still applied, the old way, to + * the node this constructs. * * This does not support any arguments to the constructor. * Setters can be used to initialize your node, and they can @@ -3372,7 +3374,13 @@ export function $create(klass: Klass): T { const registeredNode = editor.resolveRegisteredNodeAfterReplacements( editor.getRegisteredNode(klass), ); - return new registeredNode.klass() as T; + const node = new registeredNode.klass() as T; + // The resolve above follows `withKlass` as far as it goes, so a `replace` + // still set on the node it stopped at has no `withKlass` to follow: it is a + // deprecated one, and its `with` can only be given a constructed node. + return registeredNode.replace === null + ? node + : ($applyNodeReplacement(node) as T); } /** diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 2b888fc4c94..c4dd91369b4 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -28,6 +28,7 @@ import { import {JSDOM} from 'jsdom'; import * as lexical from 'lexical'; import { + $create, $createLineBreakNode, $createNodeSelection, $createParagraphNode, @@ -3992,6 +3993,61 @@ describe('LexicalEditor tests', () => { expect(onError).not.toHaveBeenCalled(); }); + it('$create resolves withKlass directly, and still honors a `with` without one', () => { + // $create looks the replacement class up rather than building the + // replaced node and throwing it away, which is why importing a document + // is not paying for a construction per replaced node. A `with` given + // without a `withKlass` is the case that cannot be resolved ahead of + // construction — only its function knows what to build — so it is + // applied the old way instead of being skipped. + class ResolvedParagraph extends ParagraphNode { + $config() { + return this.config('resolved-paragraph', {extends: ParagraphNode}); + } + } + class LegacyParagraph extends ParagraphNode { + $config() { + return this.config('legacy-paragraph', {extends: ParagraphNode}); + } + } + + const resolved = createTestEditor({ + nodes: [ + ResolvedParagraph, + { + replace: ParagraphNode, + with: () => new ResolvedParagraph(), + withKlass: ResolvedParagraph, + }, + ], + onError: err => { + throw err; + }, + }); + resolved.update( + () => { + expect($create(ParagraphNode)).toBeInstanceOf(ResolvedParagraph); + }, + {discrete: true}, + ); + + const legacy = createTestEditor({ + nodes: [ + LegacyParagraph, + {replace: ParagraphNode, with: () => new LegacyParagraph()}, + ], + onError: err => { + throw err; + }, + }); + legacy.update( + () => { + expect($create(ParagraphNode)).toBeInstanceOf(LegacyParagraph); + }, + {discrete: true}, + ); + }); + it('applies the replacement on import for `with` without `withKlass`', async () => { class CustomParagraphNode extends ParagraphNode { $config() { From c5866e59fda92e263b5b1832e97a33dd4ee3572b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 23 Aug 2026 14:01:00 +0000 Subject: [PATCH 2/3] [lexical-html][lexical-rich-text][lexical-website] Docs: clarify DOMImportExtension rule evaluation order (#9089) Co-authored-by: Claude --- .../__tests__/unit/DOMImportExtension.test.ts | 84 +++++++++++++- .../src/__tests__/unit/DOMPreprocess.test.ts | 37 ++++++ .../src/import/DOMImportExtension.ts | 96 +++++++++++---- .../src/import/compileImportRules.ts | 7 +- .../src/import/defineOverlayRules.ts | 13 ++- packages/lexical-html/src/import/types.ts | 33 ++++-- .../src/RichTextImportExtension.ts | 33 ++++-- .../docs/serialization/dom-import.md | 109 +++++++++++++++--- 8 files changed, 347 insertions(+), 65 deletions(-) diff --git a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 12e69705ecb..4bd499b917c 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -39,6 +39,7 @@ import { $isParagraphNode, $isTextNode, $setState, + type AnyLexicalExtension, configExtension, createState, defineExtension, @@ -355,7 +356,7 @@ describe('DOMImportExtension', () => { }); }); - test('rule priority: later-registered rule runs first; can call $next()', () => { + test('rule priority: the earlier entry in `rules` runs first; can call $next()', () => { using editor = buildTestEditor([ IdAttributeRule, AnchorRule, @@ -370,6 +371,87 @@ describe('DOMImportExtension', () => { }); }); + test('rules contributed by dependents outrank their dependencies', () => { + // A rule that records the extension that contributed it and then + // defers, so the visit order of the whole chain is observable. + const visited: string[] = []; + const traceRule = (name: string) => + defineImportRule({ + $import: (_ctx, _el, $next) => { + visited.push(name); + return $next(); + }, + match: sel.tag('p'), + name: `test/trace-${name}`, + }); + const makeExtension = ( + name: string, + dependencies: AnyLexicalExtension[] = [], + ) => + defineExtension({ + dependencies: [ + ...dependencies, + configExtension(DOMImportExtension, { + // Two rules from the same extension, to show that a + // contribution is inlined as a contiguous chunk. + rules: [traceRule(`${name}-a`), traceRule(`${name}-b`)], + }), + ], + name: `test-${name}`, + }); + const leaf = makeExtension('leaf'); + const mid = makeExtension('mid', [leaf]); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [mid], + name: 'test-root', + nodes: [LinkNode], + }), + // Configuration passed directly to the builder is merged last of + // all, so it outranks every extension's contribution. + configExtension(DOMImportExtension, {rules: [traceRule('builder')]}), + ); + importInto(editor, '

x

'); + // Each extension's chunk keeps its own order (a before b), and the + // chunks are ordered from the most dependent contributor to the + // least. + expect(visited).toEqual(['builder', 'mid-a', 'mid-b', 'leaf-a', 'leaf-b']); + }); + + test('among sibling dependencies, the later-listed one outranks', () => { + // Pins the order that falls out of the topological sort for two + // extensions where neither depends on the other. Documented as an + // implementation detail rather than an API guarantee — an extension + // that must override another's rules should depend on it — but the + // behavior is worth knowing about when debugging dispatch. + const visited: string[] = []; + const traceRule = (name: string) => + defineImportRule({ + $import: (_ctx, _el, $next) => { + visited.push(name); + return $next(); + }, + match: sel.tag('p'), + name: `test/sibling-${name}`, + }); + const makeExtension = (name: string) => + defineExtension({ + dependencies: [ + configExtension(DOMImportExtension, {rules: [traceRule(name)]}), + ], + name: `test-sibling-${name}`, + }); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [makeExtension('first'), makeExtension('second')], + name: 'test-sibling-root', + nodes: [LinkNode], + }), + ); + importInto(editor, '

x

'); + expect(visited).toEqual(['second', 'first']); + }); + test('CSS parser: parseSelector("p.foo") matches as expected', () => { const cssRule = defineImportRule({ $import: () => { diff --git a/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts b/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts index 4e87b505bfc..8b61af54339 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMPreprocess.test.ts @@ -26,6 +26,7 @@ import { $getEditor, $getRoot, $isParagraphNode, + type AnyLexicalExtension, defineExtension, isHTMLElement, type LexicalEditor, @@ -273,4 +274,40 @@ describe('DOMImportExtension preprocess', () => { // Per-call appends to the stack (highest index = runs first). expect(log).toEqual(['per-call', 'config']); }); + + test('preprocessors contributed by dependents run before their dependencies', () => { + const log: string[] = []; + const trace = + (name: string): DOMPreprocessFn => + (_dom, _ctx, $next) => { + log.push(name); + $next(); + }; + const makeExtension = ( + name: string, + dependencies: AnyLexicalExtension[] = [], + ) => + defineExtension({ + dependencies: [ + ...dependencies, + configExtension(DOMImportExtension, { + preprocess: [trace(`${name}-a`), trace(`${name}-b`)], + }), + ], + name: `preprocess-${name}`, + }); + const leaf = makeExtension('leaf'); + const mid = makeExtension('mid', [leaf]); + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [CoreImportExtension, mid], + name: 'preprocess-root', + }), + ); + importInto(editor, '

x

'); + // The stack is run from its end, so the LAST entry of a contribution + // runs first, and a dependent extension's whole contribution runs + // before its dependency's. + expect(log).toEqual(['mid-b', 'mid-a', 'leaf-b', 'leaf-a']); + }); }); diff --git a/packages/lexical-html/src/import/DOMImportExtension.ts b/packages/lexical-html/src/import/DOMImportExtension.ts index 737ad23ea17..cdb6c55ba9d 100644 --- a/packages/lexical-html/src/import/DOMImportExtension.ts +++ b/packages/lexical-html/src/import/DOMImportExtension.ts @@ -41,18 +41,39 @@ import {selBase} from './sel'; */ export interface DOMImportConfig { /** - * The set of rules contributed by this extension and its dependencies. + * The ordered list of rules compiled into the import dispatcher. * Entries can be raw {@link DOMImportRule}s or a * {@link CompiledOverlayRules} produced by {@link defineOverlayRules} - * (the latter is inlined in priority order — useful for libraries - * that already publish a compiled overlay). + * (the latter is inlined at its position in the list — useful for + * libraries that already publish a compiled overlay). * - * Rules are dispatched in priority order: rules contributed by - * extensions merged later (i.e. closer to the editor root) run first - * and may call `$next()` to delegate to lower-priority rules. + * **Rules are evaluated in list order.** For a given DOM node the + * dispatcher visits every rule whose `match` accepts that node, front + * to back, and the first one that returns without calling `$next()` + * decides the outcome. "Higher priority" and "earlier in this list" + * mean the same thing. * - * `mergeConfig` prepends `partial.rules` to existing `rules`, so later - * configuration carries higher priority. + * **Composition prepends.** `mergeConfig` puts `partial.rules` in + * FRONT of the rules accumulated so far, and configs are merged in + * dependency order — a dependency's contribution is merged before the + * contribution of the extension that depends on it. So: + * + * - Each contributor's array is inlined as one contiguous chunk that + * keeps its own internal order: within a single + * `configExtension(DOMImportExtension, {rules})` call the first + * entry has the highest priority. + * - The chunks are ordered from the most dependent contributor to the + * least. An extension's rules therefore outrank the rules + * contributed by its dependencies, and rules passed directly to + * `buildEditorFromExtensions` (merged last of all) outrank every + * extension's. This extension's own default entry + * ({@link DefaultHoistRule}) is the base of the list and so is + * always tried last. + * + * The relative order of two extensions where neither transitively + * depends on the other falls out of the topological sort and is not + * part of the API. Make the intended precedence explicit by having the + * overriding extension depend on the one whose rules it overrides. */ readonly rules: readonly DOMImportRuleEntry[]; /** @@ -62,16 +83,29 @@ export interface DOMImportConfig { */ readonly contextDefaults: readonly ImportContextPairOrUpdater[]; /** - * Functions run in order on the DOM before walking begins, mutating in - * place. The default config registers - * {@link $inlineStylesFromStyleSheets} (resolves `