Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/core/src/extensions/LinkToolbar/LinkToolbar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getMarkRange, posToDOMRect } from "@tiptap/core";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { getPmSchema } from "../../api/pmUtil.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";

export const LinkToolbarExtension = createExtension(({ editor }) => {
function getLinkElementAtPos(pos: number) {
Expand Down Expand Up @@ -67,6 +67,11 @@ export const LinkToolbarExtension = createExtension(({ editor }) => {

getLinkAtElement(element: HTMLElement) {
return editor.transact(() => {
// Q4: posAtDOM can fail if the editor view is not available
// (e.g. if the editor is not mounted)
// a) Unfortunately, TS doesn't give an error about this. Can we make this type safe?
// b) Double check other references of editor.prosemirrorView
Copy link
Contributor

Choose a reason for hiding this comment

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

posAtDom fails, or does accessing editor.prosemirrorView?

TS could never know if posAtDom fails at runtime
TS could say the editor.prosemirrorView is undefined

anytime editor.prosemirrorView or editor.prosemirrorState is used, it should be considered a failing of our API

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

accessing posAtDom fails fails (because there's no underlying view in the tiptap layer).

anytime editor.prosemirrorView or editor.prosemirrorState is used, it should be considered a failing of our API

Agree, this seems like the root cause. Will start eng. roadmap and add this


const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1;
return getMarkAtPos(posAtElement, "link");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,11 @@ export const CreateLinkButton = () => {
}
};

editor.domElement?.addEventListener("keydown", callback);
const domElement = editor.domElement;
domElement?.addEventListener("keydown", callback);

return () => {
editor.domElement?.removeEventListener("keydown", callback);
domElement?.removeEventListener("keydown", callback);
};
}, [editor.domElement]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Range } from "@tiptap/core";
import { FC, useEffect, useMemo, useState } from "react";

import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
import { LinkToolbar } from "./LinkToolbar.js";
import { LinkToolbarProps } from "./LinkToolbarProps.js";
import { useExtension } from "../../hooks/useExtension.js";
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
import {
GenericPopover,
GenericPopoverReference,
} from "../Popovers/GenericPopover.js";
import { LinkToolbar } from "./LinkToolbar.js";
import { LinkToolbarProps } from "./LinkToolbarProps.js";

export const LinkToolbarController = (props: {
linkToolbar?: FC<LinkToolbarProps>;
Expand Down Expand Up @@ -98,15 +98,18 @@ export const LinkToolbarController = (props: {
const destroyOnSelectionChangeHandler =
editor.onSelectionChange(textCursorCallback);

editor.domElement?.addEventListener("mouseover", mouseCursorCallback);
const domElement = editor.domElement;

// Q 1: why can domElement be available when <LinkToolbarController/> is rendered?
Copy link
Contributor

Choose a reason for hiding this comment

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

Why would domElement not be available? It would seem to me that is sort of the default state of things?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Whoops my comment was wrong. Question should be why it can be unavailable (i.e. undefined).

// Q 2: this useEffect will not necessarily run when editor.domElement changes
Copy link
Contributor

Choose a reason for hiding this comment

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

We need to enable to react eslint plugin, it would have caught this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think it's just a matter of eslint; because the component will also not automatically rerender when domElement changes; accessing the domElement property is non-reactive, so it's "dangerous" to depend on this state from the React layer?

Copy link
Contributor

Choose a reason for hiding this comment

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

Was this this observed, or a hypothetical?

because the component will also not automatically rerender when domElement changes;

My understanding is that this reference should be stable across renders.

domElement?.addEventListener("mouseover", mouseCursorCallback);

return () => {
destroyOnChangeHandler();
destroyOnSelectionChangeHandler();

editor.domElement?.removeEventListener("mouseover", mouseCursorCallback);
domElement?.removeEventListener("mouseover", mouseCursorCallback);
};
}, [editor, linkToolbar, link, toolbarPositionFrozen]);
}, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]);

const floatingUIOptions = useMemo<FloatingUIOptions>(
() => ({
Expand Down Expand Up @@ -161,6 +164,7 @@ export const LinkToolbarController = (props: {
[link?.element],
);

// Q3: similar to Q2; are we sure the component rerenders when editor.isEditable changes?
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be derived from a useEditorState, rather than queried at render time

if (!editor.isEditable) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,11 @@ export function useGridSuggestionMenuKeyboardNavigation<Item>(
return false;
};

editor.domElement?.addEventListener(
"keydown",
handleMenuNavigationKeys,
true,
);
const domElement = editor.domElement;
domElement?.addEventListener("keydown", handleMenuNavigationKeys, true);

return () => {
editor.domElement?.removeEventListener(
domElement?.removeEventListener(
"keydown",
handleMenuNavigationKeys,
true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ export function useSuggestionMenuKeyboardNavigation<Item>(
useSuggestionMenuKeyboardHandler(items, onItemClick);

useEffect(() => {
(element || editor.domElement)?.addEventListener("keydown", handler, true);
const el = element || editor.domElement;
el?.addEventListener("keydown", handler, true);

return () => {
(element || editor.domElement)?.removeEventListener(
"keydown",
handler,
true,
);
el?.removeEventListener("keydown", handler, true);
};
}, [editor.domElement, items, selectedIndex, onItemClick, element, handler]);

Expand Down
83 changes: 83 additions & 0 deletions tests/src/unit/react/BlockNoteViewRemountHover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { BlockNoteEditor } from "@blocknote/core";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import React from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

// https://github.com/TypeCellOS/BlockNote/pull/2335
describe("BlockNoteView new editor + hover", () => {
let div: HTMLDivElement;

beforeEach(() => {
div = document.createElement("div");
document.body.appendChild(div);
});

afterEach(() => {
document.body.removeChild(div);
});

it("should not throw error when replacing editor in same container and mouseovering", async () => {
// 1. Setup container
const editor1 = BlockNoteEditor.create();
const root = createRoot(div);

// 2. Render first editor twice
flushSync(() => {
root.render(
<React.StrictMode>
<BlockNoteView editor={editor1} />
</React.StrictMode>,
);
});

await new Promise((resolve) => setTimeout(resolve, 0));
flushSync(() => {
root.render(
<React.StrictMode>
<BlockNoteView editor={editor1} />
</React.StrictMode>,
);
});

await new Promise((resolve) => setTimeout(resolve, 0));

const editor1DomElement = editor1.domElement;
expect(editor1DomElement).toBeDefined();

// 3. Replace with new editor in same container
// This causes LinkToolbarController of editor1 to unmount and editor2 to mount
const editor2 = BlockNoteEditor.create();
flushSync(() => {
root.render(
<React.StrictMode>
<BlockNoteView editor={editor2} />
</React.StrictMode>,
);
});

await new Promise((resolve) => setTimeout(resolve, 0));

const editor2DomElement = editor2.domElement;
expect(editor2DomElement).toBeDefined();

// 4. Simulate mouseover on the OLD element.
// If the listener was leaked (due to editor.domElement being null/changed at cleanup),
// this will fire the callback. callback uses editor1 which might be in bad state -> Crash.

expect(() => {
editor1DomElement!.dispatchEvent(
new MouseEvent("mouseover", { bubbles: true }),
);
}).not.toThrow();

await new Promise((resolve) => setTimeout(resolve, 0));

// Cleanup
editor1._tiptapEditor.destroy();
editor2._tiptapEditor.destroy();
root.unmount();
});
});
Loading