primitives icon indicating copy to clipboard operation
primitives copied to clipboard

Popover portal does not use the `component` prop as mounting parent.

Open orangecoloured opened this issue 1 month ago • 7 comments

Bug report

Current Behavior

When using the Popover primitive, the Portal component fails to mount to the provided to the component prop element. Always mounts to the body.

Expected behavior

I expect it to mount to the provided element.

Reproducible example

There is this example where the behavior can be reproduced. It's from this topic in Discussions. The ref is populated in a weird way there, but it actually does not affect the behavior. Even if there is a check for existence of the element, the Portal still mounts to the body.

Suggested solution

No idea. I would just like to learn how this can be mitigated or fixed. Or maybe I'm misusing the prop. I would like to know any workaround possible.

Additional context

N/A

Your environment

Software Name(s) Version
Radix Package(s) @radix-ui/react-accordion ^1.2.12
@radix-ui/react-checkbox ^1.3.3
@radix-ui/react-collapsible ^1.1.12
@radix-ui/react-context-menu ^2.2.16
@radix-ui/react-collapsible ^1.0.2
@radix-ui/react-dialog ^1.1.15
@radix-ui/react-dropdown-menu ^2.1.16
@radix-ui/react-label ^2.1.7
@radix-ui/react-menubar ^1.1.16
@radix-ui/react-popover ^1.1.15
@radix-ui/react-progress ^1.1.7
@radix-ui/react-radio-group ^1.3.8
@radix-ui/react-scroll-area ^1.2.10
@radix-ui/react-select ^2.2.6
@radix-ui/react-separator ^1.1.7
@radix-ui/react-slider ^1.3.6
@radix-ui/react-slot ^1.2.3
@radix-ui/react-switch ^1.2.6
@radix-ui/react-tabs ^1.1.13
@radix-ui/react-toast ^1.2.15
@radix-ui/react-tooltip ^1.2.8
React 18.3.1
Browser Chrome 141.0.7390.123
Assistive tech n/a
Node n/a
npm/yarn/pnpm pnpm 10.12.1
Operating System macOS 26.0.1

orangecoloured avatar Oct 29 '25 10:10 orangecoloured

TL;DR: it has nothing to do with Radix, it's just how React works.


In the example you provided, it uses a ref:

const sectionRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  const section = document.querySelector('section');
  if (section) {
    sectionRef.current = section;
  }
}, []);

return (
  // ...
  <Popover.Root>
    <Popover.Trigger asChild>
      <div className="trigger">trigger</div>
    </Popover.Trigger>
    <Popover.Portal container={sectionRef.current}>
      <Popover.Content>
        <div className="popover">Popover content here</div>
      </Popover.Content>
    </Popover.Portal>
  </Popover.Root>
  // ...
);

Ref is initially null. Let's verify if sectionRef is becoming an HTMLElement and whether Popover re-renders.

const sectionRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  console.log("Ref is null:", sectionRef.current === null);
  const section = document.querySelector("section");
  if (section) {
    sectionRef.current = section;
  }
  console.log("Ref is null:", sectionRef.current === null);
}, []);

return (
  // ...
  <Popover.Root>
    <Popover.Trigger asChild>
      <div className="trigger">trigger</div>
    </Popover.Trigger>
    <Popover.Portal container={sectionRef.current}>
      <Popover.Content>
        <div className="popover">
          {(() => {
            console.log(
              "Portal Content rendering, container =",
              sectionRef.current
            );
            return "Popover content here";
          })()}
        </div>
      </Popover.Content>
    </Popover.Portal>
  </Popover.Root>
  // ...
);
// Console
Portal Content rendering, container = null
App.tsx:42 Portal Content rendering, container = null
App.tsx:10 Ref is null: true
App.tsx:15 Ref is null: false
App.tsx:10 Ref is null: false
App.tsx:15 Ref is null: false

So Portal Content doesn’t re-render, even when the ref clearly changes. But why? Well... this is expected :-)

When you change the ref.current property, React does not re-render your component. React is not aware of when you change it because a ref is a plain JavaScript object. React - useRef.

So now it’s probably very clear for you that this works:

<div onClick={(e) => handleTaskClick("id", e.currentTarget)}> // Look at the onClick!
  target
</div>

<FloatingPopover
  open={!!selectedTaskId}
  onOpenChange={() => setSelectedTaskId(null)}
  anchorEl={anchorEl}
  containerEl={sectionRef.current}
>
  <div>
    <strong>{selectedTaskId}</strong>
    <p>This is your task content</p>
  </div>
</FloatingPopover>

...not because other lib works different, but because of the re-render caused by state change:

// Both values change onClick
const handleTaskClick = (id: string, el: HTMLElement) => {
  setAnchorEl(el);
  setSelectedTaskId(id);
};

const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);

So now we know that state updates cause re-renders, but ref updates do not. That makes it clear we can just put the ref value into state:

const [sectionEl, setSectionEl] = useState<HTMLElement | null>(null);

useEffect(() => {
  const section = document.querySelector("section");
  if (section) {
    setSectionEl(section);
  }
}, []);

return (
  // ...
  <Popover.Root>
    <Popover.Trigger asChild>
      <div className="trigger">trigger</div>
    </Popover.Trigger>
    <Popover.Portal container={sectionEl}>
      <Popover.Content>
        <div className="popover">Popover content here</div>
      </Popover.Content>
    </Popover.Portal>
  </Popover.Root>
  // ...
);

Or if you want it to make totally right, just set refs correctly (separately, cause we have 2 sections!), and not use querySelector:

function App() {
  const radixSectionRef = useRef<HTMLElement | null>(null);
  const floatingSectionRef = useRef<HTMLElement | null>(null);

  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
  const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);

  const handleTaskClick = (id: string, el: HTMLElement) => {
    setAnchorEl(el);
    setSelectedTaskId(id);
  };

  return (
    <div>
      Radix's Popover
      <main>
        <aside>side panel. should not be hovered by popover</aside>
        <section ref={radixSectionRef}>
          right area
          {
            <Popover.Root>
              <Popover.Trigger asChild>
                <div className="trigger">trigger</div>
              </Popover.Trigger>
              <Popover.Portal container={radixSectionRef.current}>
                <Popover.Content>
                  <div className="popover">Popover content here</div>
                </Popover.Content>
              </Popover.Portal>
            </Popover.Root>
          }
        </section>
      </main>
      Floating Ui (WIP)
      <main>
        <aside>side panel. should not be hovered by popover</aside>
        <section ref={floatingSectionRef}>
          right area
          <div onClick={(e) => handleTaskClick("id", e.currentTarget)}>
            target
          </div>
          <FloatingPopover
            open={!!selectedTaskId}
            onOpenChange={() => setSelectedTaskId(null)}
            anchorEl={anchorEl}
            containerEl={floatingSectionRef.current}
          >
            <div>
              <strong>{selectedTaskId}</strong>
              <p>This is your task content</p>
            </div>
          </FloatingPopover>
        </section>
      </main>
    </div>
  );
}

konhi avatar Oct 29 '25 16:10 konhi

@konhi I've just copy-pasted your code.

https://github.com/user-attachments/assets/fbc215d8-aaf4-493d-9e73-1562b3662590

orangecoloured avatar Oct 29 '25 17:10 orangecoloured

@orangecoloured Right, my bad! I suppose it doesn't make sense to use ref there whatsoever, you can just go with state to correctly re-render on mount :-)

Image
function App() {
  const [radixSection, setRadixSection] = useState<HTMLElement | null>(null);
  const [floatingSection, setFloatingSection] = useState<HTMLElement | null>(
    null
  );

  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
  const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);

  const handleTaskClick = (id: string, el: HTMLElement) => {
    setAnchorEl(el);
    setSelectedTaskId(id);
  };

  return (
    <div>
      Radix's Popover
      <main>
        <aside>side panel. should not be hovered by popover</aside>
        <section ref={setRadixSection}>
          right area
          {
            <Popover.Root>
              <Popover.Trigger asChild>
                <div className="trigger">trigger</div>
              </Popover.Trigger>
              <Popover.Portal container={radixSection}>
                <Popover.Content>
                  <div className="popover">Popover content here</div>
                </Popover.Content>
              </Popover.Portal>
            </Popover.Root>
          }
        </section>
      </main>
      Floating Ui (WIP)
      <main>
        <aside>side panel. should not be hovered by popover</aside>
        <section ref={setFloatingSection}>
          right area
          <div onClick={(e) => handleTaskClick("id", e.currentTarget)}>
            target
          </div>
          <FloatingPopover
            open={!!selectedTaskId}
            onOpenChange={() => setSelectedTaskId(null)}
            anchorEl={anchorEl}
            containerEl={floatingSection}
          >
            <div>
              <strong>{selectedTaskId}</strong>
              <p>This is your task content</p>
            </div>
          </FloatingPopover>
        </section>
      </main>
    </div>
  );
}

konhi avatar Oct 29 '25 17:10 konhi

@konhi Yeah, works fine indeed. Not sure what's up, but it doesn't work in my code. I used every possible approach. I even have the Popover rendered conditionally. It checks if the reference element is present. And it's stored in the useState hook. And even then it doesn't work. The damn Portal gets appended to the body.

orangecoloured avatar Oct 29 '25 17:10 orangecoloured

Does your ref change to the element correctly and the Popover Content re-renders? I showed on how to debug that with console logs, but otherwise not sure if I can help without seeing code or reproduction

konhi avatar Oct 29 '25 18:10 konhi

My code is the same as yours with an additional check for the radixSection

{radixSection
  ? (
    <Popover.Root>
      <Popover.Trigger asChild>
        <div className="trigger">trigger</div>
      </Popover.Trigger>
      <Popover.Portal container={radixSection}>
        <Popover.Content>
          <div className="popover">Popover content here</div>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  )
  : null
}

And the set happens not directly.

const rerCallback = (element: HTMLDivElement | null) => {
    setRadixSection(element);
}

I even tried assigning a completely different element, doing setRadixSection(document.getElementById("id")), which is basically the wrapper of the whole app. No success.

Yeah. Thanks. I think this can be closed.

orangecoloured avatar Oct 29 '25 18:10 orangecoloured

I just noticed that the 1.1.15 update of the "@radix-ui/react-popover" seems to have completely broken the custom ref passing via <PopoverAnchor anchorRef={customRef} in our project. Might be related.

NiklasPor avatar Nov 02 '25 17:11 NiklasPor