[lexical-react] Bug Fix: TreeView re-renders when the editor prop changes - #9040
[lexical-react] Bug Fix: TreeView re-renders when the editor prop changes#9040LeSingh1 wants to merge 1 commit into
Conversation
…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)
```
|
@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. |
|
Confirmed the bug and tested the fix. The |
| }); | ||
| const pre = container.querySelector('pre'); | ||
| expect(pre).not.toBe(null); | ||
| return (pre as HTMLPreElement).textContent ?? ''; |
There was a problem hiding this comment.
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.
|
Good catch — |
Description
TreeViewseeds the editor state it renders on the first render, then only ever updates it from listeners:editoris 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. ButuseState's initial value is only used on mount: when a new editor arrives the effect re-subscribes whileeditorCurrentStatestill 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.
TreeViewin@lexical/devtools-coreonly rebuilds when the identity changes: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
LexicalNestedComposercreates — 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 containingAAAAAto one containingBBBBB:The sibling hooks in this package all re-derive before subscribing —
useCanShowPlaceholdercallsresetCanShowPlaceholder()at the top of its layout effect,ContentEditableElementandLexicalContentEditablecallsetEditable(editor.isEditable()), andLexicalNestedComposercallseditableListener(parentEditor.isEditable()). This adds the same line, and moves the registration to the package's shareduseLayoutEffectso it matches them:react-hooks/set-state-in-effectrejects a synchronoussetStatein auseEffectbody, 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
After
Package suite is unchanged: