From ab63d0bbbb3a7982d3e1fe6bcbe828dfe7abfeeb Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Fri, 15 Aug 2025 09:22:19 +0200 Subject: [PATCH 1/2] Add high-concurrency E2E tests for node client (#2602) --- e2e/next-sandbox/pages/storage/map.tsx | 9 +- e2e/next-sandbox/pages/storage/object.tsx | 7 ++ e2e/next-sandbox/pages/zustand.tsx | 5 + .../test/storage/list-with-suspense.test.ts | 28 ++---- e2e/next-sandbox/test/storage/list.test.ts | 17 +--- e2e/next-sandbox/test/storage/map.test.ts | 6 +- e2e/next-sandbox/test/zustand.test.ts | 20 ++-- e2e/node-sandbox/test/node.test.ts | 97 ++++++++++++++----- shared/vitest-config/index.js | 6 +- 9 files changed, 123 insertions(+), 72 deletions(-) diff --git a/e2e/next-sandbox/pages/storage/map.tsx b/e2e/next-sandbox/pages/storage/map.tsx index 7ba536280b1..f27a8003c16 100644 --- a/e2e/next-sandbox/pages/storage/map.tsx +++ b/e2e/next-sandbox/pages/storage/map.tsx @@ -17,9 +17,10 @@ const { RoomProvider, useCanRedo, useCanUndo, - useStorage, useMutation, useRedo, + useStorage, + useSyncStatus, useUndo, } = createRoomContext }>(client); @@ -42,6 +43,7 @@ function Sandbox() { const redo = useRedo(); const canUndo = useCanUndo(); const canRedo = useCanRedo(); + const syncStatus = useSyncStatus(); const map = useStorage((root) => root.map); const set_ = useMutation(({ storage }, key: string, value: string) => { @@ -113,6 +115,11 @@ function Sandbox() { + diff --git a/e2e/next-sandbox/pages/storage/object.tsx b/e2e/next-sandbox/pages/storage/object.tsx index 6f933d4c30e..5b8e116ab66 100644 --- a/e2e/next-sandbox/pages/storage/object.tsx +++ b/e2e/next-sandbox/pages/storage/object.tsx @@ -22,6 +22,7 @@ const { useRedo, useSelf, useStorage, + useSyncStatus, useUndo, } = createRoomContext< never, @@ -57,6 +58,7 @@ function Sandbox() { const canRedo = useCanRedo(); const obj = useStorage((root) => root.object); const me = useSelf(); + const syncStatus = useSyncStatus(); const set_ = useMutation( ({ storage }, key: string, value: number | LiveObject<{ a: number }>) => { @@ -145,6 +147,11 @@ function Sandbox() {
+
diff --git a/e2e/next-sandbox/pages/zustand.tsx b/e2e/next-sandbox/pages/zustand.tsx index 64d12aa38de..b7a67e40a53 100644 --- a/e2e/next-sandbox/pages/zustand.tsx +++ b/e2e/next-sandbox/pages/zustand.tsx @@ -1,3 +1,4 @@ +import { createLiveblocksContext } from "@liveblocks/react"; import type { WithLiveblocks } from "@liveblocks/zustand"; import { liveblocks } from "@liveblocks/zustand"; import { useEffect } from "react"; @@ -16,6 +17,8 @@ import { createLiveblocksClient } from "../utils/createClient"; const client = createLiveblocksClient(); +const { useSyncStatus } = createLiveblocksContext(client); + type State = { // Presence name: string; @@ -69,6 +72,7 @@ export default function ZustandApp() { clear, liveblocks: { enterRoom, leaveRoom, isStorageLoading, room, others }, } = useStore(); + const syncStatus = useSyncStatus(); const connectionId = room?.getSelf()?.connectionId ?? 0; @@ -167,6 +171,7 @@ export default function ZustandApp() { +
diff --git a/e2e/next-sandbox/test/storage/list-with-suspense.test.ts b/e2e/next-sandbox/test/storage/list-with-suspense.test.ts index 37b8b52bfa8..3578529fe0f 100644 --- a/e2e/next-sandbox/test/storage/list-with-suspense.test.ts +++ b/e2e/next-sandbox/test/storage/list-with-suspense.test.ts @@ -44,12 +44,13 @@ test.describe("Storage w/ Suspense", () => { await waitUntilEqualOnAllPages(pages, "#items"); await page1.click("#push"); + + await waitForJson(pages, "#syncStatus", "synchronized"); await waitForJson(pages, "#numItems", 3); await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("list move", async () => { + test("list move", async () => { const [page1] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); @@ -65,12 +66,12 @@ test.describe("Storage w/ Suspense", () => { await page1.click("#move"); } + await waitForJson(pages, "#syncStatus", "synchronized"); await expectJson(page1, "#numItems", 5); await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("push conflicts", async () => { + test("push conflicts", async () => { const [page1, page2] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); @@ -80,13 +81,12 @@ test.describe("Storage w/ Suspense", () => { await page2.click("#push"); } - // await expectJson(pages, "#numItems", n => n >= 10 && n <= 20); + await waitForJson(pages, "#syncStatus", "synchronized"); await waitForJson(pages, "#numItems", 20); await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("set conflicts", async () => { + test("set conflicts", async () => { const [page1, page2] = pages; await page1.click("#clear"); await page1.click("#push"); @@ -96,20 +96,15 @@ test.describe("Storage w/ Suspense", () => { // no await to create randomness await page1.click("#set"); await page2.click("#set"); - - // In this test, we should never see a list of less than or more than - // 1 element. When this happens, we'll want to immediately fail here. - await expectJson(page1, "#numItems", 1); - await expectJson(page2, "#numItems", 1); } + await waitForJson(pages, "#syncStatus", "synchronized"); await expectJson(page1, "#numItems", 1); await expectJson(page2, "#numItems", 1); await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("fuzzy with undo/redo push delete and move", async () => { + test("fuzzy with undo/redo push delete and move", async () => { const [page1] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); @@ -138,14 +133,11 @@ test.describe("Storage w/ Suspense", () => { } else { await page.click(pickFrom(actions), { force: true }); } - - // In this test, we should never see a list of more than 1 element. When - // it happens, we'll want to immediately fail here. - await expectJson(page, "#numItems", 1); } await nanoSleep(); } + await waitForJson(pages, "#syncStatus", "synchronized"); await waitUntilEqualOnAllPages(pages, "#items"); }); }); diff --git a/e2e/next-sandbox/test/storage/list.test.ts b/e2e/next-sandbox/test/storage/list.test.ts index 7ddf7fb10f6..e63bf3ff793 100644 --- a/e2e/next-sandbox/test/storage/list.test.ts +++ b/e2e/next-sandbox/test/storage/list.test.ts @@ -83,8 +83,7 @@ test.describe("Storage - LiveList", () => { await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("set conflicts", async () => { + test("set conflicts", async () => { const [page1, page2] = pages; await page1.click("#clear"); await page1.click("#push"); @@ -94,20 +93,15 @@ test.describe("Storage - LiveList", () => { for (let i = 0; i < 30; i++) { await page1.click("#set"); await page2.click("#set"); - - // In this test, we should never see a list of less than or more than - // 1 element. When this happens, we'll want to immediately fail here. - await expectJson(page1, "#numItems", 1); - await expectJson(page2, "#numItems", 1); } + await waitForJson(pages, "#syncStatus", "synchronized"); await expectJson(page1, "#numItems", 1); await expectJson(page2, "#numItems", 1); await waitUntilEqualOnAllPages(pages, "#items"); }); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("fuzzy with undo/redo push delete and move", async () => { + test("fuzzy with undo/redo push delete and move", async () => { const [page1] = pages; await page1.click("#clear"); await waitForJson(pages, "#numItems", 0); @@ -136,14 +130,11 @@ test.describe("Storage - LiveList", () => { } else { await page.click(pickFrom(actions), { force: true }); } - - // In this test, we should never see a list of more than 1 element. When - // it happens, we'll want to immediately fail here. - await expectJson(page, "#numItems", 1); } await nanoSleep(); } + await waitForJson(pages, "#syncStatus", "synchronized"); await waitUntilEqualOnAllPages(pages, "#items"); }); }); diff --git a/e2e/next-sandbox/test/storage/map.test.ts b/e2e/next-sandbox/test/storage/map.test.ts index 46e5b6c201d..7ddc4a5c337 100644 --- a/e2e/next-sandbox/test/storage/map.test.ts +++ b/e2e/next-sandbox/test/storage/map.test.ts @@ -71,8 +71,7 @@ test.describe("Storage - LiveMap", () => { ]) ); - // TODO Definitely a bug here! - test.skip("fuzzy full w/ undo/redo", async () => { + test("fuzzy full w/ undo/redo", async () => { const [page1] = pages; await page1.click("#clear"); @@ -97,11 +96,12 @@ test.describe("Storage - LiveMap", () => { await nanoSleep(); } - // TODO Investigate: sometimes these don't converge to the same value + await waitForJson(pages, "#syncStatus", "synchronized"); await waitUntilEqualOnAllPages(pages, "#map"); // Clean up the room after the test await page1.click("#clear"); + await waitForJson(pages, "#syncStatus", "synchronized"); await waitForJson(pages, "#map", {}); }); }); diff --git a/e2e/next-sandbox/test/zustand.test.ts b/e2e/next-sandbox/test/zustand.test.ts index fd8112201bb..6f01cb94585 100644 --- a/e2e/next-sandbox/test/zustand.test.ts +++ b/e2e/next-sandbox/test/zustand.test.ts @@ -132,9 +132,11 @@ test.describe("Zustand", () => { await nanoSleep(); } + await waitForJson(pages, "#syncStatus", "synchronized"); await waitUntilEqualOnAllPages(pages, "#items"); await page1.click("#clear"); + await waitForJson(pages, "#syncStatus", "synchronized"); await waitForJson(pages, "#numItems", 0); }; } @@ -142,20 +144,12 @@ test.describe("Zustand", () => { test("fuzzy [push]", fuzzyTest(["#push"])); test("fuzzy [push, delete]", fuzzyTest(["#push", "#delete"])); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("fuzzy [push, undo]", fuzzyTest(["#push", "#undo"])); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("fuzzy [delete, undo]", fuzzyTest(["#delete", "#undo"])); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip("fuzzy [push, undo, redo]", fuzzyTest(["#push", "#undo", "#redo"])); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip( - "fuzzy [delete, undo, redo]", - fuzzyTest(["#delete", "#undo", "#redo"]) - ); + test("fuzzy [push, undo]", fuzzyTest(["#push", "#undo"])); + test("fuzzy [delete, undo]", fuzzyTest(["#delete", "#undo"])); + test("fuzzy [push, undo, redo]", fuzzyTest(["#push", "#undo", "#redo"])); + test("fuzzy [delete, undo, redo]", fuzzyTest(["#delete", "#undo", "#redo"])); - // TODO FIXME Actually fails sometimes, there definitely is a bug here - test.skip( + test( "fuzzy [push, delete, undo, redo]", fuzzyTest(["#push", "#delete", "#undo", "#redo"]) ); diff --git a/e2e/node-sandbox/test/node.test.ts b/e2e/node-sandbox/test/node.test.ts index 444fca23961..6545c043273 100644 --- a/e2e/node-sandbox/test/node.test.ts +++ b/e2e/node-sandbox/test/node.test.ts @@ -1,7 +1,7 @@ import { LiveList } from "@liveblocks/core"; import { Liveblocks } from "@liveblocks/node"; import { config } from "dotenv"; -import { describe, test, expect, onTestFinished } from "vitest"; +import { describe, test, expect, onTestFinished, vi } from "vitest"; config(); @@ -19,6 +19,7 @@ async function createRandomTestRoom(): Promise { // Register cleanup onTestFinished(async () => { + await client.deleteStorageDocument(randomRoomId); await client.deleteRoom(randomRoomId); }); @@ -35,27 +36,77 @@ describe("@liveblocks/node package e2e", () => { test("storage mutation should work in node environment", async () => { const roomId = await createRandomTestRoom(); - // delete existing data in the room - await expect( - client.mutateStorage(roomId, ({ root }) => { - root.delete("z"); - }) - ).resolves.toBeUndefined(); - - // add data to the room - await expect( - client.mutateStorage(roomId, ({ root }) => { - expect(root.toImmutable()).toEqual({}); - // Mutate it! - root.set("z", new LiveList([1, 2, 3])); - }) - ).resolves.toBeUndefined(); - - // add data to the room - await expect( - client.mutateStorage(roomId, ({ root }) => { - expect(root.toImmutable()).toEqual({ z: [1, 2, 3] }); - }) - ).resolves.toBeUndefined(); + const fn = vi.fn(); + + // Delete existing data in the room + await client.mutateStorage(roomId, ({ root }) => { + fn(); + root.delete("z"); + }); + expect(fn).toHaveBeenCalledTimes(1); + fn.mockReset(); + + // Ensure the initial state is empty + expect(await client.getStorageDocument(roomId, "json")).toEqual({}); + + // Add data to the room + await client.mutateStorage(roomId, ({ root }) => { + fn(); + root.set("z", new LiveList([1, 2, 3])); + }); + expect(fn).toHaveBeenCalledTimes(1); + fn.mockReset(); + + // The GET endpoint should now also match this expected state + expect(await client.getStorageDocument(roomId, "json")).toEqual({ + z: [1, 2, 3], + }); }); + + test( + "concurrent LiveList mutations should preserve all items", + { timeout: 30000 }, + async () => { + const numberOfItemsToInsert = 24; + const roomId = await createRandomTestRoom(); + + // Initialize storage with empty list + await client.mutateStorage(roomId, ({ root }) => { + root.set("list", new LiveList([])); + }); + + // Verify base state is sound + expect(await client.getStorageDocument(roomId, "json")).toEqual({ + list: [], + }); + + const localTally = new Set(); + + // Perform concurrent mutations + async function pushOne(index: number): Promise { + await client.mutateStorage(roomId, ({ root }) => { + localTally.add(index); + const list = root.get("list") as LiveList; + list.push(index); + }); + } + + const mutations = Array.from({ length: numberOfItemsToInsert }, (_, i) => + pushOne(i) + ); + + // Wait until all mutations have run + await Promise.allSettled(mutations); + + // Verify results + const actualList = (await client.getStorageDocument(roomId, "json")) + .list as number[]; + const actualUniqueItems = new Set(actualList); + + // All items should be present in the list + expect(localTally.size).toBe(numberOfItemsToInsert); + expect(actualList.length).toBe(numberOfItemsToInsert); + expect(actualUniqueItems.size).toBe(numberOfItemsToInsert); + } + ); }); diff --git a/shared/vitest-config/index.js b/shared/vitest-config/index.js index c1dbbc91a16..cf29fb52ed1 100644 --- a/shared/vitest-config/index.js +++ b/shared/vitest-config/index.js @@ -8,7 +8,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export function defaultLiveblocksVitestConfig(options = {}) { return defineConfig({ - plugins: [tsconfigPaths()], + plugins: [ + tsconfigPaths({ + projects: ["./tsconfig.json"], + }), + ], test: { setupFiles: [path.join(__dirname, "setup.js")], From ed489e5bcf0c85ed5802dab982476f09c9611a12 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Fri, 15 Aug 2025 13:53:23 +0200 Subject: [PATCH 2/2] Improve docs for `LiveList.delete()` semantics (#2603) --- CLAUDE.md | 4 ++++ docs/pages/api-reference/liveblocks-client.mdx | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 535924fcb2c..a040503421b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,3 +46,7 @@ When running scripts, use `npx turbo`, not `npm`. Run e2e tests headlessly using Playwright: npx turbo build && env HEADLESS=1 playwright test --retries=5 -- + +# Documentation + +All documentation lives in the `docs/` directory, as Markdown files. diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index c20723a060e..8b361f48081 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -4480,17 +4480,26 @@ list.toImmutable(); ### delete [#LiveList.delete] -Deletes an element at the specified index. If the index doesn’t exist, an -`Error` is thrown. +Deletes the element living at the specified index locally. If the index doesn't +exist, an `Error` is thrown. ```ts const list = new LiveList(["adrien", "jonathan"]); -list.delete(1); +list.delete(0); -// ["adrien"] +// ["jonathan"] list.toImmutable(); ``` +This operation uses ID-based semantics, not position-based. When called, it +reads the item at the specified index from the local state, then sends a "delete +item with ID X" instruction to the server. + +If clients A and B both see a LiveList containing `["foo", "bar"]`, and client A +calls `.insert("qux", 0)`, while client B simultaneously calls `.delete(0)`, the +end result will always be `["qux", "bar"]` on both clients, and never +`["foo", "bar"]`. + _Nothing_