/* eslint-disable react/globals -- The test component exposes a rerender callback to its parent test. */
import { BlockNoteEditor } from "@blocknote/core";
import { filterSuggestionItems } from "@blocknote/core/extensions";
import {
getDefaultReactSlashMenuItems,
type PortalElementsMap,
SuggestionMenuController,
} from "@blocknote/react";
import { act, Profiler, StrictMode, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import { BlockNoteView } from "./BlockNoteView.js";
import "./style.css";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
/**
* Guards the render profile of `BlockNoteView` around portalling: routing the
* floating UI elsewhere must not cost React commits or child renders. An
* earlier portal implementation added one to three commits at mount depending
* on the setup; that is the regression these pin.
*
* Every number is relative to the default setup measured in the same run, so
* unrelated changes to how the editor itself renders don't break them. Both
* StrictMode settings are covered: its double-invoked effects are where
* append/remove and register/unregister pairs go wrong.
*/
type Setup =
| "default"
| "portalElements.default"
| "portalElements.slashMenu"
| "controller";
const PORTAL_SETUPS: Setup[] = [
"portalElements.default",
"portalElements.slashMenu",
"controller",
];
let editor: BlockNoteEditor;
let root: Root | undefined;
let container: HTMLDivElement;
let target: HTMLDivElement;
let commits = 0;
let childRenders = 0;
let rerenderParent: () => void = () => {};
function onRender() {
commits++;
}
function Child() {
childRenders++;
return null;
}
function Harness(props: { setup: Setup }) {
const [, setTick] = useState(0);
rerenderParent = () => setTick((tick) => tick + 1);
const portalElements: PortalElementsMap | undefined =
props.setup === "portalElements.default"
? { default: target }
: props.setup === "portalElements.slashMenu"
? { slashMenu: target }
: undefined;
return (
{props.setup === "controller" && (
filterSuggestionItems(
getDefaultReactSlashMenuItems(editor),
query,
)
}
portalElement={target}
/>
)}
);
}
/**
* Waits until no commit has landed for a while. Mounting schedules an update
* from an effect that can commit after `act` has returned, and on Linux WebKit
* whether it does varies from mount to mount; measuring only once things have
* settled makes every mount count the same set of commits.
*/
async function settle() {
let last = commits;
for (let i = 0; i < 20; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
if (commits === last) {
return;
}
last = commits;
}
throw new Error("commits did not settle");
}
async function mount(setup: Setup, strict: boolean) {
editor = BlockNoteEditor.create();
commits = 0;
childRenders = 0;
root = createRoot(container);
const tree = ;
await act(async () => {
root!.render(strict ? {tree} : tree);
});
await settle();
if (!container.querySelector(".bn-editor")) {
throw new Error("editor did not mount");
}
return { commits, childRenders };
}
async function unmount() {
await act(async () => {
root?.unmount();
});
root = undefined;
editor._tiptapEditor.destroy();
}
beforeEach(() => {
container = document.createElement("div");
target = document.createElement("div");
document.body.append(container, target);
});
afterEach(async () => {
if (root) {
await unmount();
}
container.remove();
target.remove();
});
describe.each([{ strict: false }, { strict: true }])(
"BlockNoteView render profile with portals (StrictMode: $strict)",
({ strict }) => {
test("portal setups add no commits or child renders at mount", async () => {
const baseline = await mount("default", strict);
await unmount();
for (const setup of PORTAL_SETUPS) {
const measured = await mount(setup, strict);
await unmount();
expect({ setup, ...measured }).toEqual({ setup, ...baseline });
}
});
test("an unrelated parent re-render costs the same with portals as without", async () => {
async function rerenderCost(setup: Setup) {
await mount(setup, strict);
const before = { commits, childRenders };
await act(async () => {
rerenderParent();
});
await settle();
const cost = {
commits: commits - before.commits,
childRenders: childRenders - before.childRenders,
};
await unmount();
return cost;
}
const baseline = await rerenderCost("default");
for (const setup of PORTAL_SETUPS) {
expect({ setup, ...(await rerenderCost(setup)) }).toEqual({
setup,
...baseline,
});
}
});
},
);