Repository: facebook/lexical (TypeScript)

Issue:
Bug: Tables do not horizontally scroll if part of the initial editor state
<!--
  Please provide a clear and concise description of what the bug is. Include
  screenshots if needed. Please test using the latest version of the relevant
  Lexical packages to make sure your issue has not already been fixed.
-->

Lexical version: 0.29.0

## Steps To Reproduce

1. Enable the `hasHorizontalScrolling` prop on the table plugin.
2. Render the Lexical editor with an `editorState` which has a table. 
 
e.g in the lexical-playground - 
```
const initialConfig = {
    editorState: function $prepopulatedRichText() {
      const root = $getRoot();
      if (root.getFirstChild() === null) {
        const tableNode = $createTableNodeWithDimensions(5, 50, true);
        root.append(tableNode);
      }
    },
...
```

2. The table will not have the scroll container - it overflows off the page.  

<!--
  Your bug will get fixed much faster if we can run your code and it doesn't
  have dependencies other than Lexical. Issues without reproduction steps or
  code examples may be closed as not actionable.
-->

Link to code example: [CodeSandbox](https://codesandbox.io/p/devbox/lexical-table-plugin-example-forked-p7mtg9?workspaceId=ws_RiR6eAoPJage2gdVhf4pzc)

<!--
  Please provide a CodeSandbox (https://codesandbox.io/s/new) or (https://stackblitz.com/github/facebook/lexical/tree/main/examples/react-plain-text?embed=1&file=src%2FApp.tsx&terminalHeight=0&ctl=1&showSidebar=0&devtoolsheight=0&view=preview), a link to a
  repository on GitHub, or provide a minimal code example that reproduces the
  problem. You may provide a screenshot of the application if you think it is
  relevant to your bug report. Here are some tips for providing a minimal
  example: https://stackoverflow.com/help/mcve.
-->

## The current behavior

Table overflows off page.

<img width="1192" alt="Image" src="https://github.com/user-attachments/assets/6aff0d28-aaa7-467f-89ec-2a922be7c30c" />

## The expected behavior

Table should have parent div with `table-scrollable-wrapper` - containing the width to the editor.

## Impact of fix

Impacts all consumers of table plugin who will be importing existing content. 

<!--
    - How severe is this bug? How often does it happen? Is it causing an outage somewhere?
    - Who would benefit from this fix? Any specific users, groups or companies?
    - If possible include metrics. Approximately how many users would this fix benefit? 
-->



Discussion:
```ts
editorState: function $prepopulatedRichText(editor: LexicalEditor) {
  setScrollableTablesActive(editor, true);
  …
}
```
> editorState: function $prepopulatedRichText(editor: LexicalEditor) {
>   setScrollableTablesActive(editor, true);
>   …
> }

Thanks @etrepum - that works as a workaround 👍  

I can see the table plugin does this in a `useEffect`, probably just needs to trigger an update on existing tables. 
> editorState: function $prepopulatedRichText(editor: LexicalEditor) {
>   setScrollableTablesActive(editor, true);
>   …
> }
@etrepum thanks for workaround, it works.
here is how it looks in my code now:
```js
const config = {
    ...
    editorState: (editor: LexicalEditor) => {
      // both defaultValue and defaultState are EditorState.toJSON();
      let state: EditorState;
      if (!defaultValue) {
        state = editor.parseEditorState(defaultState);
      }
      state = editor.parseEditorState(defaultValue as SerializedEditorState<SerializedLexicalNode>);
      editor.setEditorState(state);
      setScrollableTablesActive(editor, true);
    },
    ...
};
```
So it's a bug or not? For me it looks like a bug, or at least a place for further improvements.



Patch:
diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts
index 2136adb2dbf..2e13b776890 100644
--- a/packages/lexical-react/src/LexicalTablePlugin.ts
+++ b/packages/lexical-react/src/LexicalTablePlugin.ts
@@ -10,11 +10,13 @@ import type {JSX} from 'react';
 
 import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
 import {
+  $isScrollableTablesActive,
   registerTableCellUnmergeTransform,
   registerTablePlugin,
   registerTableSelectionObserver,
   setScrollableTablesActive,
   TableCellNode,
+  TableNode,
 } from '@lexical/table';
 import {useEffect} from 'react';
 
@@ -53,7 +55,13 @@ export function TablePlugin({
   const [editor] = useLexicalComposerContext();
 
   useEffect(() => {
-    setScrollableTablesActive(editor, hasHorizontalScroll);
+    const hadHorizontalScroll = $isScrollableTablesActive(editor);
+    if (hadHorizontalScroll !== hasHorizontalScroll) {
+      setScrollableTablesActive(editor, hasHorizontalScroll);
+      // Registering the transform has the side-effect of marking all existing
+      // TableNodes as dirty. The handler is immediately unregistered.
+      editor.registerNodeTransform(TableNode, () => {})();
+    }
   }, [editor, hasHorizontalScroll]);
 
   useEffect(() => registerTablePlugin(editor), [editor]);
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts
index b53ffca6e9e..82d981d6c52 100644
--- a/packages/lexical-table/src/LexicalTableNode.ts
+++ b/packages/lexical-table/src/LexicalTableNode.ts
@@ -337,11 +337,15 @@ export class TableNode extends ElementNode {
   }
 
   updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
-    const tableElement = this.getDOMSlot(dom).element;
+    const slot = this.getDOMSlot(dom);
+    const tableElement = slot.element;
+    if ((dom === tableElement) === $isScrollableTablesActive()) {
+      return true;
+    }
     if (isHTMLDivElement(dom)) {
       this.updateTableWrapper(prevNode, dom, tableElement, config);
     }
-    this.updateTableElement(prevNode, this.getDOMSlot(dom).element, config);
+    this.updateTableElement(prevNode, tableElement, config);
     return false;
   }
 


Test patch:
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
index 4de966f3558..59971716a4a 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx
@@ -17,6 +17,7 @@ import {
   $createTableSelection,
   $getElementForTableNode,
   $insertTableColumnAtSelection,
+  $isScrollableTablesActive,
   $isTableCellNode,
   $isTableNode,
   TableNode,
@@ -41,6 +42,8 @@ import {
   invariant,
   polyfillContentEditable,
 } from 'lexical/src/__tests__/utils';
+import {useState} from 'react';
+import {act} from 'shared/react-test-utils';
 
 export class ClipboardDataMock {
   getData: jest.Mock<string, [string]>;
@@ -1817,4 +1820,126 @@ describe('LexicalTableNode tests', () => {
       );
     });
   });
+
+  describe(`hasHorizontalScroll false -> true`, () => {
+    let hasHorizontalScroll = false;
+    let setHasHorizontalScroll: (
+      _hasHorizontalScroll: boolean,
+    ) => void = () => {};
+    function TablePluginWrapper() {
+      [hasHorizontalScroll, setHasHorizontalScroll] = useState(false);
+      return <TablePlugin hasHorizontalScroll={hasHorizontalScroll} />;
+    }
+    initializeUnitTest(
+      (testEnv) => {
+        beforeEach(async () => {
+          const {editor} = testEnv;
+          await editor.update(() => {
+            const root = $getRoot();
+            root.clear().append($createTableNodeWithDimensions(2, 2, true));
+          });
+        });
+        test('table is re-rendered when scroll changes', async () => {
+          await Promise.resolve().then();
+          expectHtmlToBeEqual(
+            testEnv.innerHTML,
+            html`
+              <table class="test-table-class">
+                <colgroup>
+                  <col />
+                  <col />
+                </colgroup>
+                <tr>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                </tr>
+                <tr>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                  <td>
+                    <p><br /></p>
+                  </td>
+                </tr>
+              </table>
+            `,
+          );
+          act(() => {
+            expect(testEnv.editor.read($isScrollableTablesActive)).toBe(false);
+            setHasHorizontalScroll(true);
+          });
+          await Promise.resolve().then();
+          expect(testEnv.editor.read($isScrollableTablesActive)).toBe(true);
+          expectHtmlToBeEqual(
+            testEnv.innerHTML,
+            html`
+              <div class="table-scrollable-wrapper">
+                <table class="test-table-class">
+                  <colgroup>
+                    <col />
+                    <col />
+                  </colgroup>
+                  <tr>
+                    <th>
+                      <p><br /></p>
+                    </th>
+                    <th>
+                      <p><br /></p>
+                    </th>
+                  </tr>
+                  <tr>
+                    <th>
+                      <p><br /></p>
+                    </th>
+                    <td>
+                      <p><br /></p>
+                    </td>
+                  </tr>
+                </table>
+              </div>
+            `,
+          );
+          act(() => {
+            expect(testEnv.editor.read($isScrollableTablesActive)).toBe(true);
+            setHasHorizontalScroll(false);
+          });
+          await Promise.resolve().then();
+          expect(testEnv.editor.read($isScrollableTablesActive)).toBe(false);
+          expectHtmlToBeEqual(
+            testEnv.innerHTML,
+            html`
+              <table class="test-table-class">
+                <colgroup>
+                  <col />
+                  <col />
+                </colgroup>
+                <tr>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                </tr>
+                <tr>
+                  <th>
+                    <p><br /></p>
+                  </th>
+                  <td>
+                    <p><br /></p>
+                  </td>
+                </tr>
+              </table>
+            `,
+          );
+        });
+      },
+      {theme: editorConfig.theme},
+      <TablePluginWrapper />,
+    );
+  });
 });