Skip to content

fix(FloatingPortal): guard addEventListener against null portalNode under concurrent React#4740

Open
ossaidqadri wants to merge 1 commit intomui:masterfrom
ossaidqadri:fix/floating-portal-null-addeventlistener
Open

fix(FloatingPortal): guard addEventListener against null portalNode under concurrent React#4740
ossaidqadri wants to merge 1 commit intomui:masterfrom
ossaidqadri:fix/floating-portal-null-addeventlistener

Conversation

@ossaidqadri
Copy link
Copy Markdown

Bug

Under React 18+ concurrent rendering (Next.js App Router with useTransition/client-side navigation), FloatingPortal throws:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')
    at Provider (<anonymous>)

Environment

  • @base-ui/react: 1.4.1
  • React: 19
  • Next.js: 16 (App Router)
  • Reproduction: client-side navigation to a page that contains Dialog (used as Sheet) and Select components inside a Suspense boundary

Root cause

In packages/react/src/floating-ui-react/components/FloatingPortal.tsx, the useEffect that attaches focusin/focusout listeners has an early-return guard at the top:

React.useEffect(() => {
  if (!portalNode || modal) {
    return undefined;
  }
  // ...
  return mergeCleanups(
    addEventListener(portalNode, 'focusin', onFocus, true),
    addEventListener(portalNode, 'focusout', onFocus, true),
  );
}, [portalNode, modal]);

Under concurrent React, the effect can fire with a portalNode that passes the !portalNode guard at the start but has since become null — or portalNode was briefly truthy during the transition phase and the effect was scheduled before it reset to null.

The @base-ui/utils/addEventListener utility has no null guard:

function addEventListener(target, type, listener, options) {
  target.addEventListener(type, listener, options);  // throws if target is null
  return () => { target.removeEventListener(type, listener, options); };
}

Compare this with the guarded pattern used elsewhere in FloatingFocusManager.tsx:

// safe — short-circuit prevents null call
floating && addEventListener(floating, 'focusin', handleFocusIn)

But the FloatingPortal call site does NOT use this pattern:

// unguarded — portalNode can be null under concurrent rendering
return mergeCleanups(
  addEventListener(portalNode, 'focusin', onFocus, true),
  addEventListener(portalNode, 'focusout', onFocus, true),
);

Suggested fix

Align the call site with the guarded pattern already used in FloatingFocusManager:

return mergeCleanups(
  portalNode && addEventListener(portalNode, 'focusin', onFocus, true),
  portalNode && addEventListener(portalNode, 'focusout', onFocus, true),
);

Or add a null guard to @base-ui/utils/addEventListener:

function addEventListener(target, type, listener, options) {
  if (!target) return () => {};
  target.addEventListener(type, listener, options);
  return () => { target.removeEventListener(type, listener, options); };
}

Impact

Non-fatal console error on every client-side navigation to a page with Dialog/Select components. Does not break functionality but pollutes the console and will surface as an error in monitoring tools (Sentry, etc.).

…nder concurrent React

Under React 18+ concurrent rendering, the useEffect that attaches focusin/focusout
listeners on portalNode can fire before the ref callback has populated the node,
despite the early-return guard at the top of the effect. This causes:

  TypeError: Cannot read properties of null (reading 'addEventListener')

Align the call site with the pattern already used in FloatingFocusManager, where
short-circuit evaluation (node && addEventListener(node, ...)) prevents the call
when the node is falsy. mergeCleanups already accepts false|null|undefined so no
type changes are needed.

Fixes mui#4739
Copilot AI review requested due to automatic review settings May 4, 2026 22:25
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

commit: e322231

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 4, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+6B(0.00%) 🔺+2B(0.00%)

Details of bundle changes

Performance

Total duration: 1,545.41 ms 🔺+383.27 ms(+33.0%) | Renders: 53 (+0) | Paint: 2,379.64 ms 🔺+591.75 ms(+33.1%)

Test Duration Renders
Tabs mount (200 instances) 308.54 ms 🔺+99.89 ms(+47.9%) 4 (+0)
Menu mount (300 instances) 172.02 ms 🔺+44.67 ms(+35.1%) 2 (+0)
Popover mount (300 instances) 128.53 ms 🔺+42.75 ms(+49.8%) 2 (+0)
Dialog mount (300 instances) 102.65 ms 🔺+38.55 ms(+60.1%) 2 (+0)
Select mount (200 instances) 176.06 ms 🔺+29.77 ms(+20.3%) 3 (+0)

…and 7 more — details


Check out the code infra dashboard for more information about this PR.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to prevent a runtime TypeError in FloatingPortal under React concurrent rendering by avoiding addEventListener(...) calls when portalNode is null.

Changes:

  • Adds portalNode && short-circuiting to the focusin/focusout listener setup in FloatingPortal.
  • Adds an inline comment explaining the guard and referencing the reported concurrent rendering issue.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 195 to 224
React.useEffect(() => {
if (!portalNode || modal) {
return undefined;
}

// Make sure elements inside the portal element are tabbable only when the
// portal has already been focused, either by tabbing into a focus trap
// element outside or using the mouse.
function onFocus(event: FocusEvent) {
if (portalNode && event.relatedTarget && isOutsideEvent(event)) {
if (event.type === 'focusin') {
if (focusInsideDisabledRef.current) {
enableFocusInside(portalNode);
focusInsideDisabledRef.current = false;
}
} else {
disableFocusInside(portalNode);
focusInsideDisabledRef.current = true;
}
}
}

// Listen to the event on the capture phase so they run before the focus
// trap elements onFocus prop is called.
// Guard with `portalNode &&` to defend against concurrent React renders
// where the effect fires before the ref is attached (issue #4739).
return mergeCleanups(
addEventListener(portalNode, 'focusin', onFocus, true),
addEventListener(portalNode, 'focusout', onFocus, true),
portalNode && addEventListener(portalNode, 'focusin', onFocus, true),
portalNode && addEventListener(portalNode, 'focusout', onFocus, true),
);
Comment on lines 195 to 225
React.useEffect(() => {
if (!portalNode || modal) {
return undefined;
}

// Make sure elements inside the portal element are tabbable only when the
// portal has already been focused, either by tabbing into a focus trap
// element outside or using the mouse.
function onFocus(event: FocusEvent) {
if (portalNode && event.relatedTarget && isOutsideEvent(event)) {
if (event.type === 'focusin') {
if (focusInsideDisabledRef.current) {
enableFocusInside(portalNode);
focusInsideDisabledRef.current = false;
}
} else {
disableFocusInside(portalNode);
focusInsideDisabledRef.current = true;
}
}
}

// Listen to the event on the capture phase so they run before the focus
// trap elements onFocus prop is called.
// Guard with `portalNode &&` to defend against concurrent React renders
// where the effect fires before the ref is attached (issue #4739).
return mergeCleanups(
addEventListener(portalNode, 'focusin', onFocus, true),
addEventListener(portalNode, 'focusout', onFocus, true),
portalNode && addEventListener(portalNode, 'focusin', onFocus, true),
portalNode && addEventListener(portalNode, 'focusout', onFocus, true),
);
}, [portalNode, modal]);
@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit e322231
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69f91ce8ce8fdb0008b3b51b
😎 Deploy Preview https://deploy-preview-4740--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented May 4, 2026

The root cause and fix here seems impossible to me?

The closure means the variable can't randomly be null since it already early-returns before ever reaching mergeCleanups, which is why TS is fine with it

Codex analysis agrees:

Short answer: with the code in v1.4.1, that exact explanation is basically not possible.

The effect in packages/react/src/floating-ui-react/components/FloatingPortal.tsx:196 closes over one render’s portalNode value. If it passes:

if (!portalNode || modal) {
  return undefined;
}

then the later calls at packages/react/src/floating-ui-react/components/FloatingPortal.tsx:220 are using that same captured value. Concurrent React can delay effects, flush old passive effects, or schedule a later render where portalNode is null, but it does not mutate the old closure’s portalNode binding from an element into null. Even if the DOM node was removed, the old HTMLElement object still has addEventListener.

So PR #4740’s change:

portalNode && addEventListener(portalNode, ...)

is runtime-equivalent after the existing early return. It would not fix the claimed path.

Most likely explanations: the stack/source map is pointing at the wrong callsite, the app is not actually running the code/version described, or the real nullable target is coming from some other addEventListener call. I’d ask for a minimal repro or a full unminified stack with source maps before accepting this fix.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants