Yjs normalization inside ySyncPlugin transaction not propagating to ProseMirror

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.

I believe the binding wraps both directions of sync going PM→Yjs and Yjs→PM, so if one direction is running, the other direction is suppressed. Without it, you’d get PM changes → Yjs → observer fires → dispatches to PM → PM changes → Yjs → etc. etc. so its definitley not a bug, you would be stuck in a cycle for any Yjs mutations that happen synchronously during PM→Yjs sync. But either way the best way to handle this is to normalize before it reaches Yjs not afterwards. Normalizing in Yjs after the sync is going to 2x your doc updates, possibly more. I would normalize at the Prosemirror level, there’s an appendTransaction plugin for enforcing invariants. Then, for remote changes normalization happens inside of PM’s pipeline before the Yjs sync and not after, so it should be good both ways. You also could just try using setTimeout to delay the after transaction callback if you absolutley need to normalize in Yjs for some reason, but its much easier to make sure ysyncplugin only ever see the normalized state.