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_
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")],