From 22a62714232bc20c5522453ff59b56fd8a1980ff Mon Sep 17 00:00:00 2001
From: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Date: Wed, 14 Jan 2026 10:56:31 +0500
Subject: [PATCH] editor: fix duplicate block-id on splitting node (#9198)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
---
.../block-id/__tests__/block-id.test.ts | 46 +++++++++++++++++++
.../src/extensions/block-id/block-id.ts | 33 +++++++++----
.../editor/src/extensions/heading/heading.ts | 5 +-
.../heading => utils}/batch-attribute-step.ts | 0
4 files changed, 73 insertions(+), 11 deletions(-)
create mode 100644 packages/editor/src/extensions/block-id/__tests__/block-id.test.ts
rename packages/editor/src/{extensions/heading => utils}/batch-attribute-step.ts (100%)
diff --git a/packages/editor/src/extensions/block-id/__tests__/block-id.test.ts b/packages/editor/src/extensions/block-id/__tests__/block-id.test.ts
new file mode 100644
index 000000000..6d2a0898a
--- /dev/null
+++ b/packages/editor/src/extensions/block-id/__tests__/block-id.test.ts
@@ -0,0 +1,46 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { test, expect } from "vitest";
+import { createEditor, h } from "../../../../test-utils";
+import { Heading } from "../../heading";
+import { BlockId } from "../block-id";
+
+test("splitting a node with blockId should generate new blockId for the new node", async () => {
+ const el = h("div", [
+ h("h1", ["A heading one"], { "data-block-id": "blockid" })
+ ]);
+ const { editor } = createEditor({
+ extensions: {
+ heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
+ blockId: BlockId
+ },
+ initialContent: el.outerHTML
+ });
+
+ editor.commands.setTextSelection(9);
+ const event = new KeyboardEvent("keydown", { key: "Enter" });
+ editor.view.dom.dispatchEvent(event);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const headings = editor.getJSON().content;
+ expect(headings?.[0].attrs?.blockId).toBe("blockid");
+ expect(headings?.[1].attrs?.blockId).not.toBeUndefined();
+ expect(headings?.[1].attrs?.blockId).not.toBe("blockid");
+});
diff --git a/packages/editor/src/extensions/block-id/block-id.ts b/packages/editor/src/extensions/block-id/block-id.ts
index ca16d7696..e3bc861f5 100644
--- a/packages/editor/src/extensions/block-id/block-id.ts
+++ b/packages/editor/src/extensions/block-id/block-id.ts
@@ -20,6 +20,10 @@ along with this program. If not, see .
import { Extension } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { nanoid } from "nanoid";
+import {
+ AttributeUpdate,
+ BatchAttributeStep
+} from "../../utils/batch-attribute-step.js";
const NESTED_BLOCK_ID_TYPES = ["callout"];
const BLOCK_ID_TYPES = [
@@ -74,24 +78,33 @@ export const BlockId = Extension.create({
const isDocChanged = transactions.some((tr) => tr.docChanged);
if (!isDocChanged) return null;
- let updated = false;
+ const blockIds = new Set();
+ const updates: AttributeUpdate[] = [];
const { tr } = newState;
+
tr.doc.forEach(function addBlockId(n, offset) {
if (!n.isBlock || !BLOCK_ID_TYPES.includes(n.type.name)) return;
- if (!n.attrs.blockId) {
- const id = nanoid(8);
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore we can't use tr.setNodeMarkup as that creates
- // a new transaction for every single node update causing
- // significant performance issues
- n.attrs.blockId = id;
- updated = true;
+
+ const currentId = n.attrs.blockId;
+ const shouldUpdateId = !currentId || blockIds.has(currentId);
+ const finalId = shouldUpdateId ? nanoid(8) : currentId;
+
+ if (shouldUpdateId) {
+ updates.push({
+ pos: offset,
+ attrName: "blockId",
+ value: finalId
+ });
}
+ blockIds.add(finalId);
+
if (NESTED_BLOCK_ID_TYPES.includes(n.type.name))
n.forEach((n, pos) => addBlockId(n, offset + pos + 1));
});
- if (updated) {
+
+ if (updates.length > 0) {
+ tr.step(new BatchAttributeStep(updates));
tr.setMeta("ignoreEdit", true);
return tr;
}
diff --git a/packages/editor/src/extensions/heading/heading.ts b/packages/editor/src/extensions/heading/heading.ts
index a16efc0ae..7ad39e7f0 100644
--- a/packages/editor/src/extensions/heading/heading.ts
+++ b/packages/editor/src/extensions/heading/heading.ts
@@ -30,7 +30,10 @@ import {
changedDescendants,
findParentNodeOfTypeClosestToPos
} from "../../utils/prosemirror.js";
-import { AttributeUpdate, BatchAttributeStep } from "./batch-attribute-step.js";
+import {
+ AttributeUpdate,
+ BatchAttributeStep
+} from "../../utils/batch-attribute-step.js";
const COLLAPSIBLE_BLOCK_TYPES = [
"paragraph",
diff --git a/packages/editor/src/extensions/heading/batch-attribute-step.ts b/packages/editor/src/utils/batch-attribute-step.ts
similarity index 100%
rename from packages/editor/src/extensions/heading/batch-attribute-step.ts
rename to packages/editor/src/utils/batch-attribute-step.ts