Hello,
I’m integrating ProseMirror with Yjs using ySyncPlugin (y-prosemirror) and I’m trying to implement a “normalize-after-change” pipeline: after any change, I rewrite parts of a Y.XmlFragment into a canonical form (e.g. normalizing text casing / structure).
What I’m trying to do
• Listen to Yjs changes (yDoc.on(“afterTransaction”, …))
• If the originating transaction comes from the ProseMirror → Yjs sync path (tx.origin === ySyncPluginKey), run a normalization step that mutates the same Y.XmlFragment inside another Yjs transaction (with a custom origin to avoid loops).
Expected
After ProseMirror edits text, the normalization transaction lowercases all text in Yjs, and then ProseMirror should reflect the normalized Yjs content.
Actual
• Normalization does apply to the Yjs doc / fragment.
• But when the initial change comes from ProseMirror → Yjs via ySyncPlugin, the normalization changes do not propagate back to ProseMirror (PM still shows the un-normalized content).
import { EditorState, type Transaction } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { describe, expect, it } from "vitest";
import { ySyncPlugin, ySyncPluginKey, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
import { scriptSchema } from "@/prosemirror";
type PluginView = {
update?: (view: EditorView, prevState: EditorState) => void;
destroy?: () => void;
};
class TestEditorView {
state: EditorState;
composing = false;
dom = {
addEventListener: (_name: string, _handler: EventListenerOrEventListenerObject) => {},
removeEventListener: (_name: string, _handler: EventListenerOrEventListenerObject) => {},
};
private pluginViews: PluginView[] = [];
constructor(state: EditorState) {
this.state = state;
this.pluginViews = state.plugins
.map((plugin) => plugin.spec.view?.(this as unknown as EditorView))
.filter(Boolean) as PluginView[];
}
dispatch(tr: Transaction): void {
const prevState = this.state;
const nextState = this.state.apply(tr);
this.updateState(nextState, prevState);
}
updateState(state: EditorState, prevState: EditorState): void {
this.state = state;
for (const view of this.pluginViews) {
view.update?.(this as unknown as EditorView, prevState);
}
}
hasFocus(): boolean {
return false;
}
destroy(): void {
for (const view of this.pluginViews) {
view.destroy?.();
}
}
}
const lowerCaseAllText = (container: Y.XmlFragment | Y.XmlElement): void => {
const children = container.toArray();
for (const child of children) {
if (child instanceof Y.XmlText) {
const text = child.toString();
const lower = text.toLowerCase();
if (text !== lower) {
child.delete(0, text.length);
child.insert(0, lower);
}
continue;
}
if (child instanceof Y.XmlElement) {
lowerCaseAllText(child);
}
}
};
describe("normalizer-mux-sync-issue", () => {
it("runs PM -> Y.Doc -> normalization in the smallest form", () => {
const yDoc = new Y.Doc();
const yFragment = yDoc.getXmlFragment("pm");
const NORMALIZER_ORIGIN = { id: "test::lowercase-normalizer" };
const onAfterTransaction = (tx: Y.Transaction) => {
if (tx.origin === NORMALIZER_ORIGIN) return;
if (tx.origin !== ySyncPluginKey) return;
yDoc.transact(() => {
lowerCaseAllText(yFragment);
}, NORMALIZER_ORIGIN);
};
yDoc.on("afterTransaction", onAfterTransaction);
const initialDoc = scriptSchema.node("doc", null, [
scriptSchema.node("paragraph", null, scriptSchema.text(" WORLD")),
]);
const syncPlugin = ySyncPlugin(yFragment, { mapping: new Map() });
const state = EditorState.create({ schema: scriptSchema, doc: initialDoc, plugins: [syncPlugin] });
const view = new TestEditorView(state);
// PM -> Y.Doc entry point
view.dispatch(view.state.tr.insertText("HELLO"));
const pmText = view.state.doc.textContent;
const yDocText = yXmlFragmentToProseMirrorRootNode(yFragment, scriptSchema).textContent;
// Expect normalization to have applied to Y.Doc
expect(yDocText).toBe("hello world");
// Expect PM to sync with normalized Y.Doc (currently fails)
expect(pmText).toBe("hello world");
view.destroy();
yDoc.off("afterTransaction", onAfterTransaction);
});
});
AssertionError: expected 'HELLO WORLD' to be 'hello world'
Expected: "hello world"
Received: "HELLO WORLD"
❯ tests/integration/prosemirror/issue.test.ts:127:18
125| expect(yDocText).toBe("hello world");
126| // Expect PM to sync with normalized Y.Doc (currently fails)
127| expect(pmText).toBe("hello world");
⸻
Questions / recommended approach
- What is the recommended way to implement normalize-after-change when using ySyncPlugin?
- Is this behavior expected, or a bug/limitation of the current binding design?
Thank you.