Skip to content

Bug: Typeahead menu appends its anchor during render, so a discarded render leaks a role="listbox" div #9875

Open

Description

@Kevinminh

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

  1. Render a LexicalComposer with a LexicalTypeaheadMenuPlugin inside a Suspense boundary.
  2. Render something after the plugin in the same boundary that suspends on its first render (a lazy component, use(promise), a data library).
  3. Let it resolve, then unmount the tree.
  4. 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:

// 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions