Skip to content

feat: Portals cleanup - #3046

Open
matthewlipski wants to merge 8 commits into
mobile-toolbar-demofrom
portals-context
Open

feat: Portals cleanup#3046
matthewlipski wants to merge 8 commits into
mobile-toolbar-demofrom
portals-context

Conversation

@matthewlipski

Copy link
Copy Markdown
Collaborator

Summary

This PR attempts to consolidate and clean up how portalling is handled in UI elements.

The trigger for this comes from the mobile formatting toolbar. It requires dropdowns to be portalled outside of it as it has overflow: auto. On desktop, position: fixed elements like the dropdowns are not clipped by an ancestor overflow: auto element, but this behaviour varies between platforms on mobile. Additionally, the mobile toolbar itself needs to be portalled to document.body as it should float on top of everything else on the page.

Originally, a proprietary MobileToolbarPortalContext would set a target element for the dropdowns to portal to. This was clunky as there is already the BlockNoteView portalElements prop which is supposed to handle portalling for editor UI elements. Alongside that there's editor.portalElement, which is set by portalElements.default and is used by isWithinEditor to check whether something is in the editor UI. However, this was also broken as it didn't take into account the other portals set by portalElements. The other portals were also broken as they don't receive proper styling/theming, as that relies on a container element with various attributes (things like a .bn-root class, data-theme attribute, etc). Overall, I think it's quite clear that there's a benefit to consolidating all of these things into one and fix some existing bugs along the way.

So this PR does so by introducing PortalTarget and usePortalContext, which replaces MobileToolbarPortalContext and is also how portalElements creates portals. It takes a target and exposes it in a PortalContext so any descendants always know where an element should be portalled to, by consuming said PortalContext. Additional PortalTargets can also be added anywhere in the render tree to override the target element (useful especially for the mobile formatting toolbar where it and descendant dropdowns portal to document.body). The reason we need a PortalTarget component instead of just using a bare PortalContext, is because the portalled elements need to be inside a container element for styling - same issue that already exists with portalElements. Therefore, PortalTarget first checks if a .bn-root container element exists up the DOM tree, and creates it if necessary.

Rationale

  1. There is no unified way of creating portals internally.
  2. isWithinEditor and portalElements have bugs which partially due to this.
  3. editor.portalElement has a pretty convoluted implementation and is treated as a special case.

Changes

  • Added PortalTarget and usePortalContext.
  • Refactored react package to always use PortalTarget & usePortalContainer to set/get portal elements.
  • Replaced editor.portalElement with editor._portalRoots. This is now a map which contains all created portals, which isWithinEditor reads to guarantee that all editor-related elements are checked.

Impact

N/A

Testing

TODO

Screenshots/Video

N/A

Checklist

  • Code follows the project's coding standards.
  • Unit tests covering the new feature have been added.
  • All existing tests pass.
  • The documentation has been updated to reflect the new feature

Additional Notes

N/A

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
blocknote Error Error Sep 4, 2026 5:16pm UTC
blocknote-website Error Error Sep 4, 2026 5:16pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: cb55509d-038b-43cd-a125-07f26f147b69

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment on lines +35 to +74
// Given a target element, checks whether a `.bn-root` element is somewhere up the DOM tree, as
// one is necessary to apply correct theming & styling. If one doesn't exist, creates one and
// returns it, both as a React node and HTML element. Otherwise, just returns the target element or
// null if the target is undefined.
function usePortalRoot(target: HTMLElement | undefined): {
root: HTMLElement | null;
themingContainer: ReactNode;
} {
const rootProps = useBlockNoteViewContext()?.portalRootProps;

const [needsContainer, setNeedsContainer] = useState<{
target: HTMLElement;
value: boolean;
}>();
const [containerElement, setContainerElement] = useState<HTMLElement | null>(
null,
);

useEffect(() => {
if (target) {
setNeedsContainer({ target, value: !target.closest(".bn-root") });
}
}, [target]);

if (!target || needsContainer?.target !== target) {
return { root: null, themingContainer: null };
}

if (!needsContainer.value) {
return { root: target, themingContainer: null };
}

return {
root: containerElement,
themingContainer: createPortal(
<div {...rootProps} ref={setContainerElement} />,
target,
),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't look at this deeply, but my first thought is whether we can just always have a container? I would rather not rapidly mount/unmount elements based on effects of what their parent tree looks like.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We could, it would just clutter the DOM somewhat since we could get .bn-roots nested within other .bn-roots. Also for the record, the container element should only get mounted once

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@3046

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@3046

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@3046

@blocknote/diagram-block

npm i https://pkg.pr.new/@blocknote/diagram-block@3046

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@3046

@blocknote/math-block

npm i https://pkg.pr.new/@blocknote/math-block@3046

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@3046

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@3046

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@3046

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@3046

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@3046

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@3046

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@3046

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@3046

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@3046

commit: 4ca626d

Comment thread packages/shadcn/src/popover/popover.tsx Outdated
// Default to the ambient portal target (a themed `.bn-root`) so popovers
// inherit light/dark mode instead of the document body's, and escape the
// mobile formatting toolbar's horizontal scroll clip.
const contextPortal = usePortalContext();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We need better naming for this. what's the difference between portalRoot and contextPortal? portalRoot also comes from a context 🤷‍♂️

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated naming, hopefully it's a bit clearer. Found it kinda difficult to have names that are not too long and also not too vague.

() => resolvePortalTarget(portalElements?.default),
[portalElements?.default],
) ??
editorDOMElement?.parentElement ??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if this defaults to editorDOMElement?.parentElement, I don't think portalTarget is a descriptive name for this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm also curious if there are race conditions here or whether we can initialize things simpler.

useEditorDOMElement will be null initially, and then passed as <PortalTarget>. Than once things are mounted, it will be set to the bn-container (BlockNoteViewContainer)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

To avoid confusion - portalTarget is put in a React a context so that any popovers further down the render tree can use it to figure out where they need to portal to. So editorDOMElement?.parentElement is not special just because it's the editor container element, portalTarget is just saying to any popovers that they should portal there if they need to. In any case, hopefully things are clearer with the updated naming.

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