mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
fix(desktop): survive an unmounted Tiptap view in the formatting tray
Clicking into a channel could leave the pane blank, with the console
showing:
[tiptap error]: The editor view is not available.
Cannot access view['dom']. The editor may not be mounted yet.
That string is a *thrown* Error, not a warning. Tiptap v3 returns a Proxy
from `editor.view` before the ProseMirror view is mounted, and the Proxy's
`get` trap throws for any key absent from its small target — including
`dom`.
`SelectionFormattingTray` read `editor.view.dom` directly inside a
`React.useEffect`. That effect runs on the same commit that mounts
`EditorContent`, so on a freshly mounted composer the view can still be
absent; the throw escaped the effect and React tore down the surrounding
subtree, which is why navigating into a channel appeared to do nothing.
Guard the access and retry on the next frame until the view exists,
matching the existing convention in the sibling hook
`useComposerSpoilerParticles`, which already wraps the same access in
try/catch with a rAF retry. Subscriptions now attach once the view is
real, and teardown cancels a pending retry as well as detaching
listeners.
Added `SelectionFormattingTrayUnmountedView.test.mjs`, which renders the
real component against a fake editor whose `view` reproduces Tiptap's
throwing Proxy (on a prototype getter, as the real `Editor` has it) and
then mounts on a later frame. Verified the two new mount tests fail
against `origin/main` with the exact error string above and pass with
this change.
Testing: `pnpm check`, `pnpm typecheck`, and `pnpm test`
(5455 passing, 0 failing).
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -161,7 +161,16 @@ export function SelectionFormattingTray({
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDom = editor.view.dom;
|
||||
// Tiptap v3 returns a Proxy from `editor.view` before the ProseMirror view
|
||||
// is mounted, and that Proxy *throws* for any key it does not carry —
|
||||
// including `dom`. This effect runs on the same commit that mounts
|
||||
// `EditorContent`, so on a freshly mounted composer (e.g. navigating into a
|
||||
// channel) `editor.view.dom` can throw and tear down the subtree. Retry on
|
||||
// the next frame until the view exists, matching `useComposerSpoilerParticles`.
|
||||
let cancelled = false;
|
||||
let retryFrame = 0;
|
||||
let detach: (() => void) | undefined;
|
||||
|
||||
const hide = () => setPosition(null);
|
||||
const handleContextMenu = () => {
|
||||
suppressRightClickUpdatesRef.current = true;
|
||||
@@ -176,28 +185,48 @@ export function SelectionFormattingTray({
|
||||
if (event.button === 0) clearSuppression();
|
||||
};
|
||||
|
||||
scheduleUpdate();
|
||||
editor.on("selectionUpdate", scheduleUpdate);
|
||||
editor.on("transaction", scheduleUpdate);
|
||||
editor.on("focus", scheduleUpdate);
|
||||
editor.on("blur", hide);
|
||||
editorDom.addEventListener("contextmenu", handleContextMenu);
|
||||
editorDom.addEventListener("pointerdown", handlePointerDown);
|
||||
editorDom.addEventListener("keydown", clearSuppression);
|
||||
window.addEventListener("resize", scheduleUpdate);
|
||||
window.addEventListener("scroll", scheduleUpdate, true);
|
||||
const attach = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
let editorDom: HTMLElement;
|
||||
try {
|
||||
editorDom = editor.view.dom as HTMLElement;
|
||||
} catch {
|
||||
retryFrame = window.requestAnimationFrame(attach);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleUpdate();
|
||||
editor.on("selectionUpdate", scheduleUpdate);
|
||||
editor.on("transaction", scheduleUpdate);
|
||||
editor.on("focus", scheduleUpdate);
|
||||
editor.on("blur", hide);
|
||||
editorDom.addEventListener("contextmenu", handleContextMenu);
|
||||
editorDom.addEventListener("pointerdown", handlePointerDown);
|
||||
editorDom.addEventListener("keydown", clearSuppression);
|
||||
window.addEventListener("resize", scheduleUpdate);
|
||||
window.addEventListener("scroll", scheduleUpdate, true);
|
||||
|
||||
detach = () => {
|
||||
editor.off("selectionUpdate", scheduleUpdate);
|
||||
editor.off("transaction", scheduleUpdate);
|
||||
editor.off("focus", scheduleUpdate);
|
||||
editor.off("blur", hide);
|
||||
editorDom.removeEventListener("contextmenu", handleContextMenu);
|
||||
editorDom.removeEventListener("pointerdown", handlePointerDown);
|
||||
editorDom.removeEventListener("keydown", clearSuppression);
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
window.removeEventListener("scroll", scheduleUpdate, true);
|
||||
};
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryFrame) window.cancelAnimationFrame(retryFrame);
|
||||
cancelScheduledUpdate();
|
||||
editor.off("selectionUpdate", scheduleUpdate);
|
||||
editor.off("transaction", scheduleUpdate);
|
||||
editor.off("focus", scheduleUpdate);
|
||||
editor.off("blur", hide);
|
||||
editorDom.removeEventListener("contextmenu", handleContextMenu);
|
||||
editorDom.removeEventListener("pointerdown", handlePointerDown);
|
||||
editorDom.removeEventListener("keydown", clearSuppression);
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
window.removeEventListener("scroll", scheduleUpdate, true);
|
||||
detach?.();
|
||||
};
|
||||
}, [cancelScheduledUpdate, editor, scheduleUpdate]);
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Regression guard for the "[tiptap error]: The editor view is not available"
|
||||
* crash that made clicking into a channel appear to do nothing.
|
||||
*
|
||||
* Tiptap v3 (`@tiptap/core` 3.x) does not return `undefined` from
|
||||
* `editor.view` before the ProseMirror view is mounted — it returns a Proxy
|
||||
* whose `get` trap *throws* for any key it does not carry, including `dom`:
|
||||
*
|
||||
* throw new Error(`[tiptap error]: The editor view is not available.
|
||||
* Cannot access view['dom']. The editor may not be mounted yet.`)
|
||||
*
|
||||
* `SelectionFormattingTray` subscribes to composer DOM events in a
|
||||
* `React.useEffect`, which runs on the same commit that mounts
|
||||
* `EditorContent`. On a freshly mounted composer the view can still be absent,
|
||||
* so an unguarded `editor.view.dom` throws out of the effect and React tears
|
||||
* down the surrounding subtree — the channel pane never appears.
|
||||
*
|
||||
* These tests use a fake editor whose `view` getter reproduces the throwing
|
||||
* Proxy, then flips to a real DOM node on a later frame, mirroring the real
|
||||
* mount sequence.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
// jsdom does not implement rAF. Back it with a real macrotask queue so the
|
||||
// component's retry loop and the tests' frame waits use the same clock.
|
||||
const rafCallbacks = new Map();
|
||||
let nextRafHandle = 1;
|
||||
|
||||
before(() => {
|
||||
dom.window.requestAnimationFrame = (cb) => {
|
||||
const handle = nextRafHandle++;
|
||||
rafCallbacks.set(
|
||||
handle,
|
||||
setTimeout(() => {
|
||||
rafCallbacks.delete(handle);
|
||||
cb(Date.now());
|
||||
}, 0),
|
||||
);
|
||||
return handle;
|
||||
};
|
||||
dom.window.cancelAnimationFrame = (handle) => {
|
||||
const timer = rafCallbacks.get(handle);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
rafCallbacks.delete(handle);
|
||||
};
|
||||
|
||||
Object.assign(globalThis, {
|
||||
cancelAnimationFrame: dom.window.cancelAnimationFrame,
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame,
|
||||
CustomEvent: dom.window.CustomEvent,
|
||||
document: dom.window.document,
|
||||
DOMRect: dom.window.DOMRect,
|
||||
Element: dom.window.Element,
|
||||
Event: dom.window.Event,
|
||||
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
Node: dom.window.Node,
|
||||
ResizeObserver: class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
},
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
/**
|
||||
* Build a fake Tiptap editor whose `view` throws exactly like the real
|
||||
* unmounted-view Proxy until `mount()` is called.
|
||||
*/
|
||||
function createFakeEditor() {
|
||||
const listeners = new Map();
|
||||
let mountedDom = null;
|
||||
|
||||
// A real Tiptap `Editor` exposes `view` as a *prototype* getter, so React's
|
||||
// dev-mode effect logging (which enumerates own enumerable properties) never
|
||||
// touches it. Defining it on a prototype here keeps the fake faithful; an own
|
||||
// getter would throw during React's introspection rather than in the effect
|
||||
// under test.
|
||||
const proto = {};
|
||||
Object.defineProperty(proto, "view", {
|
||||
get() {
|
||||
if (mountedDom) {
|
||||
return {
|
||||
coordsAtPos: () => ({ bottom: 10, left: 0, right: 5, top: 0 }),
|
||||
dom: mountedDom,
|
||||
domAtPos: () => ({ node: mountedDom, offset: 0 }),
|
||||
};
|
||||
}
|
||||
// Mirror `@tiptap/core`'s `get view()`: a Proxy over a small target that
|
||||
// throws only for keys absent from it — so `dom` throws, but the keys
|
||||
// Tiptap itself provides behave normally.
|
||||
const target = {
|
||||
composing: false,
|
||||
dispatch: () => {},
|
||||
dragging: null,
|
||||
editable: true,
|
||||
isDestroyed: false,
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
updateState: () => {},
|
||||
};
|
||||
return new Proxy(target, {
|
||||
get(obj, key) {
|
||||
if (key in obj) return Reflect.get(obj, key);
|
||||
throw new Error(
|
||||
`[tiptap error]: The editor view is not available. Cannot access view['${String(key)}']. The editor may not be mounted yet.`,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const editor = Object.assign(Object.create(proto), {
|
||||
isEditable: true,
|
||||
isFocused: false,
|
||||
off(event, fn) {
|
||||
listeners.get(event)?.delete(fn);
|
||||
},
|
||||
on(event, fn) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event).add(fn);
|
||||
},
|
||||
state: { selection: { from: 0, to: 0 } },
|
||||
});
|
||||
|
||||
return {
|
||||
editor,
|
||||
listenerCount: (event) => listeners.get(event)?.size ?? 0,
|
||||
mount() {
|
||||
mountedDom = dom.window.document.createElement("div");
|
||||
dom.window.document.body.append(mountedDom);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("tray mounts without throwing when the editor view is not yet available", async () => {
|
||||
const React = await import("react");
|
||||
const { act, render } = await import("@testing-library/react");
|
||||
const { SelectionFormattingTray } = await import(
|
||||
"./SelectionFormattingTray.tsx"
|
||||
);
|
||||
|
||||
const fake = createFakeEditor();
|
||||
|
||||
// Before the fix this render threw the tiptap error out of the effect.
|
||||
let thrown;
|
||||
try {
|
||||
await act(async () => {
|
||||
render(
|
||||
React.createElement(SelectionFormattingTray, { editor: fake.editor }),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
assert.equal(
|
||||
thrown,
|
||||
undefined,
|
||||
`mounting with an unmounted view must not throw: ${thrown?.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("tray subscribes to editor events once the view becomes available", async () => {
|
||||
const React = await import("react");
|
||||
const { act, render } = await import("@testing-library/react");
|
||||
const { SelectionFormattingTray } = await import(
|
||||
"./SelectionFormattingTray.tsx"
|
||||
);
|
||||
|
||||
const fake = createFakeEditor();
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
React.createElement(SelectionFormattingTray, { editor: fake.editor }),
|
||||
);
|
||||
});
|
||||
|
||||
// The view was absent on mount, so no subscription can exist yet.
|
||||
assert.equal(fake.listenerCount("selectionUpdate"), 0);
|
||||
|
||||
// The ProseMirror view mounts on a later frame; the retry must pick it up.
|
||||
fake.mount();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => dom.window.requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => dom.window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fake.listenerCount("selectionUpdate"),
|
||||
1,
|
||||
"tray must attach its selection listener once the view exists",
|
||||
);
|
||||
});
|
||||
|
||||
test("tray detaches its editor listeners on unmount", async () => {
|
||||
const React = await import("react");
|
||||
const { act, render } = await import("@testing-library/react");
|
||||
const { SelectionFormattingTray } = await import(
|
||||
"./SelectionFormattingTray.tsx"
|
||||
);
|
||||
|
||||
const fake = createFakeEditor();
|
||||
fake.mount();
|
||||
|
||||
let unmount;
|
||||
await act(async () => {
|
||||
({ unmount } = render(
|
||||
React.createElement(SelectionFormattingTray, { editor: fake.editor }),
|
||||
));
|
||||
});
|
||||
|
||||
assert.equal(fake.listenerCount("selectionUpdate"), 1);
|
||||
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fake.listenerCount("selectionUpdate"),
|
||||
0,
|
||||
"unmount must remove every editor subscription",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user