From 29adc95f7e741abed13640aa48589bdb0a04d8b1 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Wed, 28 Oct 2026 18:27:25 -0400 Subject: [PATCH 1/2] [lexical-table][lexical-y] Bug Fix: Don't throw when a table exists before its DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Building an editor throws `TableObserver: Expected to find TableElement in DOM for key N` when the editor state already holds a table before `TableExtension` registers and the editor has no root element yet. `registerTableSelectionObserver` registers its `TableNode` mutation listener with `skipInitialization: false`, so it receives `created` for every existing table at registration, and `$getTableAndElementByKey` asserts that the table's DOM exists. Any extension listed before `TableExtension` that sets content in its `register` hits this. For example, `YCollabExtension` reads a `Y.Doc` that already holds a table (a remount, a doc loaded from storage, or a provider that synced first): ```ts buildEditorFromExtensions( defineExtension({ dependencies: [ RichTextExtension, configExtension(YCollabExtension, {doc}), TableExtension, ], name: 'app', }), ); ``` So does any earlier extension that calls `editor.setEditorState(...)` with a table, or a direct call to `registerTableSelectionObserver` on an unrendered editor. Listing `TableExtension` first avoided it, but nothing documented that order. With this change, the observer skips a table that has no DOM and drops any observer it still holds for that key. When the table is rendered, for example by `setRootElement`'s full reconcile, the reconciler reports it as `created`, and the observer attaches then. A table with DOM is handled as before. The fix is in the observer rather than in `@lexical/y`, which could defer its initial read until every extension has registered. A deferred read would change when the binding's content is available to the rest of the editor. It would also leave the observer throwing for every other source of pre-render content. ## Test plan New tests: - `packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts`, with no collaboration: - an extension listed before `TableExtension` sets a state holding a table in its `register`; - the observer attaches when a root element renders the table, and again on the new DOM when the root is replaced; - `registerTableSelectionObserver` on a `createEditor` editor whose table is not rendered yet. - `packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts`: a document that already holds a table opens with either extension listed first. `./node_modules/.bin/vitest --run --project unit packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts` ### Before ``` × builds when an earlier extension sets a state holding a table during register 14ms × observes the table once a root element renders it 1ms × observes the new table DOM when the root element is replaced 1ms × registerTableSelectionObserver accepts an unrendered table 1ms × opens a document that holds a table with YCollabExtension listed first 26ms ✓ opens a document that holds a table with TableExtension listed first 4ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ Error: TableObserver: Expected to find TableElement in DOM for key 19 Error: TableObserver: Expected to find TableElement in DOM for key 49 Error: TableObserver: Expected to find TableElement in DOM for key 79 Error: TableObserver: Expected to find TableElement in DOM for key 91 Error: TableObserver: Expected to find TableElement in DOM for key 15 Tests 5 failed | 1 passed (6) ``` ### After ``` ✓ |unit| packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts (4 tests) 187ms ✓ |unit| packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts (2 tests) 111ms Tests 6 passed (6) ``` Also run with the fix: - `@lexical/table` unit tests: 23 files, 229 tests passed. The new file is 4 of them. - `@lexical/y` unit tests: 149 files passed and 2 skipped; 4,915 tests passed, 16 expected fail, 2 skipped. - Full unit suite: 540 files passed and 3 skipped; 11,754 tests passed, 16 expected fail, 4 skipped. - Playground `Tables.spec.mjs` e2e on Chromium: 86 passed, 1 skipped. - `pnpm run ci-check` passes. - Not run: Firefox and WebKit e2e, and browser-mode unit tests. The change only affects tables that have no DOM, which no browser test renders. --- .changeset/table-observer-unrendered-table.md | 5 + .../src/LexicalTablePluginHelpers.ts | 7 + .../TableSelectionObserverBeforeDOM.test.ts | 149 ++++++++++++++++++ .../src/__tests__/unit/TableOnOpen.test.ts | 78 +++++++++ 4 files changed, 239 insertions(+) create mode 100644 .changeset/table-observer-unrendered-table.md create mode 100644 packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts create mode 100644 packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts diff --git a/.changeset/table-observer-unrendered-table.md b/.changeset/table-observer-unrendered-table.md new file mode 100644 index 00000000000..526ebb21a77 --- /dev/null +++ b/.changeset/table-observer-unrendered-table.md @@ -0,0 +1,5 @@ +--- +'@lexical/table': patch +--- + +[fix] Building an editor no longer throws `Expected to find TableElement in DOM` when the editor state already holds a table before `TableExtension` registers, for example when an extension listed earlier (such as `YCollabExtension` on a document that holds a table) sets the content in its `register`; the table's selection handling attaches when the table is rendered diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index e6b9e8e606a..ece64935bf8 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -362,6 +362,13 @@ export function registerTableSelectionObserver( for (const [nodeKey, mutation] of nodeMutations) { const tableSelection = tableObservers.observers.get(nodeKey); if (mutation === 'created' || mutation === 'updated') { + if (editor.getElementByKey(nodeKey) === null) { + // The table is not rendered, as when the editor has no root + // element yet. The reconciler reports it as created when it + // renders it, and the observer attaches then. + tableObservers.removeObserver(nodeKey); + continue; + } const {tableNode, tableElement} = $getTableAndElementByKey(nodeKey); if (tableSelection === undefined) { diff --git a/packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts b/packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts new file mode 100644 index 00000000000..327261b739b --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createTableNodeWithDimensions, + $isTableNode, + getTableObserverFromTableElement, + type HTMLTableElementWithWithTableSelectionState, + registerTablePlugin, + registerTableSelectionObserver, + TableCellNode, + TableExtension, + TableNode, + TableRowNode, +} from '@lexical/table'; +import { + $createParagraphNode, + $getRoot, + createEditor, + defineExtension, + type LexicalEditor, + type LexicalEditorWithDispose, + mergeRegister, +} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; + +function $appendTable() { + $getRoot().append( + $createTableNodeWithDimensions(2, 2, false), + $createParagraphNode(), + ); +} + +function $getTableKey(): string | null { + const table = $getRoot().getChildren().find($isTableNode); + return table ? table.getKey() : null; +} + +/** Serialized editor state holding one 2x2 table, made by a separate editor. */ +function tableStateJSON(): string { + const source = buildEditorFromExtensions( + defineExtension({dependencies: [TableExtension], name: 'table-source'}), + ); + source.update($appendTable, {discrete: true}); + const json = JSON.stringify(source.getEditorState().toJSON()); + source.dispose(); + return json; +} + +function getRenderedTable( + rootElement: HTMLElement, +): HTMLTableElementWithWithTableSelectionState { + const tableElement = rootElement.querySelector('table'); + assert(tableElement !== null, 'Expected a rendered table'); + return tableElement; +} + +/** + * An extension listed before TableExtension that replaces the editor state + * from its register, as an extension that loads stored content does. + */ +function setStateOnRegister(json: string) { + return defineExtension({ + name: 'set-state-on-register', + register: (editor: LexicalEditor) => { + editor.setEditorState(editor.parseEditorState(json)); + return () => {}; + }, + }); +} + +describe('table selection observer for tables that exist before their DOM', () => { + const cleanups: (() => void)[] = []; + afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup(); + } + }); + + function buildWithTableSetOnRegister(): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [setStateOnRegister(tableStateJSON()), TableExtension], + name: 'state-before-table', + }), + ); + cleanups.push(() => editor.dispose()); + return editor; + } + + function attachRoot(editor: LexicalEditor): HTMLDivElement { + const rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + document.body.appendChild(rootElement); + cleanups.push(() => rootElement.remove()); + editor.setRootElement(rootElement); + cleanups.push(() => editor.setRootElement(null)); + return rootElement; + } + + test('builds when an earlier extension sets a state holding a table during register', () => { + const editor = buildWithTableSetOnRegister(); + expect(editor.read($getTableKey)).not.toBe(null); + }); + + test('observes the table once a root element renders it', () => { + const editor = buildWithTableSetOnRegister(); + const rootElement = attachRoot(editor); + const tableElement = getRenderedTable(rootElement); + const observer = getTableObserverFromTableElement(tableElement); + assert(observer !== null, 'Expected the rendered table to be observed'); + expect(observer.tableNodeKey).toBe(editor.read($getTableKey)); + }); + + test('observes the new table DOM when the root element is replaced', () => { + const editor = buildWithTableSetOnRegister(); + const firstTable = getRenderedTable(attachRoot(editor)); + editor.setRootElement(null); + const secondTable = getRenderedTable(attachRoot(editor)); + expect(secondTable).not.toBe(firstTable); + expect(getTableObserverFromTableElement(firstTable)).toBe(null); + expect(getTableObserverFromTableElement(secondTable)).not.toBe(null); + }); + + test('registerTableSelectionObserver accepts an unrendered table', () => { + const editor = createEditor({ + nodes: [TableNode, TableRowNode, TableCellNode], + onError: error => { + throw error; + }, + }); + editor.update($appendTable, {discrete: true}); + cleanups.push( + mergeRegister( + registerTablePlugin(editor), + registerTableSelectionObserver(editor), + ), + ); + const rootElement = attachRoot(editor); + expect( + getTableObserverFromTableElement(getRenderedTable(rootElement)), + ).not.toBe(null); + }); +}); diff --git a/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts new file mode 100644 index 00000000000..2b32cb0ef4c --- /dev/null +++ b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// @vitest-environment node + +// An editor bound to a document that already holds a table opens with the +// table, wherever YCollabExtension and TableExtension sit in its +// dependencies. The binding reads the document when it registers, so with +// YCollabExtension listed first the table is in the editor state before +// TableExtension registers, and before there is a root element. + +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, + TableExtension, +} from '@lexical/table'; +import * as Y from '@y/y'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + configExtension, + defineExtension, +} from 'lexical'; +import {expect, test} from 'vitest'; + +import {YCollabExtension} from '../..'; +import {seedYState} from '../../headless'; + +function $appendTable() { + $getRoot().append( + $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('cell')), + ), + ), + ), + $createParagraphNode(), + ); +} + +test.each(['YCollabExtension', 'TableExtension'] as const)( + 'opens a document that holds a table with %s listed first', + first => { + const doc = new Y.Doc(); + Y.applyUpdate( + doc, + seedYState( + {extensions: [RichTextExtension, TableExtension]}, + $appendTable, + ), + ); + const collab = configExtension(YCollabExtension, {doc}); + const editor = buildEditorFromExtensions( + defineExtension({ + dependencies: + first === 'YCollabExtension' + ? [RichTextExtension, collab, TableExtension] + : [RichTextExtension, TableExtension, collab], + name: '@lexical/y/test/table-on-open', + onError: error => { + throw error; + }, + }), + ); + expect(editor.read(() => $getRoot().getTextContent())).toContain('cell'); + editor.dispose(); + }, +); From c884a7159802cf7b0c02f9dc932c7ec695e535a6 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Thu, 29 Oct 2026 10:54:43 -0400 Subject: [PATCH 2/2] [lexical-y] Chore: Drop the redundant onError from the TableOnOpen test ## Description Review feedback on this PR: `buildEditorFromExtensions` already throws from `onError` by default, so the test's own `onError: error => { throw error; }` is redundant. Removed it. The `createEditor` test in `TableSelectionObserverBeforeDOM.test.ts` keeps its `onError`, since `createEditor`'s default only logs. ## Test plan ### After ``` $ npx vitest run --project unit packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts packages/lexical-table/src/__tests__/unit/TableSelectionObserverBeforeDOM.test.ts Test Files 2 passed (2) Tests 6 passed (6) ``` --- packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts index 2b32cb0ef4c..20730cc3aee 100644 --- a/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts +++ b/packages/lexical-y/src/__tests__/unit/TableOnOpen.test.ts @@ -67,9 +67,6 @@ test.each(['YCollabExtension', 'TableExtension'] as const)( ? [RichTextExtension, collab, TableExtension] : [RichTextExtension, TableExtension, collab], name: '@lexical/y/test/table-on-open', - onError: error => { - throw error; - }, }), ); expect(editor.read(() => $getRoot().getTextContent())).toContain('cell');