Skip to content

[lexical-react] Bug Fix: TreeView re-renders when the editor prop changes - #9040

Closed
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/treeview-editor-resync
Closed

[lexical-react] Bug Fix: TreeView re-renders when the editor prop changes#9040
LeSingh1 wants to merge 1 commit into
facebook:mainfrom
LeSingh1:fix/treeview-editor-resync

Conversation

@LeSingh1

@LeSingh1 LeSingh1 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Description

TreeView seeds the editor state it renders on the first render, then only ever updates it from listeners:

const [editorCurrentState, setEditorCurrentState] = useState<EditorState>(
  editor.getEditorState(),
);

useEffect(() => {
  // Registers listeners to update the tree view when the editor state changes
  return mergeRegister(
    editor.registerUpdateListener(({editorState}) => { setEditorCurrentState(editorState); }),
    editor.registerEditableListener(() => { setEditorCurrentState(editor.getEditorState()); }),
  );
}, [editor]);

editor is a required prop of this public component (@lexical/react/LexicalTreeView) and it is in the effect's dependency array, so it is expected to change. But useState's initial value is only used on mount: when a new editor arrives the effect re-subscribes while editorCurrentState still holds the previous editor's state, and neither listener has fired yet for the new one.

That value is not merely displayed — it is the gate on regenerating the pane. TreeView in @lexical/devtools-core only rebuilds when the identity changes:

const shouldUpdate =
  lastEditorStateRef.current !== editorState ||
  lastCommandsLogRef.current !== commandsLog;

so the view keeps rendering the old editor's node tree until the new editor happens to produce an update or an editability change. Point a devtools pane at a different editor — the root editor and a nested one (an image caption, a sticky note, a table cell), which is what LexicalNestedComposer creates — and you are reading the wrong tree, with no indication that it is stale. For an idle or read-only editor it never resolves.

Rendered on upstream/main, switching the prop from an editor containing AAAAA to one containing BBBBB:

 root
  └ (1) paragraph
    └ (2) text "AAAAA"

The sibling hooks in this package all re-derive before subscribing — useCanShowPlaceholder calls resetCanShowPlaceholder() at the top of its layout effect, ContentEditableElement and LexicalContentEditable call setEditable(editor.isEditable()), and LexicalNestedComposer calls editableListener(parentEditor.isEditable()). This adds the same line, and moves the registration to the package's shared useLayoutEffect so it matches them: react-hooks/set-state-in-effect rejects a synchronous setState in a useEffect body, which is why every one of those siblings is a layout effect too. No API change.

Test plan

New unit test packages/lexical-react/src/__tests__/unit/LexicalTreeView.test.tsx. The first case is a control covering a single editor and passes before and after; the second swaps the prop.

Before

 ❯ packages/lexical-react/src/__tests__/unit/LexicalTreeView.test.tsx (2 tests | 1 failed)
   ✓ renders the tree of the editor it was given
   × re-renders when the editor prop changes
     AssertionError: expected ' root\n  └ (3) paragraph \n    └ (4) …' to contain '"BBBBB"'

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

After

 Test Files  1 passed (1)
      Tests  2 passed (2)

Package suite is unchanged:

$ npx vitest run packages/lexical-react
 Test Files  32 passed (32)
      Tests  184 passed (184)

…nges

## Description

`TreeView` seeds the editor state it renders on the first render, then only ever updates it from listeners:

```tsx
const [editorCurrentState, setEditorCurrentState] = useState<EditorState>(
  editor.getEditorState(),
);

useEffect(() => {
  // Registers listeners to update the tree view when the editor state changes
  return mergeRegister(
    editor.registerUpdateListener(({editorState}) => { setEditorCurrentState(editorState); }),
    editor.registerEditableListener(() => { setEditorCurrentState(editor.getEditorState()); }),
  );
}, [editor]);
```

`editor` is a required prop of this public component (`@lexical/react/LexicalTreeView`) and it is in the effect's dependency array, so it is expected to change. But `useState`'s initial value is only used on mount: when a new editor arrives the effect re-subscribes while `editorCurrentState` still holds the *previous* editor's state, and neither listener has fired yet for the new one.

That value is not merely displayed — it is the gate on regenerating the pane. `TreeView` in `@lexical/devtools-core` only rebuilds when the identity changes:

```tsx
const shouldUpdate =
  lastEditorStateRef.current !== editorState ||
  lastCommandsLogRef.current !== commandsLog;
```

so the view keeps rendering the old editor's node tree until the *new* editor happens to produce an update or an editability change. Point a devtools pane at a different editor — the root editor and a nested one (an image caption, a sticky note, a table cell), which is what `LexicalNestedComposer` creates — and you are reading the wrong tree, with no indication that it is stale. For an idle or read-only editor it never resolves.

Rendered on `upstream/main`, switching the prop from an editor containing `AAAAA` to one containing `BBBBB`:

```
 root
  └ (1) paragraph
    └ (2) text "AAAAA"
```

The sibling hooks in this package all re-derive before subscribing — `useCanShowPlaceholder` calls `resetCanShowPlaceholder()` at the top of its layout effect, `ContentEditableElement` and `LexicalContentEditable` call `setEditable(editor.isEditable())`, and `LexicalNestedComposer` calls `editableListener(parentEditor.isEditable())`. This adds the same line, and moves the registration to the package's shared `useLayoutEffect` so it matches them: `react-hooks/set-state-in-effect` rejects a synchronous `setState` in a `useEffect` body, which is why every one of those siblings is a layout effect too. No API change.

## Test plan

New unit test `packages/lexical-react/src/__tests__/unit/LexicalTreeView.test.tsx`. The first case is a control covering a single editor and passes before and after; the second swaps the prop.

### Before

```
 ❯ packages/lexical-react/src/__tests__/unit/LexicalTreeView.test.tsx (2 tests | 1 failed)
   ✓ renders the tree of the editor it was given
   × re-renders when the editor prop changes
     AssertionError: expected ' root\n  └ (3) paragraph \n    └ (4) …' to contain '"BBBBB"'

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
```

### After

```
 Test Files  1 passed (1)
      Tests  2 passed (2)
```

Package suite is unchanged:

```
$ npx vitest run packages/lexical-react
 Test Files  32 passed (32)
      Tests  184 passed (184)
```
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 9, 2026
@mayrang

mayrang commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Confirmed the bug and tested the fix. The useLayoutEffect pattern and the immediate re-read of getEditorState() match useCanShowPlaceholder and ContentEditableElement exactly. One small note inline.

});
const pre = container.querySelector('pre');
expect(pre).not.toBe(null);
return (pre as HTMLPreElement).textContent ?? '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

querySelector('pre') already returns HTMLPreElement | null, so the cast to HTMLPreElement is redundant — textContent is already available on Element. A non-null assertion (pre!.textContent) is cleaner.

@LeSingh1

LeSingh1 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — querySelector('pre') already returns HTMLPreElement | null and textContent is on Element, so the cast was redundant. Will fold that into the consolidated PR this one is being merged into (see #9027 — the maintainer has asked me to consolidate my open PRs, so I am holding individual pushes to avoid more review churn).

@LeSingh1

Copy link
Copy Markdown
Contributor Author

Consolidated into #9051 with the other PRs that share this defect, per @etrepum's note on #9027 and @mayrang's on #9035. Same fix and same tests, one review.

@LeSingh1 LeSingh1 closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants