From b93ad0e35667d45981b13487cc0a6ea7b69e6ee2 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:18 +0200 Subject: [PATCH 01/13] fix(core): resolve suggested-deletion ids against the pre-removal doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeAndInsertBlocks re-resolved ids via getNodeId(node, tr.doc) while deleting, but a suggested-deletion copy's id is positional — deleting the live block shifted the copy's index mid-walk, so ids captured from editor.document no longer matched ("Blocks with the following IDs could not be found: middle-1"). This silently broke re-entering a versioning diff preview whose rendered doc contained a moved block (the gallery's Diff pane stopped updating). Resolve against the doc the ids came from instead. The versioning e2e now re-enters the preview after a follow-up edit for every scenario — the exact operation that failed. --- .../replaceBlocks/replaceBlocks.test.ts | 63 +++++++++++++++++++ .../commands/replaceBlocks/replaceBlocks.ts | 15 ++++- .../y-prosemirror/versioning.test.tsx | 33 ++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts index 5a968c49bf..aa4b4224d1 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts @@ -1,3 +1,5 @@ +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; import { describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../../setupTestEnv.js"; @@ -5,6 +7,8 @@ import { removeAndInsertBlocks } from "./replaceBlocks.js"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { BlockIdentifier } from "../../../../schema/index.js"; +import { docToBlocks } from "../../../nodeConversions/nodeToBlock.js"; +import { YAttributionMarksExtension } from "../../../../y/extensions/YAttributionMarks.js"; const getEditor = setupTestEnv(); @@ -233,3 +237,62 @@ describe("Test replaceBlocks", () => { expect(getEditor().document).toMatchSnapshot(); }); }); + +/** + * Builds a `blockContainer` holding a single paragraph with the given block + * `id`. When `suggestedDelete` is true, the container carries a + * `y-attributed-delete` mark, simulating a node that a suggestion / version + * diff keeps in the document after it has been deleted — it shares its `id` + * with the live node it was deleted from. + */ +function makeBlockContainer( + schema: Schema, + id: string, + text: string, + suggestedDelete: boolean, +) { + const paragraph = schema.nodes["paragraph"].createChecked( + {}, + schema.text(text), + ); + const marks = suggestedDelete + ? [schema.marks["y-attributed-delete"].create({ id: 1 })] + : undefined; + + return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); +} + +describe("removeAndInsertBlocks with suggested deletions", () => { + // A rendered diff (e.g. of a moved block) shows the same block `id` twice: + // the live node and a suggested-deletion copy, which `docToBlocks` reports + // under a disambiguated id ("0-1" = the second node with id "0"). Removing + // the blocks `editor.document` reports must resolve that id even though, by + // the time the walk reaches the deleted copy, the live node it was counted + // against is already gone from the transaction's doc. + it("removes a live block and its suggested deletion by the ids docToBlocks reports", () => { + const editor = BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); + const schema = editor.pmSchema; + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + makeBlockContainer(schema, "0", "Live", false), + makeBlockContainer(schema, "1", "Other", false), + makeBlockContainer(schema, "0", "Deleted", true), + ]), + ); + const blocks = docToBlocks(doc); + expect(blocks.map((block) => block.id)).toEqual(["0", "1", "0-1"]); + + const tr = EditorState.create({ doc }).tr; + const { removedBlocks } = removeAndInsertBlocks( + tr, + blocks.filter((block) => block.id !== "1"), + [], + ); + + expect(removedBlocks.map((block) => block.id)).toEqual(["0", "0-1"]); + expect(docToBlocks(tr.doc).map((block) => block.id)).toEqual(["1"]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..5cb4663bd5 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -51,7 +51,16 @@ export function removeAndInsertBlocks< : blocksToRemove[0].id; let removedSize = 0; - tr.doc.descendants((node, pos) => { + // The IDs to remove were derived from the document as it is *now* (e.g. via + // `editor.document`), so resolve them against that same document rather than + // `tr.doc`, which mutates as blocks get deleted below. This matters for + // suggested-deletion nodes: `getNodeId` disambiguates them from the live node + // sharing their `id` by their index among same-id nodes, so once the live + // node has been deleted from `tr.doc` the index — and thus the ID — would no + // longer match. + const doc = tr.doc; + + doc.descendants((node, pos) => { // Skips traversing nodes after all target blocks have been removed. if (idsOfBlocksToRemove.size === 0) { return false; @@ -62,14 +71,14 @@ export function removeAndInsertBlocks< return true; } - const nodeId = getNodeId(node, tr.doc); + const nodeId = getNodeId(node, doc); if (!idsOfBlocksToRemove.has(nodeId)) { return true; } // Saves the block that is being deleted. - removedBlocks.push(nodeToBlock(node, tr.doc)); + removedBlocks.push(nodeToBlock(node, doc)); idsOfBlocksToRemove.delete(nodeId); if (blocksToInsert.length > 0 && nodeId === idOfFirstBlock) { diff --git a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx index d5f832a643..c525d100f8 100644 --- a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx @@ -104,6 +104,7 @@ for (const scenario of scenarios) { const afterDoc = cloneWithId(beforeDoc, 2); teardown.push(() => afterDoc.destroy()); + const userEditors: { editor: GalleryEditor; doc: Y.Doc }[] = []; for (let i = 0; i < applies.length; i++) { const userDoc = cloneWithId(beforeDoc, 3 + i); const { editor, teardown: unmount } = mountEditor(userDoc); @@ -111,6 +112,7 @@ for (const scenario of scenarios) { unmount(); userDoc.destroy(); }); + userEditors.push({ editor, doc: userDoc }); applies[i](editor); // Wait for the y-prosemirror binding to flush the change into `userDoc`. @@ -134,6 +136,37 @@ for (const scenario of scenarios) { // Reached only when enterPreview didn't throw: the diff is now showing. expect(diffEditor.prosemirrorState.doc.childCount).toBeGreaterThan(0); + + // Keep editing after the diff is showing — the gallery re-renders the + // Diff on every Version 2 edit, and the versioning UI re-enters preview + // whenever another version is selected. Re-entering preview must replace + // the diff that is already rendered, including the one rendered for a + // moved block, which shows the same block id twice (the deleted copy and + // the inserted one). + const lastUser = userEditors[userEditors.length - 1]; + const editedStateBefore = Y.encodeStateAsUpdateV2(lastUser.doc); + const lastBlock = lastUser.editor.document.at(-1)!; + lastUser.editor.insertBlocks( + [{ type: "paragraph", content: "follow-up edit" }], + lastBlock, + "after", + ); + await expect + .poll( + () => + !bytesEqual( + Y.encodeStateAsUpdateV2(lastUser.doc), + editedStateBefore, + ), + ) + .toBe(true); + Y.applyUpdate(afterDoc, Y.encodeStateAsUpdate(lastUser.doc)); + + adapter.preview.enterPreview(Y.encodeStateAsUpdateV2(afterDoc), before); + + expect(diffEditor.prosemirrorState.doc.textContent).toContain( + "follow-up edit", + ); } finally { teardown.reverse().forEach((fn) => fn()); } From b71d42e031fd6a958561f55fe79c1e9e8671129a Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:21 +0200 Subject: [PATCH 02/13] fix(core): exclude suggested-deletion copies from change tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted copies rendered by suggestion / version-diff mode duplicate a live block's id and are only disambiguated positionally, so diffing them across the before/after docs misreported unchanged copies as delete+insert pairs whenever their position shifted. They are rendering artifacts, not document blocks — skip their subtrees in the snapshots. --- .../api/getBlocksChangedByTransaction.test.ts | 68 +++++++++++++++++++ .../src/api/getBlocksChangedByTransaction.ts | 12 +++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 25f112b2d2..7eda4d7110 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -1,8 +1,11 @@ +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; import { describe, expect, it, beforeEach } from "vite-plus/test"; import { setupTestEnv } from "./blockManipulation/setupTestEnv.js"; import { getBlocksChangedByTransaction } from "./getBlocksChangedByTransaction.js"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js"; const getEditor = setupTestEnv(); @@ -570,3 +573,68 @@ describe("getBlocksChangedByTransaction", () => { ); }); }); + +/** + * Builds a `blockContainer` holding a single paragraph with the given block + * `id`. When `suggestedDelete` is true, the container carries a + * `y-attributed-delete` mark, simulating a node that a suggestion / version + * diff keeps in the document after it has been deleted — it shares its `id` + * with the live node it was deleted from, and `getNodeId` disambiguates it + * positionally ("0-1" = the deletion-marked node with 1 same-id node before + * it). + */ +function makeBlockContainer( + schema: Schema, + id: string, + text: string, + suggestedDelete: boolean, +) { + const paragraph = schema.nodes["paragraph"].createChecked( + {}, + schema.text(text), + ); + const marks = suggestedDelete + ? [schema.marks["y-attributed-delete"].create({ userIds: ["A"] })] + : undefined; + return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); +} + +/** + * Regression tests: change tracking on suggestion-rendered docs. Suggested- + * deletion copies duplicate a live block's id and are only disambiguated + * *positionally* (see `getNodeId`), so diffing them across the before/after + * docs used to misreport unchanged copies as delete+insert pairs — they are + * now excluded from the snapshots as rendering artifacts. + */ +describe("getBlocksChangedByTransaction on suggestion docs", () => { + it("reports only the real change when a block before a suggested deletion is deleted", () => { + const suggestionEditor = BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); + const schema = suggestionEditor.pmSchema; + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + makeBlockContainer(schema, "0", "Live", false), + makeBlockContainer(schema, "1", "Other", false), + makeBlockContainer(schema, "0", "Deleted", true), + ]), + ); + + // Delete the live "0" block. The deleted copy is untouched — but its + // positional lying id shifts from "0-1" to "0-0", which used to make the + // before/after diff misreport it as a delete of "0-1" plus an insert of + // "0-0". + const live = doc.firstChild!.child(0); + const tr = EditorState.create({ doc }).tr.delete(1, 1 + live.nodeSize); + + const changes = getBlocksChangedByTransaction(tr).map((change) => [ + change.type, + change.block.id, + ]); + + // The only change is the deletion of the live block "0" — the deleted + // copy is a rendering artifact and never appears in change events. + expect(changes).toEqual([["delete", "0"]]); + }); +}); diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index 94b2bc1d3b..4aebba4c54 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -11,7 +11,7 @@ import { import type { BlockSchema } from "../schema/index.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; -import { getNodeId } from "./getBlockInfoFromPos.js"; +import { getNodeId, isSuggestedDeletionNode } from "./getBlockInfoFromPos.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { isNodeBlock } from "./nodeUtil.js"; @@ -165,6 +165,16 @@ function collectSnapshot< if (!isNodeBlock(node)) { return true; } + // Suggested-deletion copies are rendering artifacts of suggestion / + // version-diff mode, not blocks of the document: they duplicate a live + // block's id and are only disambiguated *positionally* (see `getNodeId`), + // so diffing them across the before/after docs misreports unchanged + // copies as delete+insert pairs whenever their position shifts. Skip the + // whole subtree — everything under a deleted copy is part of the same + // artifact. + if (isSuggestedDeletionNode(node)) { + return false; + } const parentId = getParentBlockId(doc, pos); const key = parentId ?? ROOT_KEY; if (!childrenByParent[key]) { From 29174feb94698f2e2152f692c3a9972cec30a274 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:38 +0200 Subject: [PATCH 03/13] chore(core): document the blockCache limitation with suggested deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache is keyed by node object, but a deletion-marked node's positional id can change while the node object stays identical — so cache hits can serve stale or aliased ids. Accepted for now (suggestion rendering is experimental and the fake-id scheme is slated for rework); the it.fails tests pin the behavior that rework must satisfy. --- .../api/nodeConversions/nodeToBlock.test.ts | 111 ++++++++++++++++++ .../src/api/nodeConversions/nodeToBlock.ts | 7 ++ 2 files changed, 118 insertions(+) create mode 100644 packages/core/src/api/nodeConversions/nodeToBlock.test.ts diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.test.ts b/packages/core/src/api/nodeConversions/nodeToBlock.test.ts new file mode 100644 index 0000000000..3cccf0b2f1 --- /dev/null +++ b/packages/core/src/api/nodeConversions/nodeToBlock.test.ts @@ -0,0 +1,111 @@ +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { YAttributionMarksExtension } from "../../y/extensions/YAttributionMarks.js"; +import { docToBlocks } from "./nodeToBlock.js"; + +/** + * Builds a `blockContainer` holding a single paragraph with the given block + * `id`. When `suggestedDelete` is true, the container carries a + * `y-attributed-delete` mark, simulating a node that a suggestion / version + * diff keeps in the document after it has been deleted — it shares its `id` + * with the live node it was deleted from, and `getNodeId` disambiguates it + * positionally ("0-1" = the deletion-marked node with 1 same-id node before + * it). + */ +function makeBlockContainer( + schema: Schema, + id: string, + text: string, + suggestedDelete: boolean, +) { + const paragraph = schema.nodes["paragraph"].createChecked( + {}, + schema.text(text), + ); + const marks = suggestedDelete + ? [schema.marks["y-attributed-delete"].create({ userIds: ["A"] })] + : undefined; + return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks); +} + +function createSuggestionEditor() { + return BlockNoteEditor.create({ + extensions: [YAttributionMarksExtension()], + }); +} + +/** + * KNOWN LIMITATION (`it.fails`): the editor's `blockCache` (a + * `WeakMap`) is keyed by node object, which ProseMirror reuses + * across doc versions — but a deletion-marked node's disambiguated id depends + * on the *doc context* (how many same-id nodes precede it), which the cache + * key cannot see, so it can serve stale (or aliased) ids. Accepted for now: + * suggestion rendering is experimental and its fake-id scheme is slated for + * rework — these tests are the contract that rework must satisfy (each + * asserts the CORRECT behavior and currently fails; remove `.fails` then). + */ +describe("blockCache with suggested deletions", () => { + it.fails("reports the node's current lying id after a preceding same-id block is deleted", () => { + const editor = createSuggestionEditor(); + const schema = editor.pmSchema; + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + makeBlockContainer(schema, "0", "Live", false), + makeBlockContainer(schema, "1", "Other", false), + makeBlockContainer(schema, "0", "Deleted", true), + ]), + ); + + // First read populates the editor's blockCache (keyed by node object). + expect(docToBlocks(doc).map((b) => b.id)).toEqual(["0", "1", "0-1"]); + + // Delete the live "0" block via a real transaction — ProseMirror reuses + // the untouched sibling node objects in the new doc. + const live = doc.firstChild!.child(0); + const tr = EditorState.create({ doc }).tr.delete(1, 1 + live.nodeSize); + + // Sanity: the deleted-copy node object really is reused, so the second + // read is a cache hit. + expect(tr.doc.firstChild!.child(1)).toBe(doc.firstChild!.child(2)); + + // The deleted copy now has zero same-id predecessors, so its current + // lying id is "0-0" — a stale cache would still report "0-1". + expect(docToBlocks(tr.doc).map((b) => b.id)).toEqual(["1", "0-0"]); + }); + + it.fails("keeps block ids unique when another same-id deleted copy is inserted", () => { + const editor = createSuggestionEditor(); + const schema = editor.pmSchema; + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + makeBlockContainer(schema, "0", "Live", false), + makeBlockContainer(schema, "1", "Other", false), + makeBlockContainer(schema, "0", "Deleted", true), + ]), + ); + + // First read populates the blockCache: the old deleted copy is cached + // with id "0-1". + expect(docToBlocks(doc).map((b) => b.id)).toEqual(["0", "1", "0-1"]); + + // Insert ANOTHER deleted copy of "0" right after the live block. Its + // fresh id is "0-1" (one same-id node before it), which bumps the OLD + // deleted copy to "0-2". + const live = doc.firstChild!.child(0); + const tr = EditorState.create({ doc }).tr.insert( + 1 + live.nodeSize, + makeBlockContainer(schema, "0", "Deleted again", true), + ); + + // Correct ids: ["0", "0-1", "1", "0-2"] — a stale cache would report the + // old copy as "0-1" too, aliasing two different blocks to the same id. + const ids = docToBlocks(tr.doc).map((b) => b.id); + expect(ids).toEqual(["0", "0-1", "1", "0-2"]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index fead006657..ea0bbe265f 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -22,6 +22,7 @@ import { getBlockInfoWithManualOffset, getNodeId, } from "../getBlockInfoFromPos.js"; + import { getBlockCache, getBlockSchema, @@ -405,6 +406,12 @@ export function nodeToBlock< throw Error("Node should be a bnBlock, but is instead: " + node.type.name); } + // NOTE: the cache (keyed by node object) is not safe for suggested-deletion + // blocks: their ids are positional (see `getNodeId`), so an unchanged node + // object can change id when the doc around it changes — the cache would then + // serve a stale (or aliased) id. Accepted for now: suggestion rendering is + // experimental and its fake-id scheme is slated for rework. See the + // `it.fails` tests in nodeToBlock.test.ts. const cachedBlock = blockCache?.get(node); if (cachedBlock) { From 7b299676ad9b0b5a21541e09680b413b4e70a871 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:41 +0200 Subject: [PATCH 04/13] fix(xl-pdf-exporter): make React keys unique for styled text and blocks Styled-text runs were keyed by their text (two same-text or empty runs collided) and block fragments by block id (documents built outside the editor can leave ids empty, aliasing every sibling to the key ""). The transform builds the tree in a single pass, so positional keys are correct. Found by the new e2e console guard via React's duplicate-key error during PDF export. --- .../src/pdf/__snapshots__/example.jsx | 68 +++++++++---------- .../exampleWithHeaderAndFooter.jsx | 68 +++++++++---------- .../__snapshots__/exampleWithMultiColumn.jsx | 12 ++-- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 18 ++++- 4 files changed, 89 insertions(+), 77 deletions(-) diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index 723124df6e..c53011fb31 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -11,7 +11,7 @@ paddingTop: 35 }} > - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + {styledText.text} ); @@ -183,7 +190,12 @@ export class PDFExporter< const style = this.blocknoteDefaultPropsToReactPDFStyle(b.props as any); ret.push( - + // Keyed by position, not `b.id`: the transform builds the tree in a + // single pass (never re-rendered), so keys only need to be unique + // among siblings — and ids can't be trusted to be (documents built + // outside the editor may leave ids empty, which made every sibling + // share the key "" and trip React's duplicate-key error). + {children} From 042f6fd565990e7cfe4022db53e037830a22496e Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:43 +0200 Subject: [PATCH 05/13] chore: upgrade @y/prosemirror to 2.0.0-7 2.0.0-7 ships the onInternalError debugging hook natively (upstreamed via yjs/y-prosemirror#273), so the patch no longer needs to modify the sync catch. The 2.0.0-7 patch carries: threading onInternalError through syncPlugin (upstream only wires it on YSyncRdt), the sync-utils/index export-list and pauseSync type fixes, and the ported drop-invalid-nodes hunks (yjs/y-prosemirror#258) that 2.0.0-7 does not include upstream. --- .../10-suggestion-multi-editor/package.json | 2 +- .../13-versioning-yjs14/package.json | 2 +- .../08-extensions/02-versioning/package.json | 2 +- packages/core/package.json | 2 +- ...-6.patch => @y__prosemirror@2.0.0-7.patch} | 74 ++++++++++--------- pnpm-lock.yaml | 30 ++++---- pnpm-workspace.yaml | 4 +- 7 files changed, 60 insertions(+), 56 deletions(-) rename patches/{@y__prosemirror@2.0.0-6.patch => @y__prosemirror@2.0.0-7.patch} (66%) diff --git a/examples/07-collaboration/10-suggestion-multi-editor/package.json b/examples/07-collaboration/10-suggestion-multi-editor/package.json index 683ec57c6d..aa4ab822d3 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/package.json +++ b/examples/07-collaboration/10-suggestion-multi-editor/package.json @@ -22,7 +22,7 @@ "react-dom": "^19.2.3", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-7", "@y/websocket": "^4.0.0-rc.2" }, "devDependencies": { diff --git a/examples/07-collaboration/13-versioning-yjs14/package.json b/examples/07-collaboration/13-versioning-yjs14/package.json index cce229a35b..5e421093ad 100644 --- a/examples/07-collaboration/13-versioning-yjs14/package.json +++ b/examples/07-collaboration/13-versioning-yjs14/package.json @@ -20,7 +20,7 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-7", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", "@y/y": "^14.0.0-rc.23", diff --git a/examples/08-extensions/02-versioning/package.json b/examples/08-extensions/02-versioning/package.json index 46b9bb8380..07df5c9ad6 100644 --- a/examples/08-extensions/02-versioning/package.json +++ b/examples/08-extensions/02-versioning/package.json @@ -21,7 +21,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "@y/y": "^14.0.0-rc.23", - "@y/prosemirror": "^2.0.0-6" + "@y/prosemirror": "^2.0.0-7" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/packages/core/package.json b/packages/core/package.json index eb2700636d..3258d74547 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -127,7 +127,7 @@ "yjs": "^13.6.27" }, "peerDependencies": { - "@y/prosemirror": "^2.0.0-6", + "@y/prosemirror": "^2.0.0-7", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.23", "y-prosemirror": "^1.3.7", diff --git a/patches/@y__prosemirror@2.0.0-6.patch b/patches/@y__prosemirror@2.0.0-7.patch similarity index 66% rename from patches/@y__prosemirror@2.0.0-6.patch rename to patches/@y__prosemirror@2.0.0-7.patch index b1685b7218..9ff85a9c53 100644 --- a/patches/@y__prosemirror@2.0.0-6.patch +++ b/patches/@y__prosemirror@2.0.0-7.patch @@ -11,30 +11,18 @@ diff --git a/dist/demo/schema.d.ts.map b/dist/demo/schema.d.ts.map deleted file mode 100644 index f7879c19424714d1c0314eadd81aee0d3047f84d..0000000000000000000000000000000000000000 diff --git a/dist/src/commands.d.ts b/dist/src/commands.d.ts -index 62f626eb65c508c8e7adcf43d2018ff1d1bf6efd..667ddbb86d379f6229f8c1e2f4645ccbd2b6397a 100644 +index 62f626eb65c508c8e7adcf43d2018ff1d1bf6efd..cdbe62ceacb97998d11fbc87785a74e485f67916 100644 --- a/dist/src/commands.d.ts +++ b/dist/src/commands.d.ts -@@ -1,10 +1,4 @@ --/** -- * Switch to pause mode (stop synchronization between prosemirror and ytype) -- * @param {import('prosemirror-state').EditorState} state -- * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch -- * @returns {boolean} -- */ +@@ -4,7 +4,7 @@ + * @param {((tr: import('prosemirror-state').Transaction) => void)?} dispatch + * @returns {boolean} + */ -export function pauseSync(state: import("prosemirror-state").EditorState, dispatch: ((tr: import("prosemirror-state").Transaction) => void) | null): boolean; +export function pauseSync(state: import("prosemirror-state").EditorState, dispatch?: (tr: import("prosemirror-state").Transaction) => void, view?: import("prosemirror-view").EditorView): boolean; export function configureYProsemirror(opts?: { ytype?: Y.Type | null | undefined; renderer?: Y.AbstractRenderer | null | undefined; -diff --git a/dist/src/commands.d.ts.map b/dist/src/commands.d.ts.map -index b3d7c4d9867667dd121690a6879d9886eb31db77..8e7d9a04e4988d661a0cc8a083761d2a8a16cf47 100644 ---- a/dist/src/commands.d.ts.map -+++ b/dist/src/commands.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,iCAJW,OAAO,mBAAmB,EAAE,WAAW,YACvC,CAAC,CAAC,EAAE,EAAE,OAAO,mBAAmB,EAAE,WAAW,KAAK,IAAI,CAAC,OAAC,GACtD,OAAO,CAanB;AAkBM,6CAJJ;IAAsB,KAAK;IACF,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBAjJkB,MAAM"} -\ No newline at end of file -+{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":";AAqCO,6CAJJ;IAAsB,KAAK;IACF,QAAQ;CACjC,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAa/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBA/IkB,MAAM"} -\ No newline at end of file diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts index d4c634df59f3695525e1be3b1fc89e3598e6edca..1a26ef98745535025578d9e9369cfcffad2dd3c8 100644 --- a/dist/src/index.d.ts @@ -47,15 +35,18 @@ index d4c634df59f3695525e1be3b1fc89e3598e6edca..1a26ef98745535025578d9e9369cfcff +export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark, defaultAttributionConf, attributionMapperToConf, yattr2markname, pmToFragment, fragmentToPm, deltaAttributionToFormat, deltaToPNode, deltaToPSteps, nodeToDelta } from "./sync-utils.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file -diff --git a/dist/src/rdt/prosemirror.d.ts.map b/dist/src/rdt/prosemirror.d.ts.map -index 7656d2d019bcfc3ac8e587a54186e2510c60cb73..a99625f58988dea8f7e220cabbd0c46ac0055aa0 100644 ---- a/dist/src/rdt/prosemirror.d.ts.map -+++ b/dist/src/rdt/prosemirror.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"prosemirror.d.ts","sourceRoot":"","sources":["../../../src/rdt/prosemirror.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH;WAFmC,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI;aAAW,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI;;IAGnH;;;;;;;;;;OAUG;IACH,6EATG;QAAoD,IAAI,EAAhD,OAAO,kBAAkB,EAAE,UAAU;QACL,eAAe;QAC3B,OAAO;QACX,OAAO,EAAvB,MAAM,GAAG;QAEM,kBAAkB;KAG3C,EAgCA;IA7BC,4CAAgB;IAChB,0CAAsC;IACtC,iCAAmC;IACnC,eAXe,GAAG,CAWI;IACtB;;;;;;;QAA+B;IAI/B;;;;;;OAMG;IACH,qBAFU,MAAM,OAAC,CAEsG;IACvH;;OAEG;IACH,QAFU,gBAAgB,CAE+D;IACzF,mBAAsB;IACtB;;;;;;OAMG;IACH,mBAAsB;IAGxB;;;OAGG;IACH,0BAEC;IAED;;;;;OAKG;IACH,aAFY,gBAAgB,CAI3B;IAED;;;OAGG;IACH,cAHW,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAalB;IAED;;;;OAIG;IACH,YAFY,OAAO,CAoBlB;IAED;;;;;OAKG;IACH,aA2BC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,cAJW,gBAAgB,UAChB,GAAG,GACF,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI,CA4CzC;CAMF;6BA3S4B,iBAAiB;uBACvB,YAAY"} -\ No newline at end of file -+{"version":3,"file":"prosemirror.d.ts","sourceRoot":"","sources":["../../../src/rdt/prosemirror.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH;WAFmC,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,EAAE,GAAG,KAAK,IAAI;aAAW,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI;;IAGnH;;;;;;;;;;OAUG;IACH,6EATG;QAAoD,IAAI,EAAhD,OAAO,kBAAkB,EAAE,UAAU;QACL,eAAe;QAC3B,OAAO;QACX,OAAO,EAAvB,MAAM,GAAG;QAEM,kBAAkB;KAG3C,EAgCA;IA7BC,4CAAgB;IAChB,0CAAsC;IACtC,iCAAmC;IACnC,eAXe,GAAG,CAWI;IACtB;;;;;;;QAA+B;IAI/B;;;;;;OAMG;IACH,qBAFU,MAAM,OAAC,CAEsG;IACvH;;OAEG;IACH,QAFU,gBAAgB,CAE+D;IACzF,mBAAsB;IACtB;;;;;;OAMG;IACH,mBAAsB;IAGxB;;;OAGG;IACH,0BAEC;IAED;;;;;OAKG;IACH,aAFY,gBAAgB,CAI3B;IAED;;;OAGG;IACH,cAHW,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAalB;IAED;;;;OAIG;IACH,YAFY,OAAO,CAoBlB;IAED;;;;;OAKG;IACH,aA2BC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,cAJW,gBAAgB,UAChB,GAAG,GACF,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,GAAG,IAAI,CAsDzC;CAMF;6BArT4B,iBAAiB;uBACvB,YAAY"} -\ No newline at end of file +diff --git a/dist/src/sync-plugin.d.ts b/dist/src/sync-plugin.d.ts +index 45ec8b1bf75771a2ebaba3e7a6622666398c7c22..6fa16961f93ec1146f592d5eb25905bc5355d563 100644 +--- a/dist/src/sync-plugin.d.ts ++++ b/dist/src/sync-plugin.d.ts +@@ -33,6 +33,7 @@ export function syncPlugin(opts?: { + attributedNodes?: AttributedNodesPredicate | undefined; + customCompare?: NodeCompare | undefined; + transformers?: (($d: s.Schema) => dt.Template)[] | undefined; ++ onInternalError?: ((err: Error, errCode: number) => any) | null | undefined; + }): Plugin; + /** + * The y-prosemirror binding is a bi-directional synchronization with the provided Y.Type and the EditorView diff --git a/dist/src/sync-utils.d.ts b/dist/src/sync-utils.d.ts index 539b2a70ae5d41575fa0f03c85e95d5d0f98165d..3fbb973027104938642e3c88ae588261323ec206 100644 --- a/dist/src/sync-utils.d.ts @@ -69,15 +60,6 @@ index 539b2a70ae5d41575fa0f03c85e95d5d0f98165d..3fbb973027104938642e3c88ae588261 export function docDiffToDelta(beforeDoc: Node, afterDoc: Node): delta.Delta<{ name: string; attrs: { -diff --git a/dist/src/sync-utils.d.ts.map b/dist/src/sync-utils.d.ts.map -index 29b2331b710abffb5bdc9e070738eefde9e297c2..0530725291c7b9c26ea34cf9d696321afeb4e0d9 100644 ---- a/dist/src/sync-utils.d.ts.map -+++ b/dist/src/sync-utils.d.ts.map -@@ -1 +1 @@ --{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"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"} -\ No newline at end of file -+{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"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"} -\ No newline at end of file diff --git a/dist/tests/attributed-nodes.test.d.ts b/dist/tests/attributed-nodes.test.d.ts deleted file mode 100644 index e6935d6a014cf43be563ee160c9f74f47abec7b8..0000000000000000000000000000000000000000 @@ -240,6 +222,28 @@ index cfc76909965fec6749b92170223bca0bcad537e9..f666671ab1a28d9d660daa78ac2a2634 } } if (tr.docChanged && !this._dispatch(tr)) { +diff --git a/src/sync-plugin.js b/src/sync-plugin.js +index bc8702874bc1a81a1354525fc1734aca97e583a0..39ce2a36c423d74d15e8956de9af3648eff25cfb 100644 +--- a/src/sync-plugin.js ++++ b/src/sync-plugin.js +@@ -75,6 +75,7 @@ const $maybeSyncPluginStateUpdate = $syncPluginStateUpdate.nullable + * @param {AttributedNodesPredicate} [opts.attributedNodes] Optional predicate `(nodeName, kinds) => boolean`. When it returns `true` for an attributed node *and* a `{nodeName}--attributed` type exists in the schema, that node is rendered under the variant type (the `y-attributed-*` marks are still applied). `kinds` is `{ insert?, delete?, format? }`. The variant is a pure rendering concern - the canonical name is what is stored in the Y document. The predicate must be deterministic in `(nodeName, kinds)`. + * @param {NodeCompare} [opts.customCompare] Optional predicate `(a, b) => boolean` that shifts the *diffing boundary*. To sync, y-prosemirror diffs the ProseMirror doc against the Y document as `lib0/delta` trees; lib0's `diff` decides for each candidate node pair whether to pair them (diff *in place* via a `modify` op) or to **replace the old subtree wholesale** (delete + insert). By default a pair is matched purely on node name (`a.name === b.name`). Supply this to move the boundary - e.g. make a `blockContainer` only pair when its first child type also matches (`(a, b) => a.name === b.name && (a.name !== 'blockContainer' || firstChildName(a) === firstChildName(b))`), so changing the first child replaces the whole container instead of editing it in place. Receives the raw `lib0/delta` nodes `(fromNode, toNode)` (each exposing `.name`, `.attrs`, `.children`) and is forwarded to `lib0/delta.diff` as its `compare` option, applied recursively down the tree. Generally keep the `a.name === b.name` check; omit the option to keep lib0's name-only default. + * @param {Array<(($d: s.Schema) => dt.Template)>} [opts.transformers] Optional custom transformer stages, slotted into the pipeline **between** `fullAttributions` and `attributionToFormat`, in data→view (`applyA`) order. Each is a `$d => Template` factory (see `lib0/delta/transformer`); the input schema is threaded left to right. Custom transformers see changes in canonical document space, with the complete accumulated attribution on every attribution-bearing op. ++ * @param {null|((err:Error,errCode:number)=>any)} [opts.onInternalError] forwarded to {@link YSyncRdt} - listen to internal errors for debugging purposes (unstable API) + * @returns {Plugin} + */ + export function syncPlugin (opts = {}) { +@@ -130,7 +131,8 @@ export function syncPlugin (opts = {}) { + ytype, + renderer, + origin: ySyncPluginKey.get(view.state), +- compare ++ compare, ++ onInternalError: opts.onInternalError || null + }) + const pmRdt = new ProsemirrorRdt({ + view, diff --git a/src/sync-utils.js b/src/sync-utils.js index 834e75955b4ff0e0209fcc543893b6a6a401d64b..e62e6c8bfeb970491e28adca4dec4e1a0d613fa2 100644 --- a/src/sync-utils.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6decb347ed..cb13c6d475 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,13 +23,13 @@ overrides: '@vitest/runner': 4.1.10 '@vitest/mocker': 4.1.10 '@y/y': 14.0.0-rc.23 - '@y/prosemirror': 2.0.0-6 + '@y/prosemirror': 2.0.0-7 lib0: 1.0.0-rc.22 packageExtensionsChecksum: sha256-RBsr8H6XmGjVk3a5IXktWPY+vN2mX4m0Q/uTlfMsVxo= patchedDependencies: - '@y/prosemirror@2.0.0-6': e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776 + '@y/prosemirror@2.0.0-7': 8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab katex@0.16.47: cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7 importers: @@ -244,8 +244,8 @@ importers: specifier: ^0.6.3 version: 0.6.4(react@19.2.5)(yjs@13.6.30) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-7 + version: 2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) @@ -4243,8 +4243,8 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-7 + version: 2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) @@ -4405,8 +4405,8 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-7 + version: 2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) @@ -4561,8 +4561,8 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-7 + version: 2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/y': specifier: 14.0.0-rc.23 version: 14.0.0-rc.23 @@ -5236,8 +5236,8 @@ importers: specifier: ^3.29.2 version: 3.29.2 '@y/prosemirror': - specifier: 2.0.0-6 - version: 2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) + specifier: 2.0.0-7 + version: 2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@y/protocols': specifier: ^1.0.6-rc.1 version: 1.0.6-rc.1(@y/y@14.0.0-rc.23) @@ -11444,8 +11444,8 @@ packages: '@y-sweet/sdk@0.6.4': resolution: {integrity: sha512-px51qSbckGrucN83BM9jJyaBLLdYFT+zhvsootK+WW9t/9rQSQHQX54gdtF6M1kUktA4jOGfSiAXDzuTY0zYVg==} - '@y/prosemirror@2.0.0-6': - resolution: {integrity: sha512-SRXxliKc2Q0EBoN3bayP+5PgFNzkPW0xG7PsFAeRJYn/d0kYKpJcdCPhH2awjO34P8CZXCD0eC8hDkujYFyHgg==} + '@y/prosemirror@2.0.0-7': + resolution: {integrity: sha512-f2crXEGd6188Iv/aH5F5VGmPnX/wWoHHZp5acS5s8NgdmDyuEq2DxwaLuuoWPmYX8MYVlqf8QM1NRF7Pdpk6YA==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: '@y/protocols': ^1.0.6-rc.1 @@ -21640,7 +21640,7 @@ snapshots: dependencies: '@types/node': 25.6.0 - '@y/prosemirror@2.0.0-6(patch_hash=e49b17b47e301dd138d7e383a779a0e2125bf7f038e10e3c740e26b43d988776)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)': + '@y/prosemirror@2.0.0-7(patch_hash=8be3d0147380d9953ce57af382f67a3358e219608a6533b163119669eee8b9ab)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.23))(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)': dependencies: '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.23) '@y/y': 14.0.0-rc.23 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c48f3d7dbe..f245fb9adc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,7 @@ overrides: # to hoist ("problems in resolving the mocks API"). 4.1.10 adds vite-plus/test. "@vitest/mocker": "4.1.10" "@y/y": "14.0.0-rc.23" - "@y/prosemirror": "2.0.0-6" + "@y/prosemirror": "2.0.0-7" "lib0": "1.0.0-rc.22" packageExtensions: # `@vitest/ui` is an *optional peer* of vitest, which vite-plus re-exposes as @@ -69,7 +69,7 @@ allowBuilds: leveldown: false patchedDependencies: { - "@y/prosemirror@2.0.0-6": patches/@y__prosemirror@2.0.0-6.patch, + "@y/prosemirror@2.0.0-7": patches/@y__prosemirror@2.0.0-7.patch, katex@0.16.47: patches/katex@0.16.47.patch, } catalog: From 92a272aa8d85a1deece3adbf16d952e554cb3568 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:17:58 +0200 Subject: [PATCH 06/13] feat(y): observe internal errors y-prosemirror recovered from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Y-side apply throws mid-sync, y-prosemirror reverts the unappliable part and logs a console warning — the editor keeps working but the cause is easy to miss (this hid a broken versioning re-render for two months). Feed syncPlugin's onInternalError into a module-scoped observer registry (onYSyncInternalError) so diagnostics harnesses can observe every editor without threading an option through each construction. --- packages/core/src/y/extensions/YSync.ts | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts index aff9f3ab21..4212c1e8c1 100644 --- a/packages/core/src/y/extensions/YSync.ts +++ b/packages/core/src/y/extensions/YSync.ts @@ -3,9 +3,45 @@ import { type ExtensionOptions, createExtension, } from "../../editor/BlockNoteExtension.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { blockMatchNodes } from "./blockMatchNodes.js"; import { CollaborationOptions } from "./index.js"; +/** + * Observer for internal errors y-prosemirror recovered from. When the Y-side + * apply throws mid-sync, y-prosemirror reverts the unappliable part of the + * change, logs a console warning and reports the error here — the editor + * keeps working, but without observing this, whatever caused the throw is + * easy to miss. `errCode` identifies the failure site (`0`: `applyDelta` + * failed); `error` is the original thrown error. + */ +export type YSyncInternalErrorObserver = ( + error: Error, + errCode: number, + editor: BlockNoteEditor, +) => void; + +const internalErrorObservers = new Set(); + +/** + * Subscribe to internal errors y-prosemirror recovered from, fed by + * `YSyncRdt`'s `onInternalError` debugging option (shipped in + * `@y/prosemirror` 2.0.0-7 after https://github.com/yjs/y-prosemirror/pull/273; + * upstream marks it unstable). Our pnpm patch only threads the option through + * `syncPlugin` — see patches/@y__prosemirror@2.0.0-7.patch. Module-scoped + * rather than per-editor so diagnostics harnesses (e.g. the e2e console + * guard) can observe every editor without threading an option through each + * construction. Returns an unsubscribe function. + */ +export function onYSyncInternalError( + observer: YSyncInternalErrorObserver, +): () => void { + internalErrorObservers.add(observer); + return () => { + internalErrorObservers.delete(observer); + }; +} + /** * Maps a Y attribution to BlockNote's `y-attributed-*` mark attrs. * @@ -117,6 +153,14 @@ export const YSyncExtension = createExtension( // needed; `blockContainer` already whitelists the `y-attributed-*` // marks. See blockMatchNodes.ts. customCompare: blockMatchNodes, + // Surface errors the sync recovered from (see onYSyncInternalError + // above). y-prosemirror logs its own console warning regardless, so + // this only fans out to observers. + onInternalError: (error: Error, errCode: number) => { + for (const observer of internalErrorObservers) { + observer(error, errCode, editor); + } + }, }), ], runsBefore: ["default"], From 818ce113db6dd84120603fdcd4d9416a4fbd0c9b Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:18:00 +0200 Subject: [PATCH 07/13] fix(y): defer comments thread-store subscribers out of the observer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit observeDeep fires inside the transaction that changed the threads, and the comments extension's subscriber walks the whole doc and dispatches mark updates — running that synchronously inside the commit misattributes subscriber failures to the sync machinery and blocks the committing transaction on getThreads(). A coalesced microtask defers it. --- .../core/src/y/comments/YjsThreadStoreBase.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/y/comments/YjsThreadStoreBase.ts b/packages/core/src/y/comments/YjsThreadStoreBase.ts index b62c2e1811..b956ad8a0c 100644 --- a/packages/core/src/y/comments/YjsThreadStoreBase.ts +++ b/packages/core/src/y/comments/YjsThreadStoreBase.ts @@ -37,13 +37,35 @@ export abstract class YjsThreadStoreBase extends ThreadStore { } public subscribe(cb: (threads: Map) => void) { + // Deferred out of the Yjs observer chain: `observeDeep` fires inside the + // transaction that changed the threads (a local comment edit, or a remote + // update mid-apply on the provider's chain). Subscribers do real work — + // the comments extension walks the whole doc and dispatches mark updates — + // and running that synchronously inside the commit means a subscriber + // failure unwinds into the sync machinery and gets misattributed there + // (see the suggestion gallery's deferred `renderDiff` for the same + // pattern). The microtask also coalesces observer bursts into a single + // callback and moves the `getThreads()` materialization out of the + // committing transaction. + let queued = false; + let unsubscribed = false; const observer = () => { - cb(this.getThreads()); + if (queued) { + return; + } + queued = true; + queueMicrotask(() => { + queued = false; + if (!unsubscribed) { + cb(this.getThreads()); + } + }); }; this.threadsYType.observeDeep(observer); return () => { + unsubscribed = true; this.threadsYType.unobserveDeep(observer); }; } From d4df9f447798884a421e31bdd93cca840925a1a6 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:18:03 +0200 Subject: [PATCH 08/13] test(e2e): fail tests on console errors and recovered sync errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some dependencies deliberately reduce hard failures to console output — most importantly y-prosemirror's last-resort sync catch, which is exactly how the versioning re-render bug stayed invisible. Fail any test that produces a console.error (allowlist-able), a console.warn matching known swallowed-error patterns, or an internal error observed via onYSyncInternalError (which carries the original stack). --- tests/vitestSetup.browser.ts | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 469a859137..1e1653f38a 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -1,3 +1,4 @@ +import { onYSyncInternalError } from "@blocknote/core/y"; import { afterEach, beforeAll, beforeEach } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; @@ -32,3 +33,96 @@ beforeEach(() => { afterEach(() => { delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); + +// --- Console failure guard -------------------------------------------------- +// Some dependencies deliberately swallow hard failures into console output. +// The important case is @y/prosemirror's y-sync "last-resort safety" catch, +// which downgrades an Error thrown mid-sync into +// console.warn('[y/prosemirror] ytype.applyDelta failed - reverting …', err) +// and leaves the UI silently stale — the suggestion-gallery move-diff bug +// stayed invisible for two months exactly this way. To keep that class of bug +// loud in CI, a test fails when it produces: +// - any `console.error` call (minus CONSOLE_ERROR_ALLOWLIST), or +// - a `console.warn` matching CONSOLE_WARN_DENYLIST (known swallowed-error +// sources), or +// - an internal error y-prosemirror recovered from, observed via +// `onYSyncInternalError` (fed by `YSyncRdt`'s `onInternalError` debugging +// option, shipped in @y/prosemirror 2.0.0-7 after yjs/y-prosemirror#273; +// our pnpm patch threads it through syncPlugin). The observer receives +// the original Error with its stack; y-prosemirror's own warning still +// fires too, so a swallowed sync error shows up as both entries. +// Uncaught exceptions (window "error" events) are already failed by vitest +// itself; this guard only covers failures something caught and logged. +// +// Allowlist deliberate console output per-pattern below, with a comment +// saying which test produces it and why it is expected. + +const CONSOLE_ERROR_ALLOWLIST: RegExp[] = [ + // React's structural dev warning for rich-text editors: React-rendered + // custom-block content lives inside the ProseMirror-managed contentEditable, + // so React cannot guarantee those children stay untouched. Inherent to the + // integration (fires in the custom-blocks e2e), not a swallowed failure. + /A component is `contentEditable` and contains `children` managed by React/, +]; +const CONSOLE_WARN_DENYLIST: RegExp[] = [/\[y\/prosemirror\]/]; + +function formatConsoleArg(arg: unknown): string { + if (arg instanceof Error) { + return arg.stack ?? String(arg); + } + if (typeof arg === "string") { + return arg; + } + try { + return JSON.stringify(arg) ?? String(arg); + } catch { + return String(arg); + } +} + +const consoleViolations: string[] = []; +let unsubscribeInternalErrors: (() => void) | undefined; +/* eslint-disable no-console -- the guard intercepts the console by design */ +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + +beforeEach(() => { + consoleViolations.length = 0; + console.error = (...args: unknown[]) => { + originalConsoleError.apply(console, args); + const text = args.map(formatConsoleArg).join(" "); + if (!CONSOLE_ERROR_ALLOWLIST.some((pattern) => pattern.test(text))) { + consoleViolations.push(`console.error: ${text}`); + } + }; + console.warn = (...args: unknown[]) => { + originalConsoleWarn.apply(console, args); + const text = args.map(formatConsoleArg).join(" "); + if (CONSOLE_WARN_DENYLIST.some((pattern) => pattern.test(text))) { + consoleViolations.push(`console.warn: ${text}`); + } + }; + // The observer entry is the one carrying the original stack (the package's + // own warning, caught by the denylist above, flattens the error). + unsubscribeInternalErrors = onYSyncInternalError((error, errCode) => { + consoleViolations.push( + `[y/prosemirror internal error] (code ${errCode}) ${formatConsoleArg(error)}`, + ); + }); +}); + +afterEach(() => { + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + unsubscribeInternalErrors?.(); + unsubscribeInternalErrors = undefined; + if (consoleViolations.length > 0) { + const report = consoleViolations.splice(0).join("\n\n"); + throw new Error( + "Test produced console output that indicates a swallowed failure " + + "(see the console guard in vitestSetup.browser.ts — allowlist " + + `deliberate output there):\n\n${report}`, + ); + } +}); +/* eslint-enable no-console */ From 0cea85a0064437531f6b97b8a34e7b2ffb2f5f14 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 14:18:05 +0200 Subject: [PATCH 09/13] docs(examples): defer the gallery diff re-render out of the observer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering the Diff synchronously inside the afterDoc update handler ran enterPreview within the typing editor's Y transaction commit — a failure there was swallowed by that editor's sync catch and interrupted the remaining observers mid-forward. A coalesced microtask lets the CRDT forwarding complete untouched and makes a render failure surface as a real uncaught error. --- .../14-suggestion-gallery/src/App.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx index 15a1111dac..29d778d98c 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -466,11 +466,35 @@ function VersionMerge({ setup.attrs, ); renderDiff(); - setup.afterDoc.on("update", renderDiff); + // Defer the re-render out of the Yjs observer chain. `afterDoc`'s update + // events fire synchronously inside the *typing* editor's Y transaction + // commit, so rendering the Diff directly in the handler runs enterPreview + // within that editor's sync machinery — a failure there gets caught by + // its y-sync last-resort catch and reduced to a console warning (which + // once hid a stale-diff bug for two months), and it interrupts the + // remaining observers mid-forward. A microtask lets the CRDT forwarding + // complete untouched, coalesces update bursts into one render, and makes + // a render failure surface as a real uncaught error. + let renderQueued = false; + let disposed = false; + const scheduleRenderDiff = () => { + if (renderQueued) { + return; + } + renderQueued = true; + queueMicrotask(() => { + renderQueued = false; + if (!disposed) { + renderDiff(); + } + }); + }; + setup.afterDoc.on("update", scheduleRenderDiff); return () => { + disposed = true; offs.forEach((off) => off()); - setup.afterDoc.off("update", renderDiff); + setup.afterDoc.off("update", scheduleRenderDiff); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From 16cc816d447af714ca1ae53ac352b9b786df848f Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 15:08:54 +0200 Subject: [PATCH 10/13] fix(examples): give the gallery's large-diff scenarios real block ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared testDocument is a snapshot fixture whose blocks deliberately carry empty-string ids (real ids would be minted at module load and make exporter snapshots non-deterministic). Empty ids violate the editor's id contract — getNodeId throws on them — which crashed the large-diff scenarios' apply calls ("Node blockContainer does not have an ID"), the last known versioning crasher. Follow the conversion-test convention and assign ids consumer-side via addIdsToBlocks, on a clone so the shared fixture stays untouched. The large-diff-delete-all scenario passes the versioning e2e now, so the VERSIONING_CRASHES skip is gone: all 66 scenarios run, none skipped. --- .../14-suggestion-gallery/src/scenarios.ts | 21 +++++++++++----- .../y-prosemirror/versioning.test.tsx | 24 ++++++------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts index e485ed3f87..df7f558f2b 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -1,7 +1,20 @@ +import { addIdsToBlocks } from "@shared/formatConversionTestUtil.js"; import { testDocument } from "@shared/testDocument.js"; import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; +// The shared test document is a snapshot fixture and deliberately carries +// empty-string block ids (real ids would be minted at module load, making the +// exporter snapshots that embed them non-deterministic). Empty ids violate the +// editor's id contract though — `getNodeId` throws on them, which crashed the +// large-diff scenarios' `replaceBlocks`/`insertBlocks` calls. Follow the +// conversion-test convention: the consumer assigns ids (on a clone, so the +// shared fixture stays untouched for other importers). +const testDocumentWithIds = structuredClone( + testDocument, +) as unknown as GalleryPartialBlock[]; +addIdsToBlocks(testDocumentWithIds); + /** * A browsable suggestion scenario. * @@ -1590,11 +1603,7 @@ export const scenarios: SuggestionScenario[] = [ "Insert every block type from the shared test document at once — a stress test for large diffs.", initial: [{ id: "anchor", type: "paragraph", content: "Document start" }], apply: (editor) => - editor.insertBlocks( - testDocument as unknown as GalleryPartialBlock[], - "anchor", - "after", - ), + editor.insertBlocks(testDocumentWithIds, "anchor", "after"), feedback: [ { severity: "high", @@ -1609,7 +1618,7 @@ export const scenarios: SuggestionScenario[] = [ category: "Large diffs", description: "Remove every block of the shared test document, leaving a single paragraph — a stress test for large diffs.", - initial: testDocument as unknown as GalleryPartialBlock[], + initial: testDocumentWithIds, apply: (editor) => editor.replaceBlocks(editor.document, [ { type: "paragraph", content: "(all content removed)" }, diff --git a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx index c525d100f8..c54d1fcc0a 100644 --- a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx +++ b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx @@ -4,13 +4,12 @@ * The other files in this folder exercise the SuggestionsExtension diff overlay. * This one exercises the OTHER diff path — `createYjsVersioningAdapter`'s * `enterPreview`, which reconfigures the editor through y-prosemirror - * (`configureYProsemirror`). That path crashes for a few scenarios: moving a - * block that carries (or dissolves) a nested blockGroup makes y-prosemirror's - * `applyDelta` throw lib0 "Unexpected case". Each scenario is run through the - * same shape the gallery's Versioning mode uses — every user applies their - * change on their own clone of the base, the clones are merged via the Yjs CRDT, - * and the merge is diffed against the base — so any scenario (single or - * concurrent) that breaks the versioning diff is caught in CI. + * (`configureYProsemirror`). Each scenario is run through the same shape the + * gallery's Versioning mode uses — every user applies their change on their + * own clone of the base, the clones are merged via the Yjs CRDT, and the merge + * is diffed against the base; the preview is then RE-entered after a follow-up + * edit (the gallery's live re-diff) — so any scenario (single or concurrent) + * that breaks the versioning diff or its re-render is caught in CI. */ import { BlockNoteEditor } from "@blocknote/core"; import { @@ -75,22 +74,13 @@ function mountEditor(doc: Y.Doc): { }; } -// Scenarios that currently crash the versioning diff and are skipped until -// fixed. `large-diff-delete-all` replaceBlocks-traverses a bound -// `blockContainer` that has no `id` attr, so `getNodeId` throws -// ("Node blockContainer does not have an ID"). We `test.skip` rather than -// `test.fails` because as of @y/prosemirror v2.0.0-6 the throw is caught and -// retried into a runaway warning loop that never lets the suite finish. -const VERSIONING_CRASHES = new Set(["large-diff-delete-all"]); - for (const scenario of scenarios) { const applies = scenario.kind === "single" ? [scenario.apply] : [scenario.applyA, scenario.applyB]; - const runner = VERSIONING_CRASHES.has(scenario.id) ? test.skip : test; - runner(`versioning diff: ${scenario.title}`, async () => { + test(`versioning diff: ${scenario.title}`, async () => { const teardown: Array<() => void> = []; try { // "Before": the scenario's initial blocks, seeded synchronously. From 7d505892967c05c1e3eb8ee3e8d183bea36009eb Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 16:14:45 +0200 Subject: [PATCH 11/13] test(math-block): update pdf snapshot for positional fragment keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed alongside the xl-pdf-exporter key change — math-block's own pdf snapshot embeds the fragment keys, which are positional now. --- .../pdf-exporter/__snapshots__/exampleWithMathMappings.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx b/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx index cd3a3b0a49..2bfdb5e3f5 100644 --- a/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx +++ b/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx @@ -11,7 +11,7 @@ paddingTop: 35 }} > - + - + Date: Thu, 20 Aug 2026 16:14:47 +0200 Subject: [PATCH 12/13] test(e2e): allowlist ResizeObserver loop notices in the console guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'ResizeObserver loop completed with undelivered notifications' is a benign, browser-generated layout notice (a frame's observations were superseded before delivery). It fires under CI load — WebKit and Firefox especially — and vitest surfaces it as a console error, which the guard then treated as a swallowed failure. --- tests/vitestSetup.browser.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 1e1653f38a..04ea978be1 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -63,6 +63,10 @@ const CONSOLE_ERROR_ALLOWLIST: RegExp[] = [ // so React cannot guarantee those children stay untouched. Inherent to the // integration (fires in the custom-blocks e2e), not a swallowed failure. /A component is `contentEditable` and contains `children` managed by React/, + // Benign browser-generated layout notice (one frame's observations were + // superseded before delivery). Fires under load — WebKit/Firefox on CI + // especially — and is universally treated as noise, not a swallowed failure. + /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/, ]; const CONSOLE_WARN_DENYLIST: RegExp[] = [/\[y\/prosemirror\]/]; From 21b60cb1006b6a06438eec14b958309b6ecea6ff Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 20 Aug 2026 16:24:29 +0200 Subject: [PATCH 13/13] test(e2e): include the error message in guard reports on all engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8 stacks begin with the 'Name: message' line, but WebKit and Firefox stacks contain only frames — so the guard's allowlist patterns (matched against the formatted text) never saw the message on those engines, and the allowlisted ResizeObserver notice still failed their CI shards. Compose message + stack explicitly. --- tests/vitestSetup.browser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 04ea978be1..b5b6b5c2d9 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -72,7 +72,12 @@ const CONSOLE_WARN_DENYLIST: RegExp[] = [/\[y\/prosemirror\]/]; function formatConsoleArg(arg: unknown): string { if (arg instanceof Error) { - return arg.stack ?? String(arg); + // V8 stacks begin with the "Name: message" line, but WebKit and Firefox + // stacks contain only frames — compose both so allowlist patterns can + // always match against the message, whatever the engine. + const head = String(arg); + const stack = arg.stack ?? ""; + return stack.startsWith(head) ? stack : `${head}\n${stack}`; } if (typeof arg === "string") { return arg;