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/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 }, []); 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/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/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/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 828894cf1d..6df4ac6ad2 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -1,3 +1,5 @@ +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"; @@ -5,6 +7,7 @@ import { getBlocksChangedByTransaction } from "./getBlocksChangedByTransaction.j import { getBlockInfo } from "./getBlockInfoFromPos.js"; import { getNodeById } from "./nodeUtil.js"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js"; import { PartialBlock } from "../blocks/defaultBlocks.js"; const getEditor = setupTestEnv(); @@ -574,6 +577,71 @@ 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"]]); + }); +}); + /** * These exercise the ranged optimization: getBlocksChangedByTransaction only * snapshots the range a transaction touched, not the whole document. In a large diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index 9157a86812..ec8d54f87a 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -12,7 +12,7 @@ import type { BlockSchema } from "../schema/index.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; import { getChangedRange } from "./getChangedRange.js"; -import { getNodeId } from "./getBlockInfoFromPos.js"; +import { getNodeId, isSuggestedDeletionNode } from "./getBlockInfoFromPos.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { isNodeBlock } from "./nodeUtil.js"; @@ -185,6 +185,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]) { 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) { 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); }; } 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"], 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 }} > - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + {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} 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: 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..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. @@ -104,6 +94,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 +102,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 +126,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()); } diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 469a859137..b5b6b5c2d9 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,105 @@ 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/, + // 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\]/]; + +function formatConsoleArg(arg: unknown): string { + if (arg instanceof Error) { + // 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; + } + 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 */