Skip to content

refactor(react): one vocabulary and simpler plumbing for portal elements - #3052

Draft
YousefED wants to merge 11 commits into
mobile-toolbar-demofrom
portals-cleanup-v2
Draft

refactor(react): one vocabulary and simpler plumbing for portal elements#3052
YousefED wants to merge 11 commits into
mobile-toolbar-demofrom
portals-cleanup-v2

Conversation

@YousefED

@YousefED YousefED commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

A rework of the portal consolidation in #3046. It keeps that PR's model — portal elements registered on the editor, themed roots for foreign targets, portalElements reaching every UI element — and changes how it is named and wired.

Important

This PR is opened against main, so its diff contains the whole mobile-toolbar stack underneath it (51 commits, 178 files). The portal work itself is the single commit at the tip: 42 files, +503 / −389. To review only that, compare against portals-context (#3046) — that is the intended base if this lands as a stacked PR.

layer contents
mobile-toolbar-demo (#2939) the mobile formatting toolbar
portals-context (#3046) Matthew's portal consolidation
this branch the rework described below

Rationale

Review of #3046 raised two things. The vocabulary had four stems for what are really two concepts — portal element / portal root / portal context / editor portal — and the mounting logic was doing more React work than it needed to, in a place where subtle re-render and ordering bugs are expensive.

Changes

One vocabulary, two concepts.

  • portalElement everywhere: PortalElement, portalElements, resolvePortalElement, usePortalElement, PortalElementOverride, editor.registerPortalElement / unregisterPortalElement, mount(el, { portalElement }), and the portalElement prop on every popover, menu and select (was portalRoot). Where a forwarded prop and the ambient element coexist in one scope they are portalElementProp and portalElement.
  • Themed root (.bn-root) is now expressed as a single function rather than a props object: applyThemedRoot(element) on BlockNoteViewContext. ThemedRootProps is removed.

Theming stays where it was.

packages/mantine/src/BlockNoteTheme.ts is byte-identical to mobile-toolbar-demoapplyBlockNoteCSSVariablesFromTheme and removeBlockNoteCSSVariables are untouched. The mantine view keeps its existing ref={applyThemeVariables} for the editor container and passes the same logic down as applyThemedRoot for portal roots, replacing the useEffect that used to theme editor.portalElement. It is base plus 12 lines.

Simpler plumbing.

  • The default portal element is derived inside usePortalElement from the editor's own container, so BlockNoteViewContainer goes back to its original shape: no useState, no merged refs, no provider wrapper.
  • PortalElementOverride creates its themed root directly and mounts it in a layout effect. That replaces a createPortal of an empty div, a ref/state round-trip, a needsContainer state, and a closest(".bn-root") DOM read during render.

Impact

No public API is removed that has shipped — editor.portalElement was introduced after v0.54.0, so all of this is pre-release surface.

One behavioural improvement over #3046: the default portal element resolves to the editor's .bn-container rather than the contenteditable's parentElement. Under renderEditor={false} those differ — the app's own wrapper is the parent — so floating UI could previously be clipped by a wrapper's overflow.

Testing

Verified in browser mode (chromium), all measured rather than asserted:

  • Theming reaches portal roots and follows changes: a root mounted in a foreign target goes bn-root light bn-mantinebn-root dark bn-mantine, with data-color-scheme and data-mantine-color-scheme both following a live theme toggle. This is the bug feat: Portals cleanup #3046 set out to fix.
  • Override path: renders once, never null, attached before paint, and leaves zero children behind after unmount.
  • Default path: resolves to the real .bn-container in the plain case, in the renderEditor={false} + wrapper case, and yields to an explicit override.
  • Render cost: a portal consumer renders twice on mount (null → attached element), against three on feat: Portals cleanup #3046. mobile-toolbar-demo renders once, but the element it hands over on that render is detached, with no notification when it attaches.

E2E chromium: 294 passed, 1 failed. The failure is static.test.tsx timing out at 90s; it passes standalone in 2.4s and passes in CI, which shards chromium into halves — it only times out when all 48 files run in one local container.

Not yet run: firefox/webkit, and the mobile end-to-end suite, which lives above this layer in the stack.

Breaking change

editor.mount(element, { portalTarget }) loses its options argument. The option decided where editor.portalElement got appended, and that element is removed by this work; it had already become a one-line alias for registerPortalElement. Callers do it explicitly instead:

editor.mount(element);
editor.registerPortalElement(someContainer);

Only needed when floating UI renders outside the editor's DOM tree — UI next to the contenteditable already counts as within the editor. No in-repo caller used it except one test; the docs never mentioned it.

Open question

The default portal element costs consumers one extra render at mount. Making it an imperatively created div inside the container removes that (measured: one render, never null, attached before paint) at the cost of one empty DOM node — the node mobile-toolbar-demo already had. Roughly a 15-line change if we want that trade.

matthewlipski and others added 8 commits September 2, 2026 20:01
Reworks the portal consolidation from #3046, keeping its model — portal
elements registered on the editor, themed roots for foreign targets — and
changing how it is named and wired.

Naming: one stem, `portalElement`, from `editor.registerPortalElement` and
`mount(el, { portalElement })` through `resolvePortalElement`,
`usePortalElement` and `PortalElementOverride` to the `portalElement` prop on
every popover, menu and select. `portalRoot`, `portalTarget`, `portalContext`
and `editorPortal` are gone. Where a forwarded prop and the ambient element
coexist they are `portalElementProp` and `portalElement`.

Theming: `ThemedRootProps` is replaced by a single `applyThemedRoot(element)`
on `BlockNoteViewContext`, composed from the base classes and whatever the UI
library adds. The mantine wrapper keeps base's `applyThemeVariables` ref for
the editor container and passes the same function down for portal roots, so
`BlockNoteTheme.ts` is untouched.

Plumbing: the default portal element is derived in `usePortalElement` from the
editor's own container, so `BlockNoteViewContainer` needs no state, no merged
refs and no provider wrapper. `PortalElementOverride` creates its themed root
directly and mounts it in a layout effect, replacing a `createPortal` of an
empty div, a ref/state round-trip and a `closest(".bn-root")` read during
render.
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
blocknote Building Building Preview Sep 4, 2026 4:48pm UTC
blocknote-website Building Building Preview Sep 4, 2026 4:48pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-3052/

Built to branch gh-pages at 2026-09-05 05:57 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

…aries

Inside a component that receives a `portalElement` prop, `portalElement` now
means that prop, and the surrounding default from `usePortalElement` takes the
qualified name. Reads more directly, and keeps the props destructures on one
line as they are on the base branch.
@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@3052

@blocknote/code-block

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

@blocknote/core

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

@blocknote/diagram-block

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

@blocknote/mantine

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

@blocknote/math-block

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

@blocknote/react

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

@blocknote/server-util

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

@blocknote/shadcn

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

@blocknote/xl-ai

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

@blocknote/xl-docx-exporter

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

@blocknote/xl-email-exporter

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

@blocknote/xl-multi-column

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

@blocknote/xl-odt-exporter

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

@blocknote/xl-pdf-exporter

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

commit: 473642d

BREAKING: `editor.mount(element, { portalTarget })` loses its options
argument. The option decided where `editor.portalElement` was appended, and
that element no longer exists; it had already been reduced to an alias for
`registerPortalElement`, which callers can call directly and explicitly:

  editor.mount(element);
  editor.registerPortalElement(someContainer);

Only needed when floating UI renders outside the editor's DOM tree — UI next
to the contenteditable already counts as within the editor.
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.

2 participants