0003. TypeScript and Vite, a committed IIFE bundle, one guarded runtime¶
Status: Accepted. The admin-theme isolation that this decision left Proposed was settled by the probe of 2026-09-13 and is recorded under "Probe results, 2026-09-13"; the Mantine kit stays. The registry check of a block's declared version against its PHP plugin is built and tested. The documented Vite preset and the extension API it builds against are built: js/preset/vite.preset.ts with its generated js/preset/README.md, and a second bundle built through it from a block outside the editor's own vocabulary. The lifecycle events are built too, in js/src/events.ts, and 0009 records them.
Date: 2026-09-13
Context¶
BlockNote is a React editor. @blocknote/core can be mounted without React, but every user interface surface that makes it an editor rather than a text area lives in @blocknote/react: the formatting toolbar, the slash menu, the side menu, the link toolbar, the file panel and the table handles. Shipping the editor means shipping React.
The packages publish in lockstep. At the version this module targets, 0.54.2, @blocknote/core, @blocknote/react and the user interface kits all carry the same version number, the React peer range is ^18 || ^19, and the editor sits on Tiptap 3 and ProseMirror. Upstream releases a minor every three to five weeks and two of the six minors before 0.54.2 contained breaking changes. There is no semantic versioning contract before 1.0.
Drupal core exercises no ES modules and no import maps. Every library core ships is a classic script. A module that shipped ES modules would be the first, on a surface that has to work inside dialogs, AJAX responses and off-canvas trays.
BlockNote publishes three user interface kits. The measurements that decided between them, taken against 0.54.2:
| Kit | CSS, gzipped | JavaScript, gzipped | Constraint |
|---|---|---|---|
@blocknote/mantine |
about 37 kB | about 337 kB | Upstream's default, the one the documentation is written against |
@blocknote/ariakit |
about 9 kB | about 337 kB | Smaller CSS, less documentation parity |
@blocknote/shadcn |
not measured | about 337 kB | Requires the host site to run Tailwind |
The Mantine kit renders with withCssVariables={false} and defines every --mantine-* custom property under the .bn-mantine class. Its built stylesheet contains no :root, html or body rule, so it does not leak styles outward into the admin theme. The real risk runs the other way: Claro and Gin define global rules for button, input, details and table that can restyle the editor's own chrome. A second risk is that the kit's menus render without portals, so they can be clipped inside a container with overflow: hidden, which dialogs and off-canvas trays have.
A code-split build loads the emoji picker lazily, at about 108 kB gzipped, and that chunk bundles its own Preact. An IIFE build has no lazy chunks, so an IIFE build includes all of it unless the emoji packages are stubbed out. The Inter font files that @blocknote/core offers are about 360 kB across eighteen files.
Upstream ships twenty-three locales as plain string dictionaries. Georgian is not among them, and neither are many languages Drupal sites are built in.
Committing a compiled bundle to a drupal.org repository is what core does for CKEditor 5, and what Canvas, ProseMirror, gutenberg and ckeditor5_plugin_pack all do for their own JavaScript, provided the provenance, license and source of the bundled code are documented in the repository.
Decision¶
Sources and build. TypeScript sources live in js/src/ and are built with Vite, run through bun on a developer machine at a pinned version, into a committed dist/blocknote.js in IIFE format and a committed dist/blocknote.css. React 19 and @blocknote/core, @blocknote/react and @blocknote/mantine are pinned exactly at 0.54.2, not to a range. Amended 2026-10-31. The three packages are pinned exactly at 0.57.0. package.json also overrides prosemirror-view to 1.42.5 and prosemirror-model to 1.25.12, the versions 0.57.0's own lockfile resolves, because @blocknote/core 0.57.0 asks for prosemirror-view ^1.42.5 while the packages TipTap 3.31.3 brings in were locked to 1.42.3, and two copies of either package on one page break ProseMirror's identity checks. TipTap stays at 3.31.3, the release 0.57.0's own lockfile resolves too.
One guarded runtime. The bundle registers Drupal.editors.blocknote, once and only if nothing holds that name yet, so that a second copy of the bundle on the same page cannot take the registration away from the copy that owns the editors already attached, and it exposes exactly one global object, Drupal.blocknote. That object carries React, ReactDOM, the JSX runtime, @blocknote/core and @blocknote/react, so that an extension built separately resolves the same React instance rather than a second copy, which React does not tolerate. It also carries a block-spec registry that refuses duplicate registrations and registrations whose declared version does not match the block plugin's, alter hooks for editor options, and lifecycle events. js/src/runtime.ts checks the runtime contract version an extension declares against the version the bundle provides and refuses a duplicate or unnamed registration, and buildClientSchema() in js/src/schema.tsx makes the version check against the PHP plugin: a registered specification whose declared version disagrees with the blockVersions the editor plugin sends for the format is left out of the schema, so the block reaches the author in the unsupported-block carrier rather than in an editable block whose document the server would refuse. The lifecycle events, blocknote:ready, blocknote:change and blocknote:destroy, are dispatched on the textarea the editor is attached to. Nothing is published as window.React.
Extensions. A documented Vite preset builds an extension with those modules declared as externals mapped onto the runtime, so a third-party module can add a block without rebuilding this module's bundle and without shipping React again. js/preset/vite.preset.ts is that preset: seven module ids are external and read off Drupal.blocknote, a build-time guard refuses any other entry point of those packages by name rather than letting a second copy of ProseMirror onto the page, and the built file opens with a guard that throws when Drupal.blocknote is absent, which is what a library that forgot to depend on blocknote/editor gets instead of an anonymous type error. js/preset/README.md is generated from the pins the module was built with, so the versions it tells an extension author to use are the ones the runtime actually publishes.
Admin theme isolation. The kit's stylesheet stays inside the editor on its own, but the document the editor renders is built from the elements an admin theme styles globally, so js/src/theme-isolation.css is imported by the entry and restores, under .bn-container, what those rules take over: table margins, row and cell colour, height and type size, the decorative quotation mark Claro prints, heading weight and the divider. Every selector in it starts at .bn-container, which outranks any chain of element selectors, and the two properties BlockNote drives from an attribute on the same element, data-text-color and data-background-color, are excluded rather than outranked. The editor's container also carries data-drupal-ck-style-fence, core's documented opt-out from the off-canvas dialog's all: revert stylesheet, and the colour scheme comes from the computed color-scheme of the page rather than from the operating system, so the editor follows the admin theme into dark mode instead of following the author's desktop; a page that declares neither scheme is treated as light, which is what the browser renders its own widgets as, and only a page that declares both leaves the choice to the operating system. The editor follows a change to the page's color-scheme, such as Gin's dark mode toggle, while it is attached. Amended 2026-09-15. The file is no longer only isolation: it also draws the box an admin theme gives a form control, which no theme draws for an editor that is not core's, and 0013 records where that box goes and what it is made of.
What is not shipped. emoji-mart and @emoji-mart/data are aliased to empty stubs and the emoji picker user interface is switched off, so the IIFE does not include them or the Preact they bring. Amended 2026-10-31. BlockNote 0.57.0 replaced emoji-mart with frimousse, which @blocknote/react imports statically, and moved the picker's data, one module per language, into @blocknote/core/emoji-data, which it reaches through a dynamic import. vite.config.ts aliases both to stubs in js/src/stubs/, and the emoji-mart stubs are gone. Without them dist/blocknote.js measured about 4.55 MB, against about 1.35 MB with them. The Inter font files are not shipped, so the kit's font stack falls through Inter to the system fonts the admin theme is already set in.
The kit's stylesheet, narrowed. Amended 2026-09-15. The kit's stylesheet is not shipped whole. @blocknote/mantine/style.css imports the stylesheets of all 78 @mantine/core components, because a kit cannot know which components the editor built on it renders. js/src/mantine.css takes its place with the 24 of those imports whose classes the built bundle names, followed by the kit's own Mantine baseline and default custom properties, copied from its src/mantineStyles.css unchanged because they sit in the same file as the imports; js/src/index.tsx imports the kit's other half, blocknoteStyles.css, directly after it, so the cascade keeps the order upstream writes it in. The rule for what belongs in that file is reachability: Mantine's CSS modules build compiles a component's styles into hashed m_ class and keyframes names and the component carries those names into the bundle, so a stylesheet is needed exactly when the built script names a class it defines. js/src/__tests__/mantine.test.ts holds that rule over the committed dist/ pair and fails when the script names a Mantine class the stylesheet does not define, naming the @mantine/core stylesheet to import for each one, which is what a BlockNote release that reaches for a component new to the editor produces. Amended 2026-10-31. BlockNote 0.57.0 is such a release: its bundle also names the classes of Mantine's Title, so the file carries 25 imports, Title.css among them.
Translation. The editor's interface strings come from a generated js/src/dictionary.ts that wraps BlockNote's English dictionary in literal Drupal.t() calls. Interface translation and localize.drupal.org then supply every language Drupal supports, including those upstream has no locale for. No upstream locale file is shipped. Core finds a translatable string in a script only when its argument is a quoted literal, and Vite's minifier writes template literals, so the build runs the drupalTranslatableStrings() plugin exported from js/preset/vite.preset.ts after minification. It rewrites every string argument of Drupal.t() and Drupal.formatPlural(), and a context in their options, into a quoted literal whose content equals its runtime value, and it fails the build for a string that cannot be quoted without an escape. An extension built through the preset gets the same plugin, and JavascriptTranslationExtractionTest runs core's _locale_parse_js_file() over the three committed bundles.
Provenance and licensing. The repository documents where the bundled code comes from, under which license and at which version, in THIRD_PARTY_NOTICES.md, generated from the lockfile, alongside the TypeScript sources the bundle is built from. The module ships no @blocknote/xl-* package: those are offered under GPL-3.0 or a commercial license, and GPL-3.0-only code cannot be hosted in a GPL-2.0-or-later repository on drupal.org. A site that has a commercial license can still install them itself and integrate them from a separate module.
Gates. Continuous integration installs the JavaScript dependencies, type-checks with tsc --noEmit, lints the TypeScript, runs the unit tests, rebuilds dist/ and fails if the rebuilt output differs from what is committed. A committed bundle that can drift from its sources is worse than no committed bundle.
Probe results, 2026-09-13¶
Measured on Drupal 11.4.6 with BlockNote 0.54.2 against Claro and against Gin at commit 356c4b55, in a real browser. Amended 2026-09-15. That commit is the tag of Gin 5.0.15, a published release whose core_version_requirement is ^11.2, so the measurement was made against a release that supports 11.4 and not against an untagged branch. The module's Gin coverage stays best effort, because the theme is not a dependency and is absent from continuous integration, where the browser tests that cover it are skipped.
- Claro's element rules do bleed in, and the bleed is bounded. Thirteen rules from Claro and core's
normalize.cssmatched inside the editor. The ones that changed the rendering weretd { height: 4rem },th { height; background; color; line-height },tr { border-bottom; background; color },table { margin-block },blockquote { font-size }with its::before { content: open-quote }, andhr { height; background }. Nothing reached the toolbar, the slash menu, the side menu, the link toolbar or the table handles beyondbutton { line-height }, because the kit renders its chrome from classes the themes do not name, and because the editor renders noselect,details,summary,ul,olorlielement at all.js/src/theme-isolation.csscloses every one of them. - Nothing leaks outward.
dist/blocknote.csshas no:root,html,bodyor bare*rule among its 1,936 selectors, none of its 1,799 loaded rules matched any of the 922 elements outside the editor on a node form, and attaching the stylesheet to a page with no editor changed no computed style on ten representative Claro elements. Amended 2026-10-31. Remeasured on the committeddist/blocknote.css(183,516 bytes, BlockNote 0.57.0) with Drupal 11.4.11, Claro and Gin 5.0.15 (commit 356c4b55), Chrome for Testing 153 and a scripted measurement through the development site, on a node add form whose document is empty and whose editor is idle. The stylesheet has 966 top-level rule blocks (932 style rules, 28@mediaand 6@keyframes), 982 style rules once those inside@mediaare counted, and 1,144 selectors, counting each comma-separated selector once. It has no:root,html,bodyor bare*rule, and every selector contains a class, an id or an attribute;*appears in 27 selectors, each as the child or descendant of a scoped selector. None of the 1,144 selectors matched any element outside.bn-container, which are 541 elements on the form in Claro and 570 in Gin, counting the document and head elements, against 8 inside it. The test ran with pseudo-elements removed from each selector, and again with state pseudo-classes such as:hoverand:focusremoved as well, so that a rule which could match in some state would show. Attaching the stylesheet to a page with no editor,/admin/content(975 elements in Claro, 1,057 in Gin) and a field list (429 and 437), changed no computed property of any element in either theme. On the form itself, switching the stylesheet off changed no property of any element outside the editor apart from the size and position that follow from the editor's own height: width and height on 31 elements in Claro and 29 in Gin, thegrid-template-rowsof the form layout in Claro, and thetopandbottomoffsets of four visually hidden elements in each theme. These counts are not comparable with the ones above: the earlier method is recorded only in outline, its 1,936 selectors and 1,799 loaded rules were taken before the stylesheet was narrowed, and the stylesheet has since grown from the 879 top-level blocks of the amendment of 2026-09-15 to 966. The property they were measured to show still holds. The open menus, popovers and a document with content were not measured. - Gin needs the same work at a higher specificity. Gin inherits Claro and adds its own
:where(*) table thchains, which outrank single element selectors, plush1, h2, h3 { font-weight: normal }and a rule that strips the underline from links inside tables. The isolation stylesheet is scoped by a class for exactly this reason. - Menus are not clipped inside a jQuery UI modal or an off-canvas tray. In both, the slash menu, the formatting toolbar and the side menu render above the container and stay visible, because their positioned wrapper's containing block is outside the scrolling element. The off-canvas tray was a different problem: core's
misc/dialog/off-canvas/css/reset.cssappliesall: revertto every element inside#drupal-off-canvas-wrapperat identifier specificity, which left the editor with no background, no padding, the tray's text colour and the tray's font. Thedata-drupal-ck-style-fenceattribute the reset exempts, which is how CKEditor 5 solves the same problem, restores it exactly. - Dark mode is legible once the editor is told about it. Gin's dark mode computes
color-scheme: dark, and the editor now reads that instead ofprefers-color-scheme, which had made the colour scheme a property of the author's desktop rather than of the admin theme. In dark mode the editor, its menus and its tables render in the kit's dark palette at contrast ratios of 7.4 to 18.1 against their backgrounds; the one exception is BlockNote's own red text colour at 4.2, which is below AA for body text and is a question for the accessibility pass. - A second React on the page is harmless, and a second copy of this bundle was not. React 18.3.1 loaded as
window.Reactalongside the bundle mounted its own component and left the editor working, withDrupal.blocknote.Reactreporting 19.3.0 and not identical towindow.React. React 19 publishes no UMD build, so a foreign React 19 arrives inside another module's own bundle rather than as a global. Loadingdist/blocknote.jsa second time preservedDrupal.blocknote, every attached editor and every instance id, but it replacedDrupal.editors.blocknotewith the second copy's object, whose map of attached editors is empty: a serialize detach then wrote nothing and a submit would have carried whatever the textarea held. The registration is now guarded the way the runtime is. - Keyboard: the editing surface and the formatting toolbar are reachable, the side menu is not. Tab reaches the contenteditable and leaves it again, Escape closes the slash menu without losing the caret, the arrow keys move the slash menu's selection, a selection made with Shift and the arrow keys opens the formatting toolbar, and Tab from there lands on its first button. The side menu renders only while the pointer hovers a block, so a keyboard-only author never sees it; its two buttons are ordinary labelled buttons once rendered. The contenteditable also has no accessible name, because the label points at the hidden textarea. Both belong to the accessibility pass.
What is still open¶
Two probe findings are closed. Gin's .field-multiple-table th rule outranked BlockNote's own cell background colors inside an unlimited-cardinality field; js/src/theme-isolation.css now restores BlockNote's padding, border and palette colors on those header cells with rules scoped to .field-multiple-table .bn-container and marked !important, because nothing less outranks Gin's rule. The formatting toolbar is wider than an off-canvas tray; inside #drupal-off-canvas it is placed with floating-ui's shift and size middleware, which keeps it inside the tray and wraps its buttons at the tray's width.
One is open. Claro's tabledrag table around an unlimited-cardinality field is wider than a 360px window, because the editor cell needs about 380px, so the page scrolls horizontally at that width. Both scoped fixes that were tried are rejected: a zero-width editor cell fit the page but shrank the editor at every window width, and a scrolling container around the table would clip the editor's menus, which are positioned inside it.
The built size of the IIFE, measured on 2026-09-14 at commit f9968bc with the pins above, the isolation stylesheet, the module-rendered menus, the owned link toolbar and block menu, and the file upload and resolution client included, was 1,272,962 bytes raw and 375,770 bytes gzipped for dist/blocknote.js and 333,755 bytes raw and 41,246 bytes gzipped for dist/blocknote.css. The method is stat -f%z <file> for the raw size and gzip -c <file> | wc -c for the compressed one, on the committed files. The figures in the context section are upstream measurements of a code-split build, not of this module's output.
Amended 2026-09-15, by the same method: dist/blocknote.js is 1,273,451 bytes raw and 375,949 bytes gzipped, and dist/blocknote.css, once narrowed to the Mantine component stylesheets the bundle's classes reach, is 168,442 bytes raw and 22,404 bytes gzipped. The selector and rule counts under "Nothing leaks outward" above were taken before that narrowing. The stylesheet now holds 879 top-level rule blocks where it held 1,837, and what those counts were measured to show, that it has no :root, html, body or bare * rule, still holds.
Amended 2026-09-19, by the same method, at commit a3c859c: dist/blocknote.js is 1,283,961 bytes raw and 379,211 bytes gzipped, and dist/blocknote.css is 169,101 bytes raw and 22,520 bytes gzipped. A source-map-explorer reading of a build with a source map made from the same sources, at the same commit, attributes the JavaScript's bytes to the editor's own upstream packages, BlockNote, ProseMirror, Tiptap and floating-ui together, at about 66%, to React and ReactDOM at about 17%, to the Mantine packages the kit renders through at about 11%, and to this module's own js/src/ at about 6%, with no package duplicated and nothing the module pulls in on its own reaching a size worth acting on; this is a recorded baseline, not a change. The reading was made with a scratch Vite config, copied from vite.config.ts with build.sourcemap set to true and build.outDir pointed at a scratch directory so the committed dist/ was not touched, built once, then read with bunx source-map-explorer --json --no-border-checks against that scratch output.
An extension built through the preset is small, which is the point of the seven externals. blocknote_test's callout, the worked extension, is 2,791 bytes raw and 1,103 bytes gzipped of JavaScript and 922 bytes raw and 328 bytes gzipped of CSS: a block, its editing chrome and its stylesheet, with no React and no BlockNote inside them.
Alternatives considered¶
The Ariakit kit. Its stylesheet is roughly 28 kB gzipped smaller and it defines only --bn-* custom properties. It lost because Mantine is upstream's default and the kit every upstream example is written against, which matters for a pre-1.0 dependency whose documentation is the only specification that exists. It remains the fallback if theme isolation fails.
The shadcn kit. Rejected. It requires the host site to run Tailwind, which is not an assumption a Drupal module may make about an admin theme.
No React, mounting @blocknote/core alone. Rejected. It mounts, but it produces an editor with no toolbar, no slash menu, no side menu, no link toolbar and no table handles, which is not the product.
ES modules with an import map. Rejected. Core exercises neither, so the module would be proving out that path on the way to shipping an editor.
Building at release time instead of committing dist/. Rejected for a single-module repository. It means the checked-out repository is not installable, and it moves the build into a tag process that has to be right every time. The drift guard buys most of the same safety at much lower cost.
Loading the library from a CDN or asking site owners to download it. Rejected. drupal.org's own guidance calls CDN loading a bad idea, and the download-it-yourself pattern is a documented source of recurring breakage in other modules.
Shipping upstream locale files. Rejected. It would cover twenty-three languages, exclude the rest, and route translation through a mechanism no Drupal translator uses. The Drupal.t() dictionary covers every language Drupal does.
Consequences¶
The admin-side payload is large. A few hundred kilobytes of gzipped JavaScript is loaded on any form with a BlockNote field. It is admin-only and is never loaded on a public page, because public output is rendered in PHP (see 0004).
Pinning to an exact upstream version means upgrades are deliberate work: the fixture corpus is rerun, any change in the JSON shape is recorded, and an envelope schema migration is written if one is needed. Given that upstream broke its JSON in two of six recent minors, that is the intended friction.
Committing dist/ means every change to the TypeScript produces a large diff in the same commit. The drift guard makes the alternative, a stale bundle, fail loudly instead of silently.
One runtime object is a public API surface. Once extensions depend on Drupal.blocknote, its shape cannot change without breaking them, which means the registry and the alter hooks have to be designed as though they are permanent, because they are.
The emoji picker is not available. Authors insert emoji with their operating system's picker. Turning it back on would mean shipping the emoji data and a second rendering library inside the bundle. Amended 2026-10-31. Since BlockNote 0.57.0 the second library is frimousse, which renders with the editor's own React rather than a Preact of its own, but the data is every language's emoji names, about three megabytes inlined.
Every editor string has to exist in the generated dictionary for interface translation to reach it. When upstream adds a string, the dictionary has to be regenerated, and an untranslated string falls back to English rather than disappearing.
References¶
- BlockNote 0.54.2: https://www.blocknotejs.org and https://github.com/TypeCellOS/BlockNote
- Upstream licensing, including the
@blocknote/xl-*exception:LICENSE.txtin the BlockNote repository. - Drupal core's committed CKEditor 5 build:
core/assets/vendor, declared incore.libraries.yml. - Including third-party libraries on drupal.org: https://www.drupal.org/docs/develop/git/setting-up-git-for-drupal/drupal-git-usage-policies/including-3rd-party-libraries
- Policy on third-party assets on drupal.org: https://www.drupal.org/node/422996
- 0004, 0005.