Yjs normalization inside ySyncPlugin transaction not propagating to ProseMirror

@Shion-0014 Shion Maeda
Wed, 11 Feb 2026 13:20:15 +0000

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

1. What is the recommended way to implement normalize-after-change when using ySyncPlugin?

2. Is this behavior expected, or a bug/limitation of the current binding design?

Thank you.