Preact: modifying shared Y.Array doesn't re-render anything

Hello! :smiley:

I’m making a WebXDC app with preact (a React alternative) and WebxdcProvider.

My intention is to have a shared Y.Array and have preact re-render things when it’s modified, but nothing gets re-rendered.

I’ve created a minimal example to reproduce what I’m doing in my app in this project: UUID Manager.

There’s an app that has a shared Y.Map and it works fine: Split Bill. What’s the actual difference? I’ve looked at it for days. :shaking_face:

Apologies if this is a preact issue. They don’t seem to have a forum.

Yes, I was doing something wrong!

When subscribing to the Y.Array with preact’s useSyncExternalStore, I was returning the Y.Array itself as a snapshot.

That’s the error: useSyncExternalStore requires snapshots to be immutable copies of the shared, mutable data.

I asked the same question in Delta Chat’s forum, and WofWca noticed what was wrong and proposed a fix that works: Preact: modifying shared Y.Array doesn’t re-render anything - #2 by WofWca - webxdc - Delta.Chat.

I’ll copy some things here.

WofWca pointed out:

What you are doing inside your getSnapshot function is return the same object every time. Which, I assume, what React detects as “the value didn’t change, so I don’t need to re-render”. Here is a solution that I came up with:

diff --git a/js/ystore.js b/js/ystore.js
index a1e7034..6d3e1f4 100644
--- a/js/ystore.js
+++ b/js/ystore.js
@@ -22,6 +22,7 @@ export const provider = new WebxdcProvider({
 class YStore {
 	#isSubscribed = false;
 	#listeners = new Set();
+	#cached = uuidsArray.toJSON()
 
 	#handleEvent = (event) => {
 		console.log("Received an event for the YStore!");
@@ -29,6 +30,8 @@ class YStore {
 
 		console.log(`There are ${this.#listeners.size} listeners.`);
 
+		this.#cached = uuidsArray.toJSON()
+
 		this.#listeners.forEach((l) => {
 			console.log("Calling a listener.");
 
@@ -72,7 +75,7 @@ class YStore {
 		// If I do this, the app freezes: empty page, infinite logs in the web browser console...
 		// return uuidsArray.toArray();
 
-		return uuidsArray;
+		return this.#cached;
 	};
 }

More parts of the code base to have more context:

“Frontend” code (it hasn’t changed):

import { html } from "./html.js";
import * as yStore from "./ystore.js";

export default function App(props) {
	console.log("App");

	const uuids = yStore.useUuids();

	function addRandomUuid() {
		yStore.uuidsArray.push([crypto.randomUUID()]);
	}

	return html`
		<h1>UUID manager</h1>

		<button onClick=${() => addRandomUuid()}>Add random UUID</button>

		<p>UUIDs:</p>

		${uuids.map((uuid) => html`<p>${uuid}</p>`)}
	`;
}

Yjs-related logic before applying the fix:

import * as Y from "yjs";
import WebxdcProvider from "y-webxdc";
import { useSyncExternalStore } from "preact/compat";

const ydoc = new Y.Doc();
export const uuidsArray = ydoc.getArray("uuids");

export const provider = new WebxdcProvider({
	webxdc,
	ydoc,
	autosaveInterval: webxdc.sendUpdateInterval,
	getEditInfo: () => {
		const document = "uuids";

		return { document };
	},
});

/**
 * I copied this code from another WebXDC app that works.
 */
class YStore {
	#isSubscribed = false;
	#listeners = new Set();

	#handleEvent = (event) => {
		console.log("Received an event for the YStore!");
		console.log(`uuidsArray has ${uuidsArray.length} elements.`);

		console.log(`There are ${this.#listeners.size} listeners.`);

		this.#listeners.forEach((l) => {
			console.log("Calling a listener.");

			l();
		});
	};

	subscribe = (listener) => {
		console.log("Subscribed!");

		this.#listeners.add(listener);

		if (!this.#isSubscribed) {
			console.log("Observing!");

			uuidsArray.observe(this.#handleEvent);
			this.#isSubscribed = true;
		}

		return () => {
			console.log("Unsubscribing!");

			this.#listeners.delete(listener);

			if (this.#listeners.size === 0) {
				console.log("Unobserving!");

				uuidsArray.unobserve(this.#handleEvent);
				this.#isSubscribed = false;
			}
		};
	};

	getSnapshot = () => {
		// This is an object.
		console.log(uuidsArray);

		// This is an array.
		console.log(uuidsArray.toArray());

		// If I do this, the app freezes: empty page, infinite logs in the web browser console...
		// return uuidsArray.toArray();

		return uuidsArray;
	};
}

const uuidsStore = new YStore();

export function useUuids() {
	return useSyncExternalStore(uuidsStore.subscribe, uuidsStore.getSnapshot);
}

Yjs-related logic after applying the fix:

import * as Y from "yjs";
import WebxdcProvider from "y-webxdc";
import { useSyncExternalStore } from "preact/compat";

const ydoc = new Y.Doc();
export const uuidsArray = ydoc.getArray("uuids");

export const provider = new WebxdcProvider({
	webxdc,
	ydoc,
	autosaveInterval: webxdc.sendUpdateInterval,
	getEditInfo: () => {
		const document = "uuids";

		return { document };
	},
});

/**
 * I copied this code from another WebXDC app that works.
 */
class YStore {
	#isSubscribed = false;
	#listeners = new Set();

	/**
	 * preact's useSyncExternalStore needs immutable data to work correctly.
	 * We will store immutable snapshots of the Yjs data here.
	 */
	#immutableSnapshot = uuidsArray.toArray();

	#handleEvent = (event) => {
		console.log("Received an event for the YStore!");
		console.log(`uuidsArray has ${uuidsArray.length} elements.`);

		console.log(`There are ${this.#listeners.size} listeners.`);

		this.#immutableSnapshot = uuidsArray.toArray();

		this.#listeners.forEach((l) => {
			console.log("Calling a listener.");

			l();
		});
	};

	subscribe = (listener) => {
		console.log("Subscribed!");

		this.#listeners.add(listener);

		if (!this.#isSubscribed) {
			console.log("Observing!");

			uuidsArray.observe(this.#handleEvent);
			this.#isSubscribed = true;
		}

		return () => {
			console.log("Unsubscribing!");

			this.#listeners.delete(listener);

			if (this.#listeners.size === 0) {
				console.log("Unobserving!");

				uuidsArray.unobserve(this.#handleEvent);
				this.#isSubscribed = false;
			}
		};
	};

	getSnapshot = () => {
		// Return a snapshot of the immutable data.
		// Simply doing this doesn't work (the app freezes: empty page, infinite logs in the web browser console...):
		// return uuidsArray.toArray();
		return this.#immutableSnapshot;
	};
}

const uuidsStore = new YStore();

export function useUuids() {
	return useSyncExternalStore(uuidsStore.subscribe, uuidsStore.getSnapshot);
}