Repository navigation
Description
useMenuAnchorRef appends the typeahead menu's anchor div to the page while rendering. When React throws that render away (the tree suspended, or a transition was interrupted and restarted), nothing ever removes the div, so each discarded render leaves one <div role="listbox" aria-label="Typeahead menu"> in the parent for the life of the page.
Lexical version: 0.53.0 (the code is the same on main at d6298bf)
Steps To Reproduce
- Render a
LexicalComposerwith aLexicalTypeaheadMenuPlugininside aSuspenseboundary. - Render something after the plugin in the same boundary that suspends on its first render (a
lazycomponent,use(promise), a data library). - Let it resolve, then unmount the tree.
- Count the anchors in
document.body.
Link to code example:
A script that runs in Node with jsdom, with no dependencies but Lexical and React:
// npm i @lexical/[email protected] [email protected] react@19 react-dom@19 jsdom && node repro.mjs
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><body><div id='root'></div></body>", { pretendToBeVisual: true });
for (const key of ["window", "document", "HTMLElement", "Node", "Element", "MutationObserver", "getComputedStyle", "requestAnimationFrame", "cancelAnimationFrame"]) {
globalThis[key] = dom.window[key];
}
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
const React = (await import("react")).default;
const { createRoot } = await import("react-dom/client");
const { LexicalComposer } = await import("@lexical/react/LexicalComposer");
const { LexicalTypeaheadMenuPlugin } = await import("@lexical/react/LexicalTypeaheadMenuPlugin");
const h = React.createElement;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const anchors = () => document.querySelectorAll('body > [aria-label="Typeahead menu"]').length;
const noop = () => {};
// Anything beside the editor that suspends: a lazy component, `use(promise)`, a data library.
let ready = false;
let release;
const gate = new Promise((resolve) => (release = resolve)).then(() => (ready = true));
function Suspends() {
if (!ready) throw gate;
return null;
}
function App() {
return h(
React.Suspense,
{ fallback: null },
h(
LexicalComposer,
{ initialConfig: { namespace: "repro", onError: (error) => { throw error; } } },
h(LexicalTypeaheadMenuPlugin, { options: [], onQueryChange: noop, onSelectOption: noop, triggerFn: () => null, menuRenderFn: () => null }),
h(Suspends),
),
);
}
const root = createRoot(document.getElementById("root"));
root.render(h(App));
await wait(100);
console.log("suspended, nothing committed yet:", anchors());
release();
await wait(1000);
console.log("committed:", anchors());
root.unmount();
await wait(100);
console.log("unmounted:", anchors());
process.exit(0);The current behavior
suspended, nothing committed yet: 2
committed: 3
unmounted: 2
React renders the suspended tree twice before it commits anything, and each of those renders appends an anchor. Their hooks are discarded with the render, so no effect cleanup knows about them, and they stay after the editor unmounts.
The append happens in the body of the hook:
lexical/packages/lexical-react/src/shared/LexicalMenu.tsx
Lines 849 to 858 in d6298bf
| // Append the context for the menu immediately | |
| if ( | |
| initialAnchorElement != null && | |
| initialAnchorElement === anchorElementRef.current | |
| ) { | |
| setContainerDivAttributes(initialAnchorElement, className); | |
| if (resolvedParent != null) { | |
| resolvedParent.append(initialAnchorElement); | |
| } | |
| } |
initialAnchorElement is created on every render (L741) and equals anchorElementRef.current on a component's first render, so every first render appends, whether or not it commits.
The expected behavior
suspended, nothing committed yet: 0
committed: 1
unmounted: 0
Rendering stays free of DOM writes: the anchor is appended once the component has mounted. Moving the block into an effect does it:
useEffect(() => {
const containerDiv = anchorElementRef.current;
if (containerDiv !== null && resolvedParent != null && !containerDiv.isConnected) {
setContainerDivAttributes(containerDiv, className);
resolvedParent.append(containerDiv);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);With only this change applied to 0.53.0's LexicalTypeaheadMenuPlugin.dev.js, the script prints 0, 1, 0. The existing cleanup already removes the anchor on unmount.
Impact of fix
Any app that renders an editor with a typeahead (mentions, slash commands, emoji) in a tree that can suspend or be interrupted: a lazy-loaded editor, an editor beside suspending data, a route transition. Each discarded render leaves an empty role="listbox" named "Typeahead menu" in the page for assistive technology to find, and they add up over a session.
Related: #7668 (dangling anchor divs). #8929 changes the anchor's role, but as far as I can tell not where it is appended.
Activity