Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "loomy",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
61 changes: 60 additions & 1 deletion apps/frontend/src/hooks/useBoardCollab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
PERSISTENCE_LOAD_ORIGIN,
REMOTE_ORIGIN,
applyElementsToYMap,
applyFilesToYMap,
readElementsFromYMap,
readFilesFromYMap,
type ElementJson,
type FileJson,
} from "@/lib/collab/yjs-bridge";
import { decodeYjsUpdate, encodeYjsDoc } from "@/lib/collab/yjs-persistence";
import {
Expand All @@ -20,18 +23,23 @@ export interface UseBoardCollabOptions extends Omit<
"onRemoteBinary"
> {
onRemoteElements?: (elements: ElementJson[]) => void;
onRemoteFiles?: (files: FileJson[]) => void;
}

export function useBoardCollab(
boardId: string | null,
token: string | null,
options: UseBoardCollabOptions = {},
) {
const { onRemoteElements, ...wsOptions } = options;
const { onRemoteElements, onRemoteFiles, ...wsOptions } = options;
const onRemoteElementsRef = useRef(onRemoteElements);
const onRemoteFilesRef = useRef(onRemoteFiles);
useEffect(() => {
onRemoteElementsRef.current = onRemoteElements;
}, [onRemoteElements]);
useEffect(() => {
onRemoteFilesRef.current = onRemoteFiles;
}, [onRemoteFiles]);

// boardId is the dependency even though the factory doesn't read it:
// changing boards must produce a fresh Y.Doc.
Expand All @@ -44,6 +52,7 @@ export function useBoardCollab(
}, [doc]);

const ymap = useMemo(() => doc.getMap<ElementJson>("elements"), [doc]);
const yfiles = useMemo(() => doc.getMap<FileJson>("files"), [doc]);

// Per-user undo scope: only the local client's edits are in the stack.
// Remote edits don't end up on our undo history — you can only undo
Expand Down Expand Up @@ -109,13 +118,60 @@ export function useBoardCollab(
};
}, [ymap]);

useEffect(() => {
const observer = (event: Y.YMapEvent<FileJson>) => {
if (event.transaction.origin === LOCAL_ORIGIN) return;
const added: FileJson[] = [];
for (const id of event.keysChanged) {
const f = yfiles.get(id);
if (f) added.push(f);
}
if (added.length > 0) onRemoteFilesRef.current?.(added);
};
yfiles.observe(observer);
return () => {
yfiles.unobserve(observer);
};
}, [yfiles]);

const syncLocalElements = useCallback(
(elements: readonly ElementJson[]) => {
applyElementsToYMap(doc, ymap, elements);
},
[doc, ymap],
);

const syncLocalFiles = useCallback(
(files: Readonly<Record<string, FileJson>> | null | undefined) => {
if (!files) return;
applyFilesToYMap(doc, yfiles, files);
},
[doc, yfiles],
);

// Combined writer used by the canvas onChange handler. One outer
// transact means files + elements travel as a single Yjs update,
// so a peer never sees an image element whose fileId hasn't been
// populated in the files map yet (which renders the image as a
// pending placeholder Excalidraw won't let you interact with).
const syncLocalChanges = useCallback(
(
elements: readonly ElementJson[],
files: Readonly<Record<string, FileJson>> | null | undefined,
) => {
doc.transact(() => {
if (files) applyFilesToYMap(doc, yfiles, files);
applyElementsToYMap(doc, ymap, elements);
}, LOCAL_ORIGIN);
Comment on lines +162 to +165
},
[doc, ymap, yfiles],
);

const readAllFiles = useCallback(
(): FileJson[] => readFilesFromYMap(yfiles),
[yfiles],
);

const seedFromSnapshot = useCallback(
(elements: readonly ElementJson[] | null | undefined) => {
if (!elements || elements.length === 0) return;
Expand Down Expand Up @@ -143,6 +199,9 @@ export function useBoardCollab(
return {
...ws,
syncLocalElements,
syncLocalFiles,
syncLocalChanges,
readAllFiles,
seedFromSnapshot,
encodeYjsState,
applyYjsState,
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/hooks/useBoardContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const SNAPSHOT_TYPE = "excalidraw_snapshot";
export type ExcalidrawSnapshot = {
elements?: readonly unknown[] | null;
appState?: Readonly<Record<string, unknown>> | null;
// Image blobs keyed by Excalidraw fileId. Image elements only carry
// a fileId reference, so without this map the image renders blank.
files?: Readonly<Record<string, unknown>> | null;
// Authoritative shared state when present (base64 Yjs update).
// Legacy boards have only `elements`/`appState`.
yjs_update?: string | null;
Expand Down
165 changes: 165 additions & 0 deletions apps/frontend/src/lib/collab/yjs-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
import * as Y from "yjs";
import {
applyElementsToYMap,
applyFilesToYMap,
readElementsFromYMap,
readFilesFromYMap,
type ElementJson,
type FileJson,
} from "./yjs-bridge";

function makeDoc() {
Expand Down Expand Up @@ -117,6 +120,168 @@ describe("in-place element mutation (regression: 'only a dot' bug)", () => {
});
});

describe("echo suppression (regression: peer's drag snaps back)", () => {
it("does not write when only versionNonce changed but content is identical", () => {
const { doc, ymap } = makeDoc();
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 1, x: 100, y: 100 },
]);

let updates = 0;
doc.on("update", () => updates++);
// Excalidraw bumps versionNonce while applying an inbound scene update
// even though the visible state is unchanged. Without echo suppression
// we'd write this back to the peer mid-drag.
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 999, x: 100, y: 100 },
]);
expect(updates).toBe(0);
});

it("still writes when content actually changed (different x/y)", () => {
const { doc, ymap } = makeDoc();
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 1, x: 100, y: 100 },
]);

let updates = 0;
doc.on("update", () => updates++);
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 2, x: 200, y: 100 },
]);
expect(updates).toBeGreaterThan(0);
expect(ymap.get("x")?.x).toBe(200);
});

it("ignores `version` field drift the same way", () => {
const { doc, ymap } = makeDoc();
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 1, version: 1, x: 0 },
]);

let updates = 0;
doc.on("update", () => updates++);
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 2, version: 5, x: 0 },
]);
expect(updates).toBe(0);
});

it("ignores `updated` timestamp drift (the field Excalidraw bumps when applying inbound updateScene)", () => {
const { doc, ymap } = makeDoc();
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 1, updated: 1000, x: 50 },
]);

let updates = 0;
doc.on("update", () => updates++);
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 2, updated: 9999, x: 50 },
]);
expect(updates).toBe(0);
});

it("ignores `seed` drift", () => {
const { doc, ymap } = makeDoc();
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 1, seed: 111, x: 0 },
]);

let updates = 0;
doc.on("update", () => updates++);
applyElementsToYMap(doc, ymap, [
{ id: "x", versionNonce: 2, seed: 222, x: 0 },
]);
expect(updates).toBe(0);
});

it("simulates the full peer-drag echo path: B drags, A's stamp comes back, B holds", () => {
// Models the snap-back bug end-to-end inside one doc.
const { doc, ymap } = makeDoc();
// Initial state: image at x=100.
applyElementsToYMap(doc, ymap, [
{ id: "img", versionNonce: 1, version: 1, updated: 1000, x: 100, y: 0 },
]);

// Peer B drags it to x=200 (real change).
applyElementsToYMap(doc, ymap, [
{ id: "img", versionNonce: 2, version: 2, updated: 2000, x: 200, y: 0 },
]);
expect(ymap.get("img")?.x).toBe(200);

// Peer A's Excalidraw applied that update via updateScene, then
// fired onChange with the same x=200 but with version/nonce/updated
// re-stamped. The bridge MUST NOT echo this back.
let updates = 0;
doc.on("update", () => updates++);
applyElementsToYMap(doc, ymap, [
{ id: "img", versionNonce: 3, version: 3, updated: 3000, x: 200, y: 0 },
]);
expect(updates).toBe(0);
expect(ymap.get("img")?.x).toBe(200);
});
});

describe("applyFilesToYMap", () => {
function makeFilesDoc() {
const doc = new Y.Doc();
const ymap = doc.getMap<FileJson>("files");
return { doc, ymap };
}

it("inserts files keyed by id", () => {
const { doc, ymap } = makeFilesDoc();
applyFilesToYMap(doc, ymap, {
f1: { id: "f1", dataURL: "data:a", mimeType: "image/png" },
f2: { id: "f2", dataURL: "data:b", mimeType: "image/png" },
});
expect(ymap.size).toBe(2);
expect(ymap.get("f1")?.dataURL).toBe("data:a");
});

it("never overwrites an existing entry by the same id", () => {
const { doc, ymap } = makeFilesDoc();
applyFilesToYMap(doc, ymap, {
f1: { id: "f1", dataURL: "data:a" },
});
applyFilesToYMap(doc, ymap, {
f1: { id: "f1", dataURL: "data:OVERWRITE" },
});
expect(ymap.get("f1")?.dataURL).toBe("data:a");
});

it("does not delete files missing from the input (insert-only)", () => {
const { doc, ymap } = makeFilesDoc();
applyFilesToYMap(doc, ymap, {
f1: { id: "f1", dataURL: "data:a" },
f2: { id: "f2", dataURL: "data:b" },
});
applyFilesToYMap(doc, ymap, {
f1: { id: "f1", dataURL: "data:a" },
});
expect(ymap.has("f2")).toBe(true);
});

it("ignores entries without a string id", () => {
const { doc, ymap } = makeFilesDoc();
applyFilesToYMap(doc, ymap, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bogus: { dataURL: "x" } as any,
});
expect(ymap.size).toBe(0);
});

it("syncs files between two docs via Yjs updates", () => {
const a = makeFilesDoc();
const b = makeFilesDoc();
applyFilesToYMap(a.doc, a.ymap, {
f1: { id: "f1", dataURL: "data:a" },
});
Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc));
expect(readFilesFromYMap(b.ymap).map((f) => f.id)).toEqual(["f1"]);
});
});

describe("Yjs round-trip between two docs", () => {
it("converges to the same map after exchanging updates", () => {
const a = makeDoc();
Expand Down
Loading
Loading