Skip to content

Commit eb48e3e

Browse files
authored
fix: LinkToolbar Event Listener leak (#2335)
1 parent 31dd65e commit eb48e3e

File tree

6 files changed

+101
-21
lines changed

6 files changed

+101
-21
lines changed

packages/core/src/extensions/LinkToolbar/LinkToolbar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getMarkRange, posToDOMRect } from "@tiptap/core";
2-
import { createExtension } from "../../editor/BlockNoteExtension.js";
32
import { getPmSchema } from "../../api/pmUtil.js";
3+
import { createExtension } from "../../editor/BlockNoteExtension.js";
44

55
export const LinkToolbarExtension = createExtension(({ editor }) => {
66
function getLinkElementAtPos(pos: number) {

packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,11 @@ export const CreateLinkButton = () => {
9494
}
9595
};
9696

97-
editor.domElement?.addEventListener("keydown", callback);
97+
const domElement = editor.domElement;
98+
domElement?.addEventListener("keydown", callback);
9899

99100
return () => {
100-
editor.domElement?.removeEventListener("keydown", callback);
101+
domElement?.removeEventListener("keydown", callback);
101102
};
102103
}, [editor.domElement]);
103104

packages/react/src/components/LinkToolbar/LinkToolbarController.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { Range } from "@tiptap/core";
44
import { FC, useEffect, useMemo, useState } from "react";
55

66
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
7-
import { LinkToolbar } from "./LinkToolbar.js";
8-
import { LinkToolbarProps } from "./LinkToolbarProps.js";
97
import { useExtension } from "../../hooks/useExtension.js";
108
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
119
import {
1210
GenericPopover,
1311
GenericPopoverReference,
1412
} from "../Popovers/GenericPopover.js";
13+
import { LinkToolbar } from "./LinkToolbar.js";
14+
import { LinkToolbarProps } from "./LinkToolbarProps.js";
1515

1616
export const LinkToolbarController = (props: {
1717
linkToolbar?: FC<LinkToolbarProps>;
@@ -98,15 +98,16 @@ export const LinkToolbarController = (props: {
9898
const destroyOnSelectionChangeHandler =
9999
editor.onSelectionChange(textCursorCallback);
100100

101-
editor.domElement?.addEventListener("mouseover", mouseCursorCallback);
101+
const domElement = editor.domElement;
102+
103+
domElement?.addEventListener("mouseover", mouseCursorCallback);
102104

103105
return () => {
104106
destroyOnChangeHandler();
105107
destroyOnSelectionChangeHandler();
106-
107-
editor.domElement?.removeEventListener("mouseover", mouseCursorCallback);
108+
domElement?.removeEventListener("mouseover", mouseCursorCallback);
108109
};
109-
}, [editor, linkToolbar, link, toolbarPositionFrozen]);
110+
}, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]);
110111

111112
const floatingUIOptions = useMemo<FloatingUIOptions>(
112113
() => ({
@@ -161,6 +162,7 @@ export const LinkToolbarController = (props: {
161162
[link?.element],
162163
);
163164

165+
// TODO: this should be a hook to be reactive
164166
if (!editor.isEditable) {
165167
return null;
166168
}

packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,11 @@ export function useGridSuggestionMenuKeyboardNavigation<Item>(
6666
return false;
6767
};
6868

69-
editor.domElement?.addEventListener(
70-
"keydown",
71-
handleMenuNavigationKeys,
72-
true,
73-
);
69+
const domElement = editor.domElement;
70+
domElement?.addEventListener("keydown", handleMenuNavigationKeys, true);
7471

7572
return () => {
76-
editor.domElement?.removeEventListener(
73+
domElement?.removeEventListener(
7774
"keydown",
7875
handleMenuNavigationKeys,
7976
true,

packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,11 @@ export function useSuggestionMenuKeyboardNavigation<Item>(
1515
useSuggestionMenuKeyboardHandler(items, onItemClick);
1616

1717
useEffect(() => {
18-
(element || editor.domElement)?.addEventListener("keydown", handler, true);
18+
const el = element || editor.domElement;
19+
el?.addEventListener("keydown", handler, true);
1920

2021
return () => {
21-
(element || editor.domElement)?.removeEventListener(
22-
"keydown",
23-
handler,
24-
true,
25-
);
22+
el?.removeEventListener("keydown", handler, true);
2623
};
2724
}, [editor.domElement, items, selectedIndex, onItemClick, element, handler]);
2825

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { BlockNoteEditor } from "@blocknote/core";
2+
import { BlockNoteView } from "@blocknote/mantine";
3+
import "@blocknote/mantine/style.css";
4+
import React from "react";
5+
import { flushSync } from "react-dom";
6+
import { createRoot } from "react-dom/client";
7+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
8+
9+
// https://github.com/TypeCellOS/BlockNote/pull/2335
10+
describe("BlockNoteView new editor + hover", () => {
11+
let div: HTMLDivElement;
12+
13+
beforeEach(() => {
14+
div = document.createElement("div");
15+
document.body.appendChild(div);
16+
});
17+
18+
afterEach(() => {
19+
document.body.removeChild(div);
20+
});
21+
22+
it("should not throw error when replacing editor in same container and mouseovering", async () => {
23+
// 1. Setup container
24+
const editor1 = BlockNoteEditor.create();
25+
const root = createRoot(div);
26+
27+
// 2. Render first editor twice
28+
flushSync(() => {
29+
root.render(
30+
<React.StrictMode>
31+
<BlockNoteView editor={editor1} />
32+
</React.StrictMode>,
33+
);
34+
});
35+
36+
await new Promise((resolve) => setTimeout(resolve, 0));
37+
flushSync(() => {
38+
root.render(
39+
<React.StrictMode>
40+
<BlockNoteView editor={editor1} />
41+
</React.StrictMode>,
42+
);
43+
});
44+
45+
await new Promise((resolve) => setTimeout(resolve, 0));
46+
47+
const editor1DomElement = editor1.domElement;
48+
expect(editor1DomElement).toBeDefined();
49+
50+
// 3. Replace with new editor in same container
51+
// This causes LinkToolbarController of editor1 to unmount and editor2 to mount
52+
const editor2 = BlockNoteEditor.create();
53+
flushSync(() => {
54+
root.render(
55+
<React.StrictMode>
56+
<BlockNoteView editor={editor2} />
57+
</React.StrictMode>,
58+
);
59+
});
60+
61+
await new Promise((resolve) => setTimeout(resolve, 0));
62+
63+
const editor2DomElement = editor2.domElement;
64+
expect(editor2DomElement).toBeDefined();
65+
66+
// 4. Simulate mouseover on the OLD element.
67+
// If the listener was leaked (due to editor.domElement being null/changed at cleanup),
68+
// this will fire the callback. callback uses editor1 which might be in bad state -> Crash.
69+
70+
expect(() => {
71+
editor1DomElement!.dispatchEvent(
72+
new MouseEvent("mouseover", { bubbles: true }),
73+
);
74+
}).not.toThrow();
75+
76+
await new Promise((resolve) => setTimeout(resolve, 0));
77+
78+
// Cleanup
79+
editor1._tiptapEditor.destroy();
80+
editor2._tiptapEditor.destroy();
81+
root.unmount();
82+
});
83+
});

0 commit comments

Comments
 (0)