Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
114 changes: 114 additions & 0 deletions packages/react/src/schema/ReactStyleSpec.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@blocknote/core";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { TextSelection } from "@tiptap/pm/state";
import { expect, it } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";

Expand All @@ -19,6 +20,119 @@ const schema = BlockNoteSchema.create({
styleSpecs: { ...defaultStyleSpecs, highlight },
});

it.each([
"cursor",
"forward selection",
"backward selection",
"mixed selection",
] as const)(
"preserves the native selection when a React style mounts (%s)",
async (selectionMode) => {
const editor = BlockNoteEditor.create({
schema,
initialContent: [
{
type: "paragraph",
content:
selectionMode === "mixed selection"
? [
{ type: "text", text: "al", styles: {} },
{ type: "text", text: "p", styles: { bold: true } },
{ type: "text", text: "ha", styles: {} },
]
: "alpha",
},
],
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
try {
flushSync(() => root.render(<BlockNoteViewRaw editor={editor} />));
const view = editor.prosemirrorView;
const [anchor, head] =
selectionMode === "cursor"
? [5, 5]
: selectionMode !== "backward selection"
? [4, 7]
: [7, 4];
view.dispatch(
view.state.tr.setSelection(
TextSelection.create(view.state.doc, anchor, head),
),
);
view.focus();
expect(document.getSelection()?.toString()).toBe(
selectionMode === "cursor" ? "" : "lph",
);
editor.addStyles({ highlight: true });

// Mounting a mark must preserve both endpoints, including a backwards
// selection, rather than merely retaining the selected text.
await expect
.poll(() => {
const selection = document.getSelection();
if (!selection?.anchorNode || !selection.focusNode) {
return undefined;
}
return [
view.posAtDOM(selection.anchorNode, selection.anchorOffset),
view.posAtDOM(selection.focusNode, selection.focusOffset),
];
})
.toEqual([anchor, head]);

// The first character creates a mark view for a collapsed selection.
// React may commit it after ProseMirror has already placed the caret.
await userEvent.keyboard("X");
await userEvent.keyboard("Y");

expect(editor.prosemirrorState.doc.textContent).toBe(
selectionMode === "cursor" ? "alXYpha" : "aXYa",
);
expect(editor.getActiveStyles()).toMatchObject({ highlight: true });
} finally {
root.unmount();
editor._tiptapEditor.destroy();
host.remove();
}
},
);

it("does not refocus the editor after a React style mounts", async () => {
const editor = BlockNoteEditor.create({
schema,
initialContent: [{ type: "paragraph", content: "alpha" }],
});
const host = document.createElement("div");
const input = document.createElement("input");
document.body.append(host, input);
const root = createRoot(host);
try {
flushSync(() => root.render(<BlockNoteViewRaw editor={editor} />));
editor.prosemirrorView.focus();
flushSync(() => {
editor.updateBlock(editor.document[0], {
content: [
{ type: "text", text: "styled", styles: { highlight: true } },
],
});
});
// Focus can change between React's ref callback and reconciliation.
input.focus();
await userEvent.keyboard("X");

expect(document.activeElement).toBe(input);
expect(input.value).toBe("X");
expect(editor.prosemirrorState.doc.textContent).toBe("styled");
} finally {
root.unmount();
editor._tiptapEditor.destroy();
host.remove();
input.remove();
}
});

it.each(["initial mount", "remount", "API remount"])(
"keeps typing after text inserted with a React style (%s)",
async (mountMode) => {
Expand Down
72 changes: 37 additions & 35 deletions packages/react/src/schema/ReactStyleSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ReactMarkViewContext,
ReactMarkViewRenderer,
} from "@tiptap/react";
import { FC, useContext } from "react";
import { FC, useCallback, useContext } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";

// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
Expand Down Expand Up @@ -88,44 +88,46 @@ export function createReactStyleSpec<T extends StyleConfig>(
addMarkView() {
const editor: BlockNoteEditor<any, any, any> = this.options.editor;

return (markViewProps) => {
const renderResult = ReactMarkViewRenderer((props) => {
const ref = useContext(ReactMarkViewContext).markViewContentRef;
return ReactMarkViewRenderer((props) => {
const ref = useContext(ReactMarkViewContext).markViewContentRef;

if (!ref) {
throw new Error("markViewContentRef is not set");
}
if (!ref) {
throw new Error("markViewContentRef is not set");
}

const Content = styleImplementation.render;
return (
<Content
editor={editor}
contentRef={(element) => {
ref(element);
if (element) {
element.dataset.markViewContent = "";
const view = props.view;
const contentRef = useCallback(
(element: HTMLElement | null) => {
ref(element);
if (element) {
element.dataset.markViewContent = "";
// React can attach this content after ProseMirror maps selection.
// EditorView.focus() resyncs it from the current editor state.
// Wait until the commit/transaction finishes, and only reconcile
// an already-focused editor.
queueMicrotask(() => {
if (!view.isDestroyed && view.hasFocus()) {
view.focus();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}}
value={
styleConfig.propSchema === "boolean"
? undefined
: props.mark.attrs.stringValue
}
/>
);
})(markViewProps);

/**
* See https://github.com/ueberdosis/tiptap/blob/5ba480bcd97a0e92f889019f1a65fc67fd3506f9/packages/react/src/ReactMarkViewRenderer.tsx#L98-L103
* If the `contentRef` is not set synchronously, the contentDOM will be null
* This is a hack to make sure that it is always defined so that Prosemirror can insert the content into the correct position
*
* (This is only an issue with Firefox)
*/
(renderResult as any).didMountContentDomElement = true;
});
}
},
[ref, view],
);

return renderResult;
};
const Content = styleImplementation.render;
return (
<Content
editor={editor}
contentRef={contentRef}
value={
styleConfig.propSchema === "boolean"
? undefined
: props.mark.attrs.stringValue
}
/>
);
});
},
});

Expand Down
40 changes: 28 additions & 12 deletions tests/src/unit/react/staleNodeViewPos.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useLayoutEffect } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import * as editorContent from "../../../../packages/react/src/editor/EditorContent.js";

/**
* Regression tests for #2937 (and the same family: #2682, #2621).
Expand Down Expand Up @@ -111,6 +112,21 @@ function paragraphs(count: number, prefix: string) {
}

function mountEditor(initialContent: any[]): ReproEditor {
// Reproduce the synchronous node-view mounting used by older TipTap releases,
// independently of whether ReactRenderer itself still calls flushSync.
// Flush at BlockNote's portal registration boundary, while ProseMirror is
// still reconciling, rather than after the command has already completed.
const getContentComponent = editorContent.getContentComponent;
vi.spyOn(editorContent, "getContentComponent").mockImplementation(() => {
const contentComponent = getContentComponent();
return {
...contentComponent,
setRenderer(...args) {
flushSync(() => contentComponent.setRenderer(...args));
},
};
});

div = document.createElement("div");
document.body.appendChild(div);

Expand All @@ -131,16 +147,10 @@ function mountEditor(initialContent: any[]): ReproEditor {
root!.render(<BlockNoteViewRaw editor={editor as any} />);
});

// TipTap's `ReactRenderer` only renders a node view synchronously (via
// `flushSync`) when `isEditorContentInitialized` is set; otherwise it defers
// to a microtask. TipTap's own `PureEditorContent` sets it, but BlockNote
// replaces that component with its own mounting (`BlockNoteView.tsx`) and
// never does, so BlockNote currently gets the deferred path by accident.
// Set it here so node views mount the way they do for every other TipTap
// React user — and the way they do for anyone resolving `@tiptap/react`
// below 3.22, where this flag was still the always-true `isInitialized`.
// That is the configuration #2937 was reported against.
(editor as any)._tiptapEditor.isEditorContentInitialized = true;
// Avoid ReactRenderer's initial microtask deferral (introduced in TipTap
// 3.22). This flag no longer guarantees a synchronous React commit in newer
// releases; the portal registration wrapper above supplies that separately.
Object.assign(editor._tiptapEditor, { isEditorContentInitialized: true });

return editor;
}
Expand Down Expand Up @@ -184,12 +194,18 @@ describe("stale node view getPos (#2937)", () => {
{ type: "repro", content: "existing repro block" },
...paragraphs(10, "tail"),
]);
let mounted = false;
onMount = (_id, text) => {
mounted ||= text === MARKER;
};
const errors = runCapturingErrors(() => {
ed.replaceBlocks(ed.document.slice(0, 28), [
{ type: "repro", content: MARKER } as any,
]);
});

// Even the non-reentrant control must actually mount during the command.
expect(mounted).toBe(true);
expect(errors).toEqual([]);
// The straightforward case must not go anywhere near the fallback.
expect(renderedFromFallbackCount).toBe(0);
Expand All @@ -207,8 +223,8 @@ describe("stale node view getPos (#2937)", () => {
return;
}
fired = true;
// Runs inside React's commit phase, which is inside the `flushSync` in
// TipTap's `ReactRenderer` constructor, which is inside ProseMirror's
// Runs inside React's commit phase, which is inside the test's
// synchronous portal registration, which is inside ProseMirror's
// `updateChildren`. Node views built or rendered from here see a
// `getPos()` computed against a half-updated view-desc tree.
ed.removeBlocks(ed.document.slice(-8));
Expand Down
Loading