diff --git a/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx
new file mode 100644
index 0000000000..ec5991464c
--- /dev/null
+++ b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx
@@ -0,0 +1,134 @@
+import "@blocknote/core/style.css";
+
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { TextSelection } from "@tiptap/pm/state";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { createReactInlineContentSpec } from "./ReactInlineContentSpec.js";
+
+const tag = createReactInlineContentSpec(
+ { type: "tag", propSchema: {}, content: "styled" },
+ {
+ render: ({ contentRef }) => (
+
+ #
+
+
+ ),
+ },
+);
+const schema = BlockNoteSchema.create().extend({
+ inlineContentSpecs: { tag },
+});
+
+it.each([
+ { name: "initial content", initial: true, text: "seed" },
+ { name: "an inserted empty tag", initial: false, text: "" },
+ { name: "an inserted tag with text", initial: false, text: "seed" },
+])(
+ "keeps $name connected to the document (#2142)",
+ async ({ initial, text }) => {
+ const editor = BlockNoteEditor.create({
+ schema,
+ trailingBlock: false,
+ initialContent: [
+ {
+ id: "paragraph",
+ type: "paragraph",
+ content: initial ? [{ type: "tag", content: text }] : [],
+ },
+ ],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+
+ try {
+ flushSync(() => {
+ root.render(
+ ,
+ );
+ });
+
+ if (!initial) {
+ editor.insertInlineContent([{ type: "tag", content: text }]);
+ }
+
+ const editable = await vi.waitFor(() => {
+ const element = host.querySelector(
+ '[data-test="tag-content"]',
+ );
+ expect(element).not.toBeNull();
+ expect(element?.textContent).toBe(text);
+ return element!;
+ });
+
+ editor.transact((tr) => {
+ tr.doc.descendants((node, pos) => {
+ if (node.type.name === "tag") {
+ tr.setSelection(
+ TextSelection.create(tr.doc, pos + 1 + text.length),
+ );
+ return false;
+ }
+ return undefined;
+ });
+ });
+ editor.focus();
+
+ if (text === "") {
+ // Match the report: click the visible empty input before typing into it.
+ await userEvent.click(editable);
+ }
+
+ const selection = window.getSelection();
+ const inputContext = JSON.stringify({
+ parent: editor.prosemirrorState.selection.$from.parent.type.name,
+ anchor: selection?.anchorNode?.parentElement?.outerHTML,
+ anchorOffset: selection?.anchorOffset,
+ content: editable.outerHTML,
+ rect: editable.getBoundingClientRect().toJSON(),
+ });
+
+ // Both keyboard input and native edits must reach ProseMirror through this
+ // node view's content DOM, so editor.document matches the visible text.
+ if (text === "") {
+ await userEvent.keyboard("added");
+ } else {
+ document.execCommand("insertText", false, "added");
+ }
+
+ await vi.waitFor(() => {
+ expect(
+ host.querySelector('[data-test="tag-content"]')?.textContent,
+ `${inputContext}; after input: ${host.innerHTML}; document: ${JSON.stringify(editor.document)}`,
+ ).toBe(`${text}added`);
+ expect(editor.document[0].content).toEqual([
+ {
+ type: "tag",
+ props: {},
+ content: [{ type: "text", text: `${text}added`, styles: {} }],
+ },
+ ]);
+ });
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
diff --git a/packages/react/src/schema/ReactInlineContentSpec.test.tsx b/packages/react/src/schema/ReactInlineContentSpec.test.tsx
new file mode 100644
index 0000000000..4ca5568b6e
--- /dev/null
+++ b/packages/react/src/schema/ReactInlineContentSpec.test.tsx
@@ -0,0 +1,64 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { createReactInlineContentSpec } from "./ReactInlineContentSpec.js";
+
+function createSchema(content: "styled" | "plain") {
+ const config =
+ content === "styled"
+ ? ({ type: "tag", propSchema: {}, content: "styled" } as const)
+ : ({ type: "tag", propSchema: {}, content: "plain" } as const);
+ const tag = createReactInlineContentSpec(config, {
+ render: ({ contentRef }) => (
+
+ #
+
+ ),
+ });
+ return BlockNoteSchema.create().extend({
+ inlineContentSpecs: { tag },
+ });
+}
+
+it.each(["styled", "plain"] as const)(
+ "renders inserted %s inline content inside its contentRef element (#2142)",
+ async (content) => {
+ const editor = BlockNoteEditor.create({
+ schema: createSchema(content),
+ trailingBlock: false,
+ initialContent: [{ id: "paragraph", type: "paragraph" }],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+
+ try {
+ flushSync(() => {
+ root.render(
+ ,
+ );
+ });
+ editor.insertInlineContent([{ type: "tag", content: "seed" }]);
+
+ await vi.waitFor(() => {
+ expect(
+ host.querySelector('[data-test="tag-content"]')?.textContent,
+ host.innerHTML,
+ ).toBe("seed");
+ });
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx
index 863d5421a3..ef91283ef5 100644
--- a/packages/react/src/schema/ReactInlineContentSpec.tsx
+++ b/packages/react/src/schema/ReactInlineContentSpec.tsx
@@ -226,6 +226,10 @@ export function createReactInlineContentSpec<
contentRef={(element) => {
ref(element);
if (element) {
+ // The initial React ref runs before TipTap creates the
+ // content DOM. Mark its target so the node view can attach
+ // the content there once its constructor resumes.
+ element.dataset.nodeViewContent = "";
element.dataset.editable = "";
}
}}