diff --git a/CHANGELOG_PUBLIC.md b/CHANGELOG_PUBLIC.md
index 0b37d3d187..c2eafec0f5 100644
--- a/CHANGELOG_PUBLIC.md
+++ b/CHANGELOG_PUBLIC.md
@@ -18,6 +18,56 @@ list and feel free to give them credit at the end of a line, e.g.:
-->
+# Week 10 (2026-03-06)
+
+## v3.15.0
+
+### `@liveblocks/react-ui`
+
+- Add various new ways to customize `Thread` and `Comment`:
+ - Comments in `Thread` can now be overridden or customized via the
+ `components` prop.
+ - New parts of `Comment` (content, avatar, author, and date) can now be
+ overridden or customized via the `children`, `additionalContent`, `avatar`,
+ `author`, and `date` props.
+- Fix `commentDropdownItems` prop on `Thread` not working as expected in some
+ cases.
+
+### `@liveblocks/react`
+
+- Each `createRoomContext()` invocation now creates its own isolated context to
+ allow nesting independent room contexts and their `RoomProvider` components.
+
+### `@liveblocks/react-blocknote`
+
+- Support newer BlockNote versions and bump the minimum required version to
+ v0.43.0. (Thanks @nperez0111 for the contribution!)
+
+### `@liveblocks/react-ui`, `@liveblocks/react-tiptap`, and `@liveblocks/react-lexical`
+
+- Improve how inline components passed to `components={{ ... }}` props are
+ handled by keeping them stable instead of re-mounting them on every render.
+- Move `@radix-ui/*` dependencies to the `radix-ui` mono package.
+
+## Examples
+
+- New example: [AG Grid Comments](https://liveblocks.io/examples/ag-grid-comments/nextjs-comments-ag-grid).
+- Update old examples to use new presence and commenting components.
+
+## Documentation
+
+- New quickstart: [Draggable comments with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-canvas).
+- New quickstart: [Commenting inside AG Grid with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-ag-grid).
+- New quickstart: [Commenting inside a table with Next.js](https://liveblocks.io/docs/get-started/nextjs-comments-table).
+- New quickstart: [Realtime avatar and cursor presence with Next.js](https://liveblocks.io/docs/get-started/nextjs-presence).
+- New guide: [How to add users to Liveblocks presence components](https://liveblocks.io/docs/guides/how-to-add-users-to-liveblocks-presence-components).
+- Mention `sk_localdev` and `pk_localdev` keys more explicitly in dev server docs.
+- Mention `["comments:write"]` permission under authentication.
+
+## Contributors
+
+nperez0111, marcbouchenoire, ctnicholas
+
# Week 9 (2026-02-27)
## v3.14.1
diff --git a/docs/pages/get-started/nextjs-presence.mdx b/docs/pages/get-started/nextjs-presence.mdx
index e3cc5719d5..297aec0550 100644
--- a/docs/pages/get-started/nextjs-presence.mdx
+++ b/docs/pages/get-started/nextjs-presence.mdx
@@ -162,7 +162,7 @@ the [`@liveblocks/react`](/docs/api-reference/liveblocks-react) package.
add each user's name and avatar.
-
+
Set up authentication and add user information
diff --git a/examples/nextjs-comments-ag-grid/app/CommentCell.tsx b/examples/nextjs-comments-ag-grid/app/CommentCell.tsx
index 9e4aa2e547..a8c2529d7c 100644
--- a/examples/nextjs-comments-ag-grid/app/CommentCell.tsx
+++ b/examples/nextjs-comments-ag-grid/app/CommentCell.tsx
@@ -1,15 +1,30 @@
"use client";
import {
- Comment,
+ CommentPin,
FloatingComposer,
FloatingThread,
+ Icon,
} from "@liveblocks/react-ui";
+import { useSelf } from "@liveblocks/react";
import { CustomCellRendererProps } from "ag-grid-react";
import { useCellThread } from "./CellThreadContext";
+import { CSSProperties, useState } from "react";
+
+const COMMENT_PIN_SIZE = 24;
+
+const commentPinStyle = {
+ "--lb-comment-pin-padding": "3px",
+ width: COMMENT_PIN_SIZE,
+ height: COMMENT_PIN_SIZE,
+ cursor: "pointer",
+ marginTop: 3,
+} as CSSProperties;
export function CommentCell(params: CustomCellRendererProps) {
const { threads, openCell, setOpenCell } = useCellThread();
+ const currentUserId = useSelf((self) => self.id) ?? undefined;
+ const [isComposerOpen, setIsComposerOpen] = useState(false);
const rowId = params.data?.id;
const columnId = params.colDef?.field;
@@ -37,8 +52,7 @@ export function CommentCell(params: CustomCellRendererProps) {
style={{
display: "flex",
alignItems: "center",
- justifyContent: "space-between",
- gap: 16,
+ gap: 12,
}}
>
{/* Cell contents */}
@@ -46,26 +60,25 @@ export function CommentCell(params: CustomCellRendererProps) {
{/* Show thread if it exists, otherwise show thread composer (plus on hover) */}
{!thread ? (
-
+
setOpenCell(metadata)}
+ onOpenChange={setIsComposerOpen}
style={{ zIndex: 10 }}
>
-
-
-
-
-
+
+ {!isComposerOpen ? (
+
+ ) : null}
+
) : (
@@ -81,13 +94,9 @@ export function CommentCell(params: CustomCellRendererProps) {
style={{ zIndex: 10 }}
autoFocus
>
-
diff --git a/examples/nextjs-comments-ag-grid/app/globals.css b/examples/nextjs-comments-ag-grid/app/globals.css
index 177ce92c58..f97db037d3 100644
--- a/examples/nextjs-comments-ag-grid/app/globals.css
+++ b/examples/nextjs-comments-ag-grid/app/globals.css
@@ -34,30 +34,16 @@ button {
--lb-accent: var(--ag-accent-color, #2196f3);
}
-/* Comment cell: plus trigger is hidden until row/cell is hovered */
+/* Hover to show "+" CommentPin icon */
.comment-cell-trigger {
opacity: 0;
transition: opacity 0.15s ease;
}
-.comment-cell:hover .comment-cell-trigger {
+.comment-cell:hover .comment-cell-trigger,
+.comment-cell-trigger[data-open] {
opacity: 1;
}
-/* Style the plus (add comment) trigger here */
-.comment-cell-trigger button {
- border-radius: 100%;
- background: #ddf0ff;
- padding-top: 0px;
- appearance: none;
- height: 28px;
- width: 28px;
- border: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #111c26;
-}
-
.lb-portal {
z-index: 10;
}
diff --git a/examples/nextjs-comments-ag-grid/package-lock.json b/examples/nextjs-comments-ag-grid/package-lock.json
index 5525f3cf47..b41a9adc43 100644
--- a/examples/nextjs-comments-ag-grid/package-lock.json
+++ b/examples/nextjs-comments-ag-grid/package-lock.json
@@ -7,10 +7,10 @@
"name": "@liveblocks-examples/nextjs-comments-ag-grid",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/client": "^3.15.0-rc1",
- "@liveblocks/node": "^3.15.0-rc1",
- "@liveblocks/react": "^3.15.0-rc1",
- "@liveblocks/react-ui": "^3.15.0-rc1",
+ "@liveblocks/client": "^3.15.1-rc1",
+ "@liveblocks/node": "^3.15.1-rc1",
+ "@liveblocks/react": "^3.15.1-rc1",
+ "@liveblocks/react-ui": "^3.15.1-rc1",
"ag-grid-community": "^35.1.0",
"ag-grid-react": "^35.1.0",
"next": "^16.1.6",
@@ -545,43 +545,43 @@
"license": "Apache-2.0"
},
"node_modules/@liveblocks/client": {
- "version": "3.15.0-rc1",
- "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.15.0-rc1.tgz",
- "integrity": "sha512-SmMpegEmd4XWbPs9CRDniv3GNVkGjXO1FlpVhCP4xZMLCtyWLTMdAIfxgwIRzRHhWQKzSn0W4XryXWNN/Hknzg==",
+ "version": "3.15.1-rc1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.15.1-rc1.tgz",
+ "integrity": "sha512-HJJ9QzEsc/KEZPF2MpVWhJ4/mmJzDyy2AC+9eqB9kL79krQII7RNYBfxQLobDnl2zwmNQsENnP8dlmzRGDhZxg==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/core": "3.15.0-rc1"
+ "@liveblocks/core": "3.15.1-rc1"
}
},
"node_modules/@liveblocks/core": {
- "version": "3.15.0-rc1",
- "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.15.0-rc1.tgz",
- "integrity": "sha512-B88HJ974l0V1oteSH+fePTGCx3jUHvU2CAEB+pKGQjV4SOr6XcHJkLd5kRULVGLDhl6xBveqzxHhH7UXRK+MHg==",
+ "version": "3.15.1-rc1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.15.1-rc1.tgz",
+ "integrity": "sha512-ha4atG61rGxlhRUJMnIrdam4PwZ3C0imkHRp+/Lbnb6TkLUJzcmCoHZcp2igVbOfQDrVG+P7wJn8b6JUVRNaVw==",
"license": "Apache-2.0",
"peerDependencies": {
"@types/json-schema": "^7"
}
},
"node_modules/@liveblocks/node": {
- "version": "3.15.0-rc1",
- "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.15.0-rc1.tgz",
- "integrity": "sha512-hc52BJVkX9nXvtTnqYhSD0H/y4a5p5MSyC0LFpDT061SDQixxZZp5vfHbOT61knhQGE9Q3FoTVliGqUrYPczMA==",
+ "version": "3.15.1-rc1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.15.1-rc1.tgz",
+ "integrity": "sha512-ELKE7qyMT4boYJ0wAWwQxOKX+qzV56cBFaMo7MimL0fimbzyxkQAcxNYw0TksZ4UvspwuO+5s04Qap9py+s9tA==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/core": "3.15.0-rc1",
+ "@liveblocks/core": "3.15.1-rc1",
"@stablelib/base64": "^1.0.1",
"fast-sha256": "^1.3.0",
"node-fetch": "^2.6.1"
}
},
"node_modules/@liveblocks/react": {
- "version": "3.15.0-rc1",
- "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.15.0-rc1.tgz",
- "integrity": "sha512-F3X3QTH896Kj+ohRjWjbETb82dIYDfApJEsoU5ruwF9Omx/hn9A/PYdb8PQ/vNhVt2R7FtesN/mnA3pJugyq7w==",
+ "version": "3.15.1-rc1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.15.1-rc1.tgz",
+ "integrity": "sha512-vVRka7bhT5VMAGhTcdWJR03w3+mUO7OhIxkDQ9MQ9ClQO0weUQq9AVR2Zgt5DxOQ56LGAZ01bGPh+96/hU50aw==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/client": "3.15.0-rc1",
- "@liveblocks/core": "3.15.0-rc1"
+ "@liveblocks/client": "3.15.1-rc1",
+ "@liveblocks/core": "3.15.1-rc1"
},
"peerDependencies": {
"@types/react": "*",
@@ -598,15 +598,15 @@
}
},
"node_modules/@liveblocks/react-ui": {
- "version": "3.15.0-rc1",
- "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.15.0-rc1.tgz",
- "integrity": "sha512-qX7FMROa/vMbjNriWvOFKo3uUAGfCeHssiMeCegkR7CpJNFAL0pUiAZMkrzs5Vf3/AyU9JaKkhu9FujavJlhRA==",
+ "version": "3.15.1-rc1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.15.1-rc1.tgz",
+ "integrity": "sha512-+jQdsHcwsypFc77amDPjy2Q0xzV4qpZfJmf5wsqWrMYAFKoKS3UouJh/VE1Vnyjh+in0DvVha0yP/MrqA7iBLA==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/react-dom": "^2.1.0",
- "@liveblocks/client": "3.15.0-rc1",
- "@liveblocks/core": "3.15.0-rc1",
- "@liveblocks/react": "3.15.0-rc1",
+ "@liveblocks/client": "3.15.1-rc1",
+ "@liveblocks/core": "3.15.1-rc1",
+ "@liveblocks/react": "3.15.1-rc1",
"frimousse": "^0.2.0",
"marked": "^15.0.11",
"radix-ui": "^1.4.0",
diff --git a/examples/nextjs-comments-ag-grid/package.json b/examples/nextjs-comments-ag-grid/package.json
index 75e0c70978..c60f8b132c 100644
--- a/examples/nextjs-comments-ag-grid/package.json
+++ b/examples/nextjs-comments-ag-grid/package.json
@@ -9,10 +9,10 @@
"start": "next start"
},
"dependencies": {
- "@liveblocks/client": "^3.15.0-rc1",
- "@liveblocks/node": "^3.15.0-rc1",
- "@liveblocks/react": "^3.15.0-rc1",
- "@liveblocks/react-ui": "^3.15.0-rc1",
+ "@liveblocks/client": "^3.15.1-rc1",
+ "@liveblocks/node": "^3.15.1-rc1",
+ "@liveblocks/react": "^3.15.1-rc1",
+ "@liveblocks/react-ui": "^3.15.1-rc1",
"ag-grid-community": "^35.1.0",
"ag-grid-react": "^35.1.0",
"next": "^16.1.6",
diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json
index 45719b73fe..dd361f1c4f 100644
--- a/packages/liveblocks-core/package.json
+++ b/packages/liveblocks-core/package.json
@@ -37,8 +37,8 @@
"format": "(eslint --fix src/ e2e/ || true) && prettier --write src/ e2e/",
"lint": "eslint src/",
"lint:package": "publint --strict && attw --pack",
- "test": "NODE_OPTIONS=\"--no-deprecation\" vitest run",
- "test:ci": "NODE_OPTIONS=\"--no-deprecation\" vitest run",
+ "test": "npx liveblocks dev -c 'vitest run --coverage'",
+ "test:ci": "vitest run",
"test:types": "ls test-d/* | xargs -n1 tsd --files",
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest",
"test:e2e": "npx liveblocks dev -p 1154 -c 'vitest run --config=./vitest.config.e2e.ts'",
diff --git a/packages/liveblocks-core/src/__tests__/_behaviors.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts
similarity index 98%
rename from packages/liveblocks-core/src/__tests__/_behaviors.ts
rename to packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts
index fbfa51f0dd..17d87d7206 100644
--- a/packages/liveblocks-core/src/__tests__/_behaviors.ts
+++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.behaviors.ts
@@ -18,7 +18,11 @@ import { ServerMsgCode } from "../protocol/ServerMsg";
import type { WebsocketCloseCodes } from "../types/IWebSocket";
import type { MockWebSocket } from "./_MockWebSocketServer";
import { MockWebSocketServer } from "./_MockWebSocketServer";
-import { makeAccessToken, makeIDToken, serverMessage } from "./_utils";
+import {
+ makeAccessToken,
+ makeIDToken,
+ serverMessage,
+} from "./_MockWebSocketServer.setup";
type AuthBehavior = () => AuthValue;
type SocketBehavior = (wss: MockWebSocketServer) => MockWebSocket;
diff --git a/packages/liveblocks-core/src/__tests__/_utils.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts
similarity index 95%
rename from packages/liveblocks-core/src/__tests__/_utils.ts
rename to packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts
index 8eb6caf9d9..fc3e64c539 100644
--- a/packages/liveblocks-core/src/__tests__/_utils.ts
+++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.setup.ts
@@ -1,3 +1,11 @@
+/**
+ * MockWebSocket-based test utilities for testing edge cases that cannot be
+ * tested against the real dev server: connection state machine (auth,
+ * reconnection, backoff, close codes), wire protocol message inspection,
+ * and CRDT conflict resolution with precise op injection.
+ *
+ * For normal storage/presence/history tests, use `_devserver.ts`.
+ */
import { expect, onTestFinished } from "vitest";
import { createApiClient } from "../api-client";
@@ -34,13 +42,13 @@ import type { Room, RoomConfig, RoomDelegates, SyncSource } from "../room";
import { createRoom } from "../room";
import { WebsocketCloseCodes } from "../types/IWebSocket";
import type { LiveblocksError } from "../types/LiveblocksError";
+import type { MockWebSocketServer } from "./_MockWebSocketServer";
+import { MockWebSocket } from "./_MockWebSocketServer";
import {
ALWAYS_AUTH_WITH_ACCESS_TOKEN,
defineBehavior,
SOCKET_AUTOCONNECT_AND_ROOM_STATE,
-} from "./_behaviors";
-import type { MockWebSocketServer } from "./_MockWebSocketServer";
-import { MockWebSocket } from "./_MockWebSocketServer";
+} from "./_MockWebSocketServer.behaviors";
import type { JsonStorageUpdate } from "./_updatesUtils";
import { serializeUpdateToJson } from "./_updatesUtils";
@@ -527,43 +535,6 @@ export async function prepareStorageUpdateTest<
};
}
-/**
- * Create a room, join with the client but sync local storage changes with the server
- */
-export async function prepareDisconnectedStorageUpdateTest<
- S extends LsonObject,
- P extends JsonObject = never,
- U extends BaseUserMeta = never,
- E extends Json = never,
- TM extends BaseMetadata = never,
- CM extends BaseMetadata = never,
->(items: StorageNode[]) {
- const { storage, room } = await prepareRoomWithStorage
(
- items,
- -1
- );
-
- const receivedUpdates: JsonStorageUpdate[][] = [];
-
- onTestFinished(
- room.subscribe(
- storage.root,
- (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)),
- { isDeep: true }
- )
- );
-
- function expectUpdates(updates: JsonStorageUpdate[][]) {
- expect(receivedUpdates).toEqual(updates);
- }
-
- return {
- room,
- root: storage.root,
- expectUpdates,
- };
-}
-
export function replaceRemoteStorageAndReconnect(
wss: MockWebSocketServer,
nextStorageItems: StorageNode[]
diff --git a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts
index b863febc54..3bed439a36 100644
--- a/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts
+++ b/packages/liveblocks-core/src/__tests__/_MockWebSocketServer.ts
@@ -1,3 +1,10 @@
+/**
+ * Mock WebSocket server for testing connection edge cases.
+ *
+ * Used by room.mockserver.test.ts and *.mockserver.test.ts for scenarios
+ * requiring low-level socket control. Normal tests use the real dev server
+ * instead. See `_devserver.ts` for dev-server-based test helpers.
+ */
import type { EventSource, Observable } from "../lib/EventSource";
import { makeEventSource } from "../lib/EventSource";
import type { Json } from "../lib/Json";
diff --git a/packages/liveblocks-core/src/__tests__/_devserver.ts b/packages/liveblocks-core/src/__tests__/_devserver.ts
new file mode 100644
index 0000000000..0409662d48
--- /dev/null
+++ b/packages/liveblocks-core/src/__tests__/_devserver.ts
@@ -0,0 +1,340 @@
+/**
+ * Test utilities for running @liveblocks/core unit tests against the real
+ * local dev server at localhost:1154.
+ */
+import { expect, onTestFinished, vi } from "vitest";
+
+import { createClient } from "../client";
+import type { LsonObject } from "../crdts/Lson";
+import type { ToImmutable } from "../crdts/utils";
+import { kInternal } from "../internal";
+import type { JsonObject } from "../lib/Json";
+import { nanoid } from "../lib/nanoid";
+import type { PlainLsonObject } from "../types/PlainLson";
+import type { JsonStorageUpdate } from "./_updatesUtils";
+import { serializeUpdateToJson } from "./_updatesUtils";
+
+const DEV_SERVER = "http://localhost:1154";
+
+export function randomRoomId(): string {
+ return `room-${nanoid()}`;
+}
+
+/**
+ * Creates a room on the dev server and initializes its storage.
+ * Returns the room ID, which can be passed to enterAndConnect().
+ */
+export async function initRoom(storage?: PlainLsonObject): Promise {
+ const roomId = randomRoomId();
+
+ // Create the room
+ await fetch(`${DEV_SERVER}/v2/rooms`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id: roomId }),
+ });
+
+ // Initialize its storage
+ if (storage) {
+ await fetch(
+ `${DEV_SERVER}/v2/rooms/${encodeURIComponent(roomId)}/storage`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(storage),
+ }
+ );
+ }
+ return roomId;
+}
+
+async function UNSAFE_generateAccessToken(
+ roomId?: string,
+ permissions?: string[]
+) {
+ const res = await fetch(`${DEV_SERVER}/v2/authorize-user`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ userId: `user-${nanoid()}`,
+ userInfo: { name: "Testy McTester" },
+ permissions: { [roomId!]: permissions ?? ["room:write"] },
+ }),
+ });
+ return (await res.json()) as { token: string };
+}
+
+export function createTestClient(permissions?: string[]) {
+ return createClient({
+ baseUrl: DEV_SERVER,
+ authEndpoint: (roomId) => UNSAFE_generateAccessToken(roomId, permissions),
+ polyfills: { WebSocket: globalThis.WebSocket },
+ __DANGEROUSLY_disableThrottling: true,
+ });
+}
+
+/**
+ * Enters a room, waits for "connected" status, and registers cleanup
+ * via onTestFinished.
+ */
+export async function enterAndConnect(
+ roomId: string,
+ opts?: { initialStorage?: S; permissions?: string[] }
+) {
+ const client = createTestClient(opts?.permissions);
+ const { room, leave } = client.enterRoom(
+ roomId,
+ // @ts-expect-error Test helper passes options directly
+ {
+ initialPresence: {},
+ initialStorage: (opts?.initialStorage ?? {}) as S,
+ }
+ );
+
+ onTestFinished(leave);
+
+ await vi.waitUntil(() => room.getStatus() === "connected");
+ return { room, leave };
+}
+
+/**
+ * Like enterAndConnect, but also calls getStorage() before returning.
+ * Useful for parallelizing connection + storage fetch across multiple clients.
+ */
+export async function enterConnectAndGetStorage(
+ roomId: string,
+ opts?: { permissions?: string[] }
+) {
+ const { room, leave } = await enterAndConnect(roomId, opts);
+ const storage = await room.getStorage();
+ return { room, leave, storage };
+}
+
+/**
+ * Atomically replaces a room's storage on the dev server and disconnects all
+ * clients, forcing them to reconnect and reconcile with the new storage.
+ *
+ * TODO: This does not exist yet in the dev server.
+ * See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature
+ *
+ * The dev server needs a new endpoint (e.g. POST /v2/rooms/{roomId}/storage
+ * with force-replace semantics) that:
+ * 1. Replaces the room's storage with the provided PlainLsonObject
+ * 2. Disconnects all connected clients in that room
+ * 3. Clients reconnect automatically and receive the new storage
+ * 4. The client reconciles the diff and fires subscription callbacks
+ */
+export async function replaceStorageAndReconnectDevServer(
+ roomId: string,
+ newStorage: PlainLsonObject
+): Promise {
+ await fetch(`${DEV_SERVER}/v2/rooms/${encodeURIComponent(roomId)}/storage`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(newStorage),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// High-level test helpers
+// ---------------------------------------------------------------------------
+
+function deepCloneWithoutOpId(item: T) {
+ return JSON.parse(
+ JSON.stringify(item),
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ (key, value) => (key === "opId" ? undefined : value)
+ ) as T;
+}
+
+/**
+ * Two clients (A and B) connected to the same room via the real dev server.
+ *
+ * Returns { roomA, roomB, storageA, storageB, expectStorage, assertUndoRedo }.
+ *
+ * - expectStorage(data) is async: asserts client A's storage equals `data`,
+ * then waits for client B to sync to the same state.
+ * - assertUndoRedo() is async: walks the full undo/redo stack on client A,
+ * verifying client B stays in sync at each step.
+ */
+export async function prepareStorageTest(
+ initialStorage: PlainLsonObject
+) {
+ const roomId = await initRoom(initialStorage);
+
+ const [clientA, clientB] = await Promise.all([
+ enterConnectAndGetStorage(roomId),
+ enterConnectAndGetStorage(roomId),
+ ]);
+
+ const storageA = clientA.storage;
+ const storageB = clientB.storage;
+
+ // Wait for both clients to have synced initial storage
+ await vi.waitFor(() => {
+ expect(storageA.root.toImmutable()).toEqual(storageB.root.toImmutable());
+ });
+
+ const states: ToImmutable[] = [];
+
+ async function expectBothClientStoragesToEqual(data: ToImmutable) {
+ expect(storageA.root.toImmutable()).toEqual(data);
+
+ await vi.waitFor(() => {
+ expect(storageB.root.toImmutable()).toEqual(data);
+ });
+
+ expect(clientA.room[kInternal].nodeCount).toBe(
+ clientB.room[kInternal].nodeCount
+ );
+ }
+
+ async function expectStorage(data: ToImmutable) {
+ states.push(data);
+ await expectBothClientStoragesToEqual(data);
+ }
+
+ async function assertUndoRedo() {
+ const before = deepCloneWithoutOpId(
+ clientA.room[kInternal].undoStack[
+ clientA.room[kInternal].undoStack.length - 1
+ ]
+ );
+
+ // Undo the whole stack
+ for (let i = 0; i < states.length - 1; i++) {
+ clientA.room.history.undo();
+ await expectBothClientStoragesToEqual(states[states.length - 2 - i]);
+ }
+
+ // Redo the whole stack
+ for (let i = 0; i < states.length - 1; i++) {
+ clientA.room.history.redo();
+ await expectBothClientStoragesToEqual(states[i + 1]);
+ }
+
+ const after = deepCloneWithoutOpId(
+ clientA.room[kInternal].undoStack[
+ clientA.room[kInternal].undoStack.length - 1
+ ]
+ );
+
+ // It should be identical before/after
+ expect(before).toEqual(after);
+
+ // Undo everything again
+ for (let i = 0; i < states.length - 1; i++) {
+ clientA.room.history.undo();
+ await expectBothClientStoragesToEqual(states[states.length - 2 - i]);
+ }
+ }
+
+ return {
+ roomA: clientA.room,
+ roomB: clientB.room,
+ storageA,
+ storageB,
+ expectStorage,
+ assertUndoRedo,
+ };
+}
+
+/**
+ * Single client connected to a room via the real dev server.
+ *
+ * Returns { root, room, expectStorage }.
+ *
+ * - expectStorage(data) is synchronous: just asserts the single client's
+ * storage equals `data` (no second client to wait for).
+ */
+export async function prepareIsolatedStorageTest(
+ initialStorage?: PlainLsonObject,
+ opts?: { permissions?: string[] }
+) {
+ const roomId = initialStorage
+ ? await initRoom(initialStorage)
+ : randomRoomId();
+
+ const { room, storage } = await enterConnectAndGetStorage(roomId, {
+ permissions: opts?.permissions,
+ });
+
+ function expectStorage(data: ToImmutable) {
+ expect(storage.root.toImmutable()).toEqual(data);
+ }
+
+ return {
+ root: storage.root,
+ room,
+ expectStorage,
+ };
+}
+
+/**
+ * Two clients (A and B) connected to the same room via the real dev server,
+ * both subscribed to storageBatch events.
+ *
+ * Returns { roomA, roomB, rootA, expectUpdates }.
+ *
+ * - expectUpdates(updates) is async: asserts client A received the expected
+ * update batches, then waits for client B to receive the same.
+ */
+export async function prepareStorageUpdateTest(
+ initialStorage: PlainLsonObject
+) {
+ const roomId = await initRoom(initialStorage);
+
+ const [clientA, clientB] = await Promise.all([
+ enterConnectAndGetStorage(roomId),
+ enterConnectAndGetStorage(roomId),
+ ]);
+
+ const storageA = clientA.storage;
+ const storageB = clientB.storage;
+
+ // Wait for both clients to have synced initial storage
+ await vi.waitFor(() => {
+ expect(storageA.root.toImmutable()).toEqual(storageB.root.toImmutable());
+ });
+
+ const updatesA: JsonStorageUpdate[][] = [];
+ const updatesB: JsonStorageUpdate[][] = [];
+
+ onTestFinished(
+ clientA.room.events.storageBatch.subscribe((updates) =>
+ updatesA.push(updates.map(serializeUpdateToJson))
+ )
+ );
+ onTestFinished(
+ clientB.room.events.storageBatch.subscribe((updates) =>
+ updatesB.push(updates.map(serializeUpdateToJson))
+ )
+ );
+
+ async function expectUpdates(updates: JsonStorageUpdate[][]) {
+ expect(updatesA).toEqual(updates);
+
+ await vi.waitFor(() => {
+ expect(updatesB).toEqual(updates);
+ });
+ }
+
+ return {
+ roomA: clientA.room,
+ roomB: clientB.room,
+ rootA: storageA.root,
+ expectUpdates,
+ };
+}
diff --git a/packages/liveblocks-core/src/__tests__/_waitUtils.ts b/packages/liveblocks-core/src/__tests__/_waitUtils.ts
index fd70be44be..63151a91ba 100644
--- a/packages/liveblocks-core/src/__tests__/_waitUtils.ts
+++ b/packages/liveblocks-core/src/__tests__/_waitUtils.ts
@@ -1,25 +1,7 @@
import type { Status } from "../connection";
-import { wait, withTimeout } from "../lib/utils";
+import { withTimeout } from "../lib/utils";
import type { OpaqueRoom } from "../room";
-export async function waitFor(predicate: () => boolean): Promise {
- const result = predicate();
- if (result) {
- return;
- }
-
- const time = new Date().getTime();
-
- while (new Date().getTime() - time < 2000) {
- await wait(100);
- if (predicate()) {
- return;
- }
- }
-
- throw new Error("TIMEOUT");
-}
-
/**
* Handy helper that allows to pause test execution until the room has
* asynchronously reached a particular status. Status must be reached within
diff --git a/packages/liveblocks-core/src/__tests__/connection.test.ts b/packages/liveblocks-core/src/__tests__/connection.test.ts
index b5fb101fb7..62be6d1d6f 100644
--- a/packages/liveblocks-core/src/__tests__/connection.test.ts
+++ b/packages/liveblocks-core/src/__tests__/connection.test.ts
@@ -8,7 +8,7 @@ import {
SOCKET_AUTOCONNECT_AND_ROOM_STATE,
SOCKET_AUTOCONNECT_BUT_NO_ROOM_STATE,
SOCKET_NO_BEHAVIOR,
-} from "./_behaviors";
+} from "./_MockWebSocketServer.behaviors";
describe("ManagedSocket", () => {
test("failure to authenticate", async () => {
diff --git a/packages/liveblocks-core/src/__tests__/immutable.test.ts b/packages/liveblocks-core/src/__tests__/immutable.test.ts
index 51ae278f28..2eef4a42b2 100644
--- a/packages/liveblocks-core/src/__tests__/immutable.test.ts
+++ b/packages/liveblocks-core/src/__tests__/immutable.test.ts
@@ -22,107 +22,83 @@ import {
} from "../immutable";
import { kInternal } from "../internal";
import * as console from "../lib/fancy-console";
-import type { Json, JsonObject } from "../lib/Json";
-import type { BaseUserMeta } from "../protocol/BaseUserMeta";
-import { ClientMsgCode } from "../protocol/ClientMsg";
-import type { BaseMetadata } from "../protocol/Comments";
-import { ServerMsgCode } from "../protocol/ServerMsg";
-import type { StorageNode } from "../protocol/StorageNode";
-import {
- createSerializedList,
- createSerializedObject,
- createSerializedRegister,
- createSerializedRoot,
- FIRST_POSITION,
- FOURTH_POSITION,
- parseAsClientMsgs,
- prepareRoomWithStorage,
- SECOND_POSITION,
- serverMessage,
- THIRD_POSITION,
-} from "./_utils";
-
-export async function prepareStorageImmutableTest<
- S extends LsonObject,
- P extends JsonObject = never,
- U extends BaseUserMeta = never,
- E extends Json = never,
- TM extends BaseMetadata = never,
- CM extends BaseMetadata = never,
->(items: StorageNode[], actor: number = 0) {
- let state = {} as ToJson;
- let refState = {} as ToJson;
-
- let totalStorageOps = 0;
-
- const ref = await prepareRoomWithStorage (items, -1);
-
- const subject = await prepareRoomWithStorage
(
- items,
- actor
- );
-
- onTestFinished(
- subject.wss.onReceive.subscribe((data) => {
- const messages = parseAsClientMsgs(data);
- for (const message of messages) {
- if (message.type === ClientMsgCode.UPDATE_STORAGE) {
- totalStorageOps += message.ops.length;
- ref.wss.last.send(
- serverMessage({
- type: ServerMsgCode.UPDATE_STORAGE,
- ops: message.ops,
- })
- );
- subject.wss.last.send(
- serverMessage({
- type: ServerMsgCode.UPDATE_STORAGE,
- ops: message.ops,
- })
- );
- }
- }
- })
- );
+import type { JsonObject } from "../lib/Json";
+import type { PlainLsonObject } from "../types/PlainLson";
+import { enterConnectAndGetStorage, initRoom } from "./_devserver";
+
+/**
+ * Sets up two real clients (A and B) connected to the same room via the dev
+ * server, with storage initialized from `initialStorage`.
+ *
+ * Returns:
+ * - `storage` — client A's storage root (the "active" client that tests
+ * mutate via `patchLiveObjectKey` / `patchLiveObject`)
+ * - `refStorage` — client B's storage root (the "reference" client that
+ * passively receives changes via server-mediated sync)
+ * - `state` — a plain-JSON mirror of client A's storage, shared by reference
+ * with the caller. Tests mutate this object directly (e.g.
+ * `state.foo = "bar"`) and then call `patchLiveObjectKey` to propagate
+ * the diff into the live CRDT tree.
+ * - `expectStorageAndState(data, itemsCount?)` — asserts that both clients'
+ * storage equals `data`, that the CRDT node count matches `itemsCount`
+ * (if provided), and that the JSON `state` mirror was kept in sync by
+ * the `storageBatch` subscription.
+ * - `expectStorage(data)` — lighter variant that only checks storage equality
+ * across both clients (no node count or state mirror checks).
+ */
+export async function prepareStorageImmutableTest(
+ initialStorage: PlainLsonObject
+) {
+ const roomId = await initRoom(initialStorage);
+
+ const [clientA, clientB] = await Promise.all([
+ enterConnectAndGetStorage(roomId),
+ enterConnectAndGetStorage(roomId),
+ ]);
+
+ const storageA = clientA.storage;
+ const storageB = clientB.storage;
+
+ // Wait for both clients to sync initial storage
+ await vi.waitFor(() => {
+ expect(lsonToJson(storageA.root)).toEqual(lsonToJson(storageB.root));
+ });
- state = lsonToJson(subject.storage.root) as ToJson;
- refState = lsonToJson(ref.storage.root) as ToJson;
+ const state = lsonToJson(storageA.root) as ToJson;
+ let refState = lsonToJson(storageB.root) as ToJson;
onTestFinished(
- ref.room.events.storageBatch.subscribe(() => {
- refState = lsonToJson(ref.storage.root) as ToJson;
+ clientB.room.events.storageBatch.subscribe(() => {
+ refState = lsonToJson(storageB.root) as ToJson;
})
);
- function expectStorageAndStateInBothClients(
- data: ToJson,
- itemsCount?: number,
- storageOpsCount?: number
- ) {
- expectStorageInBothClients(data);
+ async function expectStorageAndState(data: ToJson, itemsCount?: number) {
+ expect(lsonToJson(storageA.root)).toEqual(data);
+
+ await vi.waitFor(() => {
+ expect(lsonToJson(storageB.root)).toEqual(data);
+ });
if (itemsCount !== undefined) {
- expect(subject.room[kInternal].nodeCount).toBe(itemsCount);
+ expect(clientA.room[kInternal].nodeCount).toBe(itemsCount);
}
expect(state).toEqual(refState);
expect(state).toEqual(data);
-
- if (storageOpsCount !== undefined) {
- expect(totalStorageOps).toEqual(storageOpsCount);
- }
}
- function expectStorageInBothClients(data: ToJson) {
- const json = lsonToJson(subject.storage.root);
- expect(json).toEqual(data);
- expect(lsonToJson(ref.storage.root)).toEqual(data);
+ async function expectStorage(data: ToJson) {
+ expect(lsonToJson(storageA.root)).toEqual(data);
+ await vi.waitFor(() => {
+ expect(lsonToJson(storageB.root)).toEqual(data);
+ });
}
return {
- storage: subject.storage,
- refStorage: ref.storage,
- expectStorageAndState: expectStorageAndStateInBothClients,
- expectStorage: expectStorageInBothClients,
+ storage: storageA,
+ refStorage: storageB,
+ expectStorageAndState,
+ expectStorage,
state,
};
}
@@ -171,7 +147,10 @@ describe("2 ways tests with two clients", () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncObj: { a: number };
- }>([createSerializedRoot()], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
expect(state).toEqual({});
@@ -186,20 +165,19 @@ describe("2 ways tests with two clients", () => {
newState["syncObj"]
);
- expectStorageAndState({ syncObj: { a: 1 } }, 2, 1);
+ await expectStorageAndState({ syncObj: { a: 1 } }, 2);
});
test("update object", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncObj: { a: number };
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "syncObj"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncObj: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
expect(state).toEqual({ syncObj: { a: 0 } });
@@ -214,20 +192,19 @@ describe("2 ways tests with two clients", () => {
newState["syncObj"]
);
- expectStorageAndState({ syncObj: { a: 1 } }, 2, 1);
+ await expectStorageAndState({ syncObj: { a: 1 } }, 2);
});
test("add nested object", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncObj: { a: any };
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "syncObj"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncObj: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
expect(state).toEqual({ syncObj: { a: 0 } });
@@ -242,20 +219,19 @@ describe("2 ways tests with two clients", () => {
newState["syncObj"]
);
- expectStorageAndState({ syncObj: { a: { subA: "ok" } } }, 3, 1);
+ await expectStorageAndState({ syncObj: { a: { subA: "ok" } } }, 3);
});
test("create LiveList with one LiveRegister item in same batch", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
doc: any;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", {}, "root", "doc"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ doc: { liveblocksType: "LiveObject", data: {} },
+ },
+ });
expect(state).toEqual({ doc: {} });
@@ -265,20 +241,19 @@ describe("2 ways tests with two clients", () => {
patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]);
- expectStorageAndState({ doc: { sub: [0] } }, 4, 2);
+ await expectStorageAndState({ doc: { sub: [0] } }, 4);
});
test("create nested LiveList with one LiveObject item in same batch", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
doc: any;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", {}, "root", "doc"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ doc: { liveblocksType: "LiveObject", data: {} },
+ },
+ });
expect(state).toEqual({ doc: {} });
@@ -288,20 +263,19 @@ describe("2 ways tests with two clients", () => {
patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]);
- expectStorageAndState({ doc: { sub: { subSub: [{ a: 1 }] } } }, 5, 3);
+ await expectStorageAndState({ doc: { sub: { subSub: [{ a: 1 }] } } }, 5);
});
test("Add nested objects in same batch", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
doc: any;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", {}, "root", "doc"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ doc: { liveblocksType: "LiveObject", data: {} },
+ },
+ });
expect(state).toEqual({ doc: {} });
@@ -311,20 +285,19 @@ describe("2 ways tests with two clients", () => {
patchLiveObjectKey(storage.root, "doc", oldState["doc"], newState["doc"]);
- expectStorageAndState({ doc: { pos: { a: { b: 1 } } } }, 4, 2);
+ await expectStorageAndState({ doc: { pos: { a: { b: 1 } } } }, 4);
});
test("delete object key", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncObj: { a?: number };
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "syncObj"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncObj: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
expect(state).toEqual({ syncObj: { a: 0 } });
@@ -339,7 +312,7 @@ describe("2 ways tests with two clients", () => {
newState["syncObj"]
);
- expectStorageAndState({ syncObj: {} }, 2, 1);
+ await expectStorageAndState({ syncObj: {} }, 2);
});
});
@@ -348,16 +321,12 @@ describe("2 ways tests with two clients", () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, 1),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, 1),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, 1),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: { liveblocksType: "LiveList", data: [1, 1, 1] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList = [2];
@@ -370,20 +339,19 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: [2] });
+ await expectStorageAndState({ syncList: [2] });
});
test("add item to array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: { liveblocksType: "LiveList", data: [] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList.push("a");
@@ -396,23 +364,19 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["a"] }, 3, 1);
+ await expectStorageAndState({ syncList: ["a"] }, 3);
});
test("replace first item in array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
list: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "list"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ list: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.list[0] = "D";
@@ -420,23 +384,19 @@ describe("2 ways tests with two clients", () => {
patchLiveObject(storage.root, oldState, newState);
- expectStorageAndState({ list: ["D", "B", "C"] }, 5, 1);
+ await expectStorageAndState({ list: ["D", "B", "C"] }, 5);
});
test("replace last item in array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
list: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "list"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ list: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.list[2] = "D";
@@ -444,21 +404,19 @@ describe("2 ways tests with two clients", () => {
patchLiveObject(storage.root, oldState, newState);
- expectStorageAndState({ list: ["A", "B", "D"] }, 5, 1);
+ await expectStorageAndState({ list: ["A", "B", "D"] }, 5);
});
test("insert item at beginning of array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: { liveblocksType: "LiveList", data: ["a"] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList.unshift("b");
@@ -471,24 +429,22 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["b", "a"] }, 4, 1);
+ await expectStorageAndState({ syncList: ["b", "a"] }, 4);
});
test("swap items in array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"),
- createSerializedRegister("0:5", "0:1", FOURTH_POSITION, "d"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: {
+ liveblocksType: "LiveList",
+ data: ["a", "b", "c", "d"],
+ },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList = ["d", "b", "c", "a"];
@@ -501,21 +457,22 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["d", "b", "c", "a"] }, 6, 4);
+ await expectStorageAndState({ syncList: ["d", "b", "c", "a"] }, 6);
});
test("array of objects", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: {
+ liveblocksType: "LiveList",
+ data: [{ liveblocksType: "LiveObject", data: { a: 1 } }],
+ },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList[0].a = 2;
@@ -528,22 +485,19 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: [{ a: 2 }] }, 3, 1);
+ await expectStorageAndState({ syncList: [{ a: 2 }] }, 3);
});
test("remove first item from array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: { liveblocksType: "LiveList", data: ["a", "b"] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList.shift();
@@ -556,22 +510,19 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["b"] }, 3, 1);
+ await expectStorageAndState({ syncList: ["b"] }, 3);
});
test("remove last item from array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: { liveblocksType: "LiveList", data: ["a", "b"] },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList.pop();
@@ -584,23 +535,22 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["a"] }, 3, 1);
+ await expectStorageAndState({ syncList: ["a"] }, 3);
});
test("remove all elements of array except first", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: {
+ liveblocksType: "LiveList",
+ data: ["a", "b", "c"],
+ },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList = ["a"];
@@ -613,23 +563,22 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["a"] }, 3, 2);
+ await expectStorageAndState({ syncList: ["a"] }, 3);
});
test("remove all elements of array except last", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: {
+ liveblocksType: "LiveList",
+ data: ["a", "b", "c"],
+ },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList = ["c"];
@@ -642,23 +591,22 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: ["c"] }, 3, 2);
+ await expectStorageAndState({ syncList: ["c"] }, 3);
});
test("remove all elements of array", async () => {
const { storage, state, expectStorageAndState } =
await prepareStorageImmutableTest<{
syncList: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "syncList"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "c"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncList: {
+ liveblocksType: "LiveList",
+ data: ["a", "b", "c"],
+ },
+ },
+ });
const { oldState, newState } = applyStateChanges(state, () => {
state.syncList = [];
@@ -671,7 +619,7 @@ describe("2 ways tests with two clients", () => {
newState["syncList"]
);
- expectStorageAndState({ syncList: [] }, 2, 3);
+ await expectStorageAndState({ syncList: [] }, 2);
});
});
@@ -691,13 +639,12 @@ describe("2 ways tests with two clients", () => {
test("new state contains a function", async () => {
const { storage, state, expectStorage } =
- await prepareStorageImmutableTest<{ syncObj: { a: any } }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "syncObj"),
- ],
- 1
- );
+ await prepareStorageImmutableTest<{ syncObj: { a: any } }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncObj: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
expect(state).toEqual({ syncObj: { a: 0 } });
@@ -714,19 +661,18 @@ describe("2 ways tests with two clients", () => {
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
- expectStorage({ syncObj: { a: 0 } });
+ await expectStorage({ syncObj: { a: 0 } });
});
test("Production env - new state contains a function", async () => {
const { storage, state } = await prepareStorageImmutableTest<{
syncObj: { a: any };
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "syncObj"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ syncObj: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
expect(state).toEqual({ syncObj: { a: 0 } });
diff --git a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts
new file mode 100644
index 0000000000..590906b8af
--- /dev/null
+++ b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts
@@ -0,0 +1,275 @@
+/**
+ * Room tests that run against the real dev server. Covers history (undo/redo,
+ * pause/resume, batch, clear) and storage subscription behavior.
+ *
+ * For connection state machine, auth, reconnection, and wire protocol tests,
+ * see room.mockserver.test.ts.
+ */
+import { describe, expect, onTestFinished, test } from "vitest";
+
+import { LiveList } from "../crdts/LiveList";
+import { LiveObject } from "../crdts/LiveObject";
+import type { StorageUpdate } from "../crdts/StorageUpdates";
+import { legacy_patchImmutableObject } from "../immutable";
+import { nn } from "../lib/assert";
+import { prepareIsolatedStorageTest } from "./_devserver";
+import type { JsonStorageUpdate } from "./_updatesUtils";
+import {
+ listUpdate,
+ listUpdateInsert,
+ listUpdateSet,
+ serializeUpdateToJson,
+} from "./_updatesUtils";
+
+describe("room (dev server)", () => {
+ test("pausing history twice is a no-op", async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveList>>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [{ liveblocksType: "LiveObject", data: {} }],
+ },
+ },
+ });
+
+ const items = root.get("items");
+
+ room.history.pause();
+ nn(items.get(0)).set("a", 1);
+ room.history.pause(); // Pausing again should be a no-op!
+ nn(items.get(0)).set("b", 2);
+ room.history.pause(); // Pausing again should be a no-op!
+ room.history.resume();
+ room.history.resume(); // Resuming again should also be a no-op!
+
+ expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]);
+ expect(room.history.canUndo()).toBe(true);
+ expect(room.history.canRedo()).toBe(false);
+ room.history.undo();
+
+ expect(items.toImmutable()).toEqual([{}]);
+ expect(room.history.canUndo()).toBe(false);
+ expect(room.history.canRedo()).toBe(true);
+ room.history.redo();
+
+ expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]);
+ expect(room.history.canUndo()).toBe(true);
+ expect(room.history.canRedo()).toBe(false);
+ });
+
+ test("undo redo batch", async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveList>>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [{ liveblocksType: "LiveObject", data: {} }],
+ },
+ },
+ });
+
+ const receivedUpdates: JsonStorageUpdate[][] = [];
+ onTestFinished(
+ room.subscribe(
+ root,
+ (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)),
+ { isDeep: true }
+ )
+ );
+
+ const items = root.get("items");
+ room.batch(() => {
+ nn(items.get(0)).set("a", 1);
+ items.set(0, new LiveObject({ a: 2 }));
+ });
+
+ expect(items.toImmutable()).toEqual([{ a: 2 }]);
+ room.history.undo();
+
+ expect(items.toImmutable()).toEqual([{}]);
+ room.history.redo();
+
+ expect(items.toImmutable()).toEqual([{ a: 2 }]);
+ expect(receivedUpdates).toEqual([
+ [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])],
+ [listUpdate([{}], [listUpdateSet(0, {})])],
+ [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])],
+ ]);
+ });
+
+ test("canUndo / canRedo", async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ a: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 1 },
+ });
+
+ expect(room.history.canUndo()).toBe(false);
+ expect(room.history.canRedo()).toBe(false);
+
+ root.set("a", 2);
+
+ expect(room.history.canUndo()).toBe(true);
+
+ room.history.undo();
+
+ expect(room.history.canRedo()).toBe(true);
+ });
+
+ test("clearing undo/redo stack", async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ a: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 1 },
+ });
+
+ expect(room.history.canUndo()).toBe(false);
+ expect(room.history.canRedo()).toBe(false);
+
+ root.set("a", 2);
+ root.set("a", 3);
+ root.set("a", 4);
+ room.history.undo();
+
+ expect(room.history.canUndo()).toBe(true);
+ expect(room.history.canRedo()).toBe(true);
+
+ room.history.clear();
+ expect(room.history.canUndo()).toBe(false);
+ expect(room.history.canRedo()).toBe(false);
+
+ room.history.undo(); // won't do anything now
+
+ expect(root.toObject()).toEqual({ a: 3 });
+ });
+
+ describe("subscription", () => {
+ test("batch without operations should not add an item to the undo stack", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ a: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 1 },
+ });
+
+ root.set("a", 2);
+
+ // Batch without operations on storage or presence
+ room.batch(() => {});
+
+ expectStorage({ a: 2 });
+
+ room.history.undo();
+
+ expectStorage({ a: 1 });
+ });
+
+ test("batch storage with changes from server", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: [] },
+ },
+ });
+
+ const items = root.get("items");
+
+ room.batch(() => {
+ items.push("A");
+ items.push("B");
+ items.push("C");
+ });
+
+ expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ room.history.undo();
+
+ expectStorage({
+ items: [],
+ });
+
+ room.history.redo();
+
+ expectStorage({
+ items: ["A", "B", "C"],
+ });
+ });
+
+ test("nested storage updates", async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveList }>>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [
+ {
+ liveblocksType: "LiveObject",
+ data: {
+ names: { liveblocksType: "LiveList", data: [] },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ const jsonUpdates: JsonStorageUpdate[][] = [];
+ let receivedUpdates: StorageUpdate[] = [];
+
+ onTestFinished(
+ room.events.storageBatch.subscribe((updates) => {
+ jsonUpdates.push(updates.map(serializeUpdateToJson));
+ receivedUpdates = updates;
+ })
+ );
+
+ const immutableState = root.toImmutable() as {
+ items: Array<{ names: Array }>;
+ };
+
+ room.batch(() => {
+ const items = root.get("items");
+ items.insert(new LiveObject({ names: new LiveList(["John Doe"]) }), 0);
+ items.get(1)?.get("names").push("Jane Doe");
+ items.push(new LiveObject({ names: new LiveList(["James Doe"]) }));
+ });
+
+ expect(jsonUpdates).toEqual([
+ [
+ listUpdate(
+ [
+ { names: ["John Doe"] },
+ { names: ["Jane Doe"] },
+ { names: ["James Doe"] },
+ ],
+ [
+ listUpdateInsert(0, { names: ["John Doe"] }),
+ listUpdateInsert(2, { names: ["James Doe"] }),
+ ]
+ ),
+ listUpdate(["Jane Doe"], [listUpdateInsert(0, "Jane Doe")]),
+ ],
+ ]);
+
+ // Additional check to prove that generated updates could patch an immutable state
+ const newImmutableState = legacy_patchImmutableObject(
+ immutableState,
+ receivedUpdates
+ );
+ expect(newImmutableState).toEqual(root.toImmutable());
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/__tests__/room.test.ts b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts
similarity index 91%
rename from packages/liveblocks-core/src/__tests__/room.test.ts
rename to packages/liveblocks-core/src/__tests__/room.mockserver.test.ts
index bec69436b2..f6d2e1e0db 100644
--- a/packages/liveblocks-core/src/__tests__/room.test.ts
+++ b/packages/liveblocks-core/src/__tests__/room.mockserver.test.ts
@@ -1,3 +1,10 @@
+/**
+ * Room tests that use a MockWebSocket server. Covers connection state machine
+ * (auth flows, reconnection, backoff, close codes), wire protocol message
+ * inspection, presence, subscriptions, and offline behavior.
+ *
+ * For normal history/batch/storage tests, see room.devserver.test.ts.
+ */
import {
afterEach,
beforeEach,
@@ -17,10 +24,8 @@ import { LiveList } from "../crdts/LiveList";
import { LiveMap } from "../crdts/LiveMap";
import { LiveObject } from "../crdts/LiveObject";
import type { LsonObject } from "../crdts/Lson";
-import type { StorageUpdate } from "../crdts/StorageUpdates";
-import { legacy_patchImmutableObject, lsonToJson } from "../immutable";
+import { lsonToJson } from "../immutable";
import { kInternal } from "../internal";
-import { nn } from "../lib/assert";
import { makeEventSource } from "../lib/EventSource";
import * as console from "../lib/fancy-console";
import type { Json, JsonObject } from "../lib/Json";
@@ -46,22 +51,18 @@ import {
SOCKET_REFUSES,
SOCKET_SEQUENCE,
SOCKET_THROWS,
-} from "./_behaviors";
-import { listUpdate, listUpdateInsert, listUpdateSet } from "./_updatesUtils";
+} from "./_MockWebSocketServer.behaviors";
import {
createSerializedList,
- createSerializedObject,
createSerializedRegister,
createSerializedRoot,
FIRST_POSITION,
makeSyncSource,
- prepareDisconnectedStorageUpdateTest,
prepareIsolatedStorageTest,
prepareRoomWithStorage_loadWithDelay,
prepareStorageTest,
- prepareStorageUpdateTest,
serverMessage,
-} from "./_utils";
+} from "./_MockWebSocketServer.setup";
import {
waitUntilCustomEvent,
waitUntilOthersEvent,
@@ -1097,70 +1098,6 @@ describe("room", () => {
expect(room.getPresence()).toEqual({ x: 1 });
});
- test("pausing history twice is a no-op", async () => {
- const { room, root } = await prepareDisconnectedStorageUpdateTest<{
- items: LiveList>>;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", {}, "0:1", FIRST_POSITION),
- ]);
-
- const items = root.get("items");
-
- room.history.pause();
- nn(items.get(0)).set("a", 1);
- room.history.pause(); // Pausing again should be a no-op!
- nn(items.get(0)).set("b", 2);
- room.history.pause(); // Pausing again should be a no-op!
- room.history.resume();
- room.history.resume(); // Resuming again should also be a no-op!
-
- expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]);
- expect(room.history.canUndo()).toBe(true);
- expect(room.history.canRedo()).toBe(false);
- room.history.undo();
-
- expect(items.toImmutable()).toEqual([{}]);
- expect(room.history.canUndo()).toBe(false);
- expect(room.history.canRedo()).toBe(true);
- room.history.redo();
-
- expect(items.toImmutable()).toEqual([{ a: 1, b: 2 }]);
- expect(room.history.canUndo()).toBe(true);
- expect(room.history.canRedo()).toBe(false);
- });
-
- test("undo redo batch", async () => {
- const { room, root, expectUpdates } =
- await prepareDisconnectedStorageUpdateTest<{
- items: LiveList>>;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", {}, "0:1", FIRST_POSITION),
- ]);
-
- const items = root.get("items");
- room.batch(() => {
- nn(items.get(0)).set("a", 1);
- items.set(0, new LiveObject({ a: 2 }));
- });
-
- expect(items.toImmutable()).toEqual([{ a: 2 }]);
- room.history.undo();
-
- expect(items.toImmutable()).toEqual([{}]);
- room.history.redo();
-
- expect(items.toImmutable()).toEqual([{ a: 2 }]);
- expectUpdates([
- [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])],
- [listUpdate([{}], [listUpdateSet(0, {})])],
- [listUpdate([{ a: 2 }], [listUpdateSet(0, { a: 2 })])],
- ]);
- });
-
test("if presence is not added to history during a batch, it should not impact the undo/stack", async () => {
const { room, wss } = createTestableRoom({});
@@ -1341,48 +1278,6 @@ describe("room", () => {
expect(storage.root.toObject()).toEqual({ x: 1 });
});
- test("canUndo / canRedo", async () => {
- const { room, storage } = await prepareStorageTest<{
- a: number;
- }>([createSerializedRoot({ a: 1 })], 1);
-
- expect(room.history.canUndo()).toBe(false);
- expect(room.history.canRedo()).toBe(false);
-
- storage.root.set("a", 2);
-
- expect(room.history.canUndo()).toBe(true);
-
- room.history.undo();
-
- expect(room.history.canRedo()).toBe(true);
- });
-
- test("clearing undo/redo stack", async () => {
- const { room, storage } = await prepareStorageTest<{
- a: number;
- }>([createSerializedRoot({ a: 1 })], 1);
-
- expect(room.history.canUndo()).toBe(false);
- expect(room.history.canRedo()).toBe(false);
-
- storage.root.set("a", 2);
- storage.root.set("a", 3);
- storage.root.set("a", 4);
- room.history.undo();
-
- expect(room.history.canUndo()).toBe(true);
- expect(room.history.canRedo()).toBe(true);
-
- room.history.clear();
- expect(room.history.canUndo()).toBe(false);
- expect(room.history.canRedo()).toBe(false);
-
- room.history.undo(); // won't do anything now
-
- expect(storage.root.toObject()).toEqual({ a: 3 });
- });
-
describe("subscription", () => {
test("batch my-presence", () => {
const { room } = createTestableRoom({});
@@ -1439,56 +1334,6 @@ describe("room", () => {
expect(storageRootSubscriber).toHaveBeenCalledWith(storage.root);
});
- test("batch without operations should not add an item to the undo stack", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
- a: number;
- }>([createSerializedRoot({ a: 1 })], 1);
-
- storage.root.set("a", 2);
-
- // Batch without operations on storage or presence
- room.batch(() => {});
-
- expectStorage({ a: 2 });
-
- room.history.undo();
-
- expectStorage({ a: 1 });
- });
-
- test("batch storage with changes from server", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
-
- const items = storage.root.get("items");
-
- room.batch(() => {
- items.push("A");
- items.push("B");
- items.push("C");
- });
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- room.history.undo();
-
- expectStorage({
- items: [],
- });
-
- room.history.redo();
-
- expectStorage({
- items: ["A", "B", "C"],
- });
- });
-
test("batch storage and presence with changes from server", async () => {
type P = { x?: number };
type S = { items: LiveList };
@@ -1547,60 +1392,6 @@ describe("room", () => {
});
});
- test("nested storage updates", async () => {
- const { room, root, expectUpdates } = await prepareStorageUpdateTest<{
- items: LiveList }>>;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", {}, "0:1", FIRST_POSITION),
- createSerializedList("0:3", "0:2", "names"),
- ]);
-
- let receivedUpdates: StorageUpdate[] = [];
-
- onTestFinished(
- room.events.storageBatch.subscribe(
- (updates) => (receivedUpdates = updates)
- )
- );
-
- const immutableState = root.toImmutable() as {
- items: Array<{ names: Array }>;
- };
-
- room.batch(() => {
- const items = root.get("items");
- items.insert(new LiveObject({ names: new LiveList(["John Doe"]) }), 0);
- items.get(1)?.get("names").push("Jane Doe");
- items.push(new LiveObject({ names: new LiveList(["James Doe"]) }));
- });
-
- expectUpdates([
- [
- listUpdate(
- [
- { names: ["John Doe"] },
- { names: ["Jane Doe"] },
- { names: ["James Doe"] },
- ],
- [
- listUpdateInsert(0, { names: ["John Doe"] }),
- listUpdateInsert(2, { names: ["James Doe"] }),
- ]
- ),
- listUpdate(["Jane Doe"], [listUpdateInsert(0, "Jane Doe")]),
- ],
- ]);
-
- // Additional check to prove that generated updates could patch an immutable state
- const newImmutableState = legacy_patchImmutableObject(
- immutableState,
- receivedUpdates
- );
- expect(newImmutableState).toEqual(root.toImmutable());
- });
-
test("batch history", () => {
const { room } = createTestableRoom({});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts
new file mode 100644
index 0000000000..a691bf7136
--- /dev/null
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.devserver.test.ts
@@ -0,0 +1,820 @@
+/**
+ * LiveList tests that run against the real dev server.
+ *
+ * For edge cases that require precise control over wire-level ops (CRDT
+ * conflict resolution, reconnection behavior), see LiveList.mockserver.test.ts.
+ */
+import { describe, expect, onTestFinished, test, vi } from "vitest";
+
+import {
+ prepareIsolatedStorageTest,
+ prepareStorageTest,
+ prepareStorageUpdateTest,
+} from "../../__tests__/_devserver";
+import {
+ listUpdate,
+ listUpdateDelete,
+ listUpdateInsert,
+ listUpdateMove,
+} from "../../__tests__/_updatesUtils";
+import { kInternal } from "../../internal";
+import { LiveList } from "../LiveList";
+import { LiveMap } from "../LiveMap";
+import { LiveObject } from "../LiveObject";
+
+describe("LiveList", () => {
+ describe("not attached", () => {
+ test("basic operations with native objects", () => {
+ const list = new LiveList(["first", "second", "third"]);
+ expect(list.get(0)).toEqual("first");
+ expect(list.length).toBe(3);
+
+ expect(list.toArray()).toEqual(["first", "second", "third"]);
+
+ expect(Array.from(list)).toEqual(["first", "second", "third"]);
+
+ expect(list.map((item) => item.toUpperCase())).toEqual([
+ "FIRST",
+ "SECOND",
+ "THIRD",
+ ]);
+
+ expect(list.filter((item) => item.endsWith("d"))).toEqual([
+ "second",
+ "third",
+ ]);
+
+ expect(list.findIndex((item) => item.startsWith("s"))).toEqual(1);
+
+ expect(list.some((item) => item.startsWith("x"))).toEqual(false);
+
+ expect(list.indexOf("quatre")).toEqual(-1);
+ expect(list.indexOf("third")).toEqual(2);
+
+ list.delete(0);
+
+ expect(list.toArray()).toEqual(["second", "third"]);
+ expect(list.get(2)).toBe(undefined);
+ expect(list.length).toBe(2);
+
+ list.clear();
+ expect(list.toArray()).toEqual([]);
+ });
+ });
+
+ describe("deserialization", () => {
+ test("create document with list in root", async () => {
+ const { expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ expectStorage({
+ items: [],
+ });
+ });
+
+ test("init list with items", async () => {
+ const { expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [
+ { liveblocksType: "LiveObject", data: { a: 0 } },
+ { liveblocksType: "LiveObject", data: { a: 1 } },
+ { liveblocksType: "LiveObject", data: { a: 2 } },
+ ],
+ },
+ },
+ });
+
+ expectStorage({
+ items: [{ a: 0 }, { a: 1 }, { a: 2 }],
+ });
+ });
+ });
+
+ describe("push", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.push("first")).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ describe("updates", () => {
+ test("push on empty list update", async () => {
+ const {
+ rootA: root,
+ expectUpdates,
+ roomA: room,
+ } = await prepareStorageUpdateTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ root.get("items").push("a");
+ room.history.undo();
+ room.history.redo();
+
+ await expectUpdates([
+ [listUpdate(["a"], [listUpdateInsert(0, "a")])],
+ [listUpdate([], [listUpdateDelete(0, "a")])],
+ [listUpdate(["a"], [listUpdateInsert(0, "a")])],
+ ]);
+ });
+ });
+
+ test("push LiveObject on empty list", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ const root = storageA.root;
+ const items = root.get("items");
+
+ await expectStorage({
+ items: [],
+ });
+
+ items.push(new LiveObject({ a: 0 }));
+
+ await expectStorage({
+ items: [{ a: 0 }],
+ });
+
+ await assertUndoRedo();
+ });
+
+ test("push number on empty list", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+
+ await expectStorage({ items: [] });
+
+ items.push(0);
+ await expectStorage({ items: [0] });
+
+ await assertUndoRedo();
+ });
+
+ test("push LiveMap on empty list", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ const root = storageA.root;
+ const items = root.get("items");
+
+ await expectStorage({ items: [] });
+
+ items.push(new LiveMap([["first", 0]]));
+
+ await expectStorage({ items: [new Map([["first", 0]])] });
+
+ await assertUndoRedo();
+ });
+
+ test("push already attached LiveObject should throw", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ const items = root.toObject().items;
+
+ const object = new LiveObject({ a: 0 });
+
+ items.push(object);
+ expect(() => items.push(object)).toThrow();
+ });
+ });
+
+ describe("insert", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.insert("first", 0)).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ describe("updates", () => {
+ test("insert at the middle update", async () => {
+ const {
+ rootA: root,
+ expectUpdates,
+ roomA: room,
+ } = await prepareStorageUpdateTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "C"] },
+ },
+ });
+
+ root.get("items").insert("B", 1);
+ room.history.undo();
+ room.history.redo();
+
+ await expectUpdates([
+ [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])],
+ [listUpdate(["A", "C"], [listUpdateDelete(1, "B")])],
+ [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])],
+ ]);
+ });
+ });
+
+ test("insert LiveObject at position 0", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [{ liveblocksType: "LiveObject", data: { a: 1 } }],
+ },
+ },
+ });
+
+ await expectStorage({
+ items: [{ a: 1 }],
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+
+ items.insert(new LiveObject({ a: 0 }), 0);
+
+ await expectStorage({ items: [{ a: 0 }, { a: 1 }] });
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("delete", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.delete(0)).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ describe("updates", () => {
+ test("delete first update", async () => {
+ const {
+ rootA: root,
+ expectUpdates,
+ roomA: room,
+ } = await prepareStorageUpdateTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A"] },
+ },
+ });
+
+ root.get("items").delete(0);
+ room.history.undo();
+ room.history.redo();
+
+ await expectUpdates([
+ [listUpdate([], [listUpdateDelete(0, "A")])],
+ [listUpdate(["A"], [listUpdateInsert(0, "A")])],
+ [listUpdate([], [listUpdateDelete(0, "A")])],
+ ]);
+ });
+ });
+
+ test("delete first item", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B"] },
+ },
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+
+ await expectStorage({
+ items: ["A", "B"],
+ });
+
+ items.delete(0);
+
+ await expectStorage({
+ items: ["B"],
+ });
+
+ await assertUndoRedo();
+ });
+
+ test("delete should remove descendants", async () => {
+ const { roomA, storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList }>>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [
+ {
+ liveblocksType: "LiveObject",
+ data: {
+ child: {
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ await expectStorage({
+ items: [{ child: { a: 0 } }],
+ });
+
+ storageA.root.toObject().items.delete(0);
+
+ await expectStorage({
+ items: [],
+ });
+
+ // Ensure that LiveStructure are deleted properly
+ expect(roomA[kInternal].nodeCount).toBe(2);
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("move", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.move(0, 1)).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ describe("updates", () => {
+ test("move at the end update", async () => {
+ const {
+ rootA: root,
+ expectUpdates,
+ roomA: room,
+ } = await prepareStorageUpdateTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B"] },
+ },
+ });
+
+ root.get("items").move(0, 1);
+ room.history.undo();
+ room.history.redo();
+
+ await expectUpdates([
+ [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])],
+ [listUpdate(["A", "B"], [listUpdateMove(1, 0, "A")])],
+ [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])],
+ ]);
+ });
+ });
+
+ test("move after current position", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
+
+ await expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+ items.move(0, 1);
+
+ await expectStorage({ items: ["B", "A", "C"] });
+
+ await assertUndoRedo();
+ });
+
+ test("move before current position", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
+
+ await expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ const items = storageA.root.get("items");
+
+ items.move(0, 1);
+ await expectStorage({
+ items: ["B", "A", "C"],
+ });
+
+ await assertUndoRedo();
+ });
+
+ test("move at the end of the list", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
+
+ await expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+ items.move(0, 2);
+
+ await expectStorage({
+ items: ["B", "C", "A"],
+ });
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("clear", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.clear()).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ describe("updates", () => {
+ test("clear updates", async () => {
+ const {
+ rootA: root,
+ expectUpdates,
+ roomA: room,
+ } = await prepareStorageUpdateTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B"] },
+ },
+ });
+
+ root.get("items").clear();
+ room.history.undo();
+ room.history.redo();
+
+ await expectUpdates([
+ [
+ listUpdate(
+ [],
+ [listUpdateDelete(0, "A"), listUpdateDelete(0, "B")]
+ ),
+ ],
+ [
+ listUpdate(
+ ["A", "B"],
+ [listUpdateInsert(0, "A"), listUpdateInsert(1, "B")]
+ ),
+ ],
+ // Because redo reverse the operations, we delete items from the end
+ [
+ listUpdate(
+ [],
+ [listUpdateDelete(1, "B"), listUpdateDelete(0, "A")]
+ ),
+ ],
+ ]);
+ });
+ });
+
+ test("clear should delete all items", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
+
+ const root = storageA.root;
+ const items = root.get("items");
+
+ await expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ items.clear();
+ await expectStorage({
+ items: [],
+ });
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("batch", () => {
+ test("batch multiple inserts", async () => {
+ const {
+ roomA: room,
+ storageA,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
+
+ const items = storageA.root.get("items");
+
+ await expectStorage({ items: [] });
+
+ room.batch(() => {
+ items.push("A");
+ items.push("B");
+ });
+
+ await expectStorage(
+ { items: ["A", "B"] }
+ // Updates are not tested here because undo/redo is not symetric
+ );
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("set", () => {
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
+
+ const items = root.get("items");
+
+ expect(() => items.set(0, "A")).toThrow(
+ "Cannot write to storage with a read only user, please ensure the user has write permissions"
+ );
+ });
+
+ test("set register on detached list", () => {
+ const list = new LiveList(["A", "B", "C"]);
+ list.set(0, "D");
+ expect(list.toArray()).toEqual(["D", "B", "C"]);
+ });
+
+ test("set at invalid position should throw", () => {
+ const list = new LiveList(["A", "B", "C"]);
+ expect(() => list.set(-1, "D")).toThrow(
+ 'Cannot set list item at index "-1". index should be between 0 and 2'
+ );
+ expect(() => list.set(3, "D")).toThrow(
+ 'Cannot set list item at index "3". index should be between 0 and 2'
+ );
+ });
+
+ test("set register", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["A", "B", "C"] },
+ },
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+
+ await expectStorage({ items: ["A", "B", "C"] });
+
+ items.set(0, "D");
+ await expectStorage({ items: ["D", "B", "C"] });
+
+ items.set(1, "E");
+ await expectStorage({ items: ["D", "E", "C"] });
+
+ await assertUndoRedo();
+ });
+
+ test("set nested object", async () => {
+ const { storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ items: LiveList>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveList",
+ data: [{ liveblocksType: "LiveObject", data: { a: 1 } }],
+ },
+ },
+ });
+
+ const root = storageA.root;
+ const items = root.toObject().items;
+
+ await expectStorage({ items: [{ a: 1 }] });
+
+ items.set(0, new LiveObject({ a: 2 }));
+ await expectStorage({ items: [{ a: 2 }] });
+
+ await assertUndoRedo();
+ });
+ });
+
+ describe("subscriptions", () => {
+ test("batch multiple actions", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["a"] },
+ },
+ });
+
+ const callback = vi.fn();
+ onTestFinished(room.events.storageBatch.subscribe(callback));
+
+ const liveList = root.get("items");
+
+ room.batch(() => {
+ liveList.push("b");
+ liveList.push("c");
+ });
+
+ expectStorage({ items: ["a", "b", "c"] });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith([
+ {
+ node: liveList,
+ type: "LiveList",
+ updates: [
+ { index: 1, item: "b", type: "insert" },
+ { index: 2, item: "c", type: "insert" },
+ ],
+ },
+ ]);
+ });
+
+ test("batch multiple inserts", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: { liveblocksType: "LiveList", data: ["a"] },
+ },
+ });
+
+ const callback = vi.fn();
+ onTestFinished(room.events.storageBatch.subscribe(callback));
+
+ const liveList = root.get("items");
+
+ room.batch(() => {
+ liveList.insert("b", 1);
+ liveList.insert("c", 2);
+ });
+
+ expectStorage({ items: ["a", "b", "c"] });
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts
new file mode 100644
index 0000000000..7f5ba892e9
--- /dev/null
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveList.mockserver.test.ts
@@ -0,0 +1,930 @@
+/**
+ * LiveList tests that use a MockWebSocket server for precise control over
+ * wire-level operations. Covers CRDT conflict resolution, reconnection
+ * behavior, and internal methods that need deterministic node IDs.
+ *
+ * For normal storage/presence/history tests, see LiveList.devserver.test.ts.
+ */
+import { describe, expect, onTestFinished, test, vi } from "vitest";
+
+import {
+ createSerializedList,
+ createSerializedObject,
+ createSerializedRegister,
+ createSerializedRoot,
+ FIFTH_POSITION,
+ FIRST_POSITION,
+ FOURTH_POSITION,
+ prepareIsolatedStorageTest,
+ replaceRemoteStorageAndReconnect,
+ SECOND_POSITION,
+ THIRD_POSITION,
+} from "../../__tests__/_MockWebSocketServer.setup";
+import {
+ waitUntilStatus,
+ waitUntilStorageUpdate,
+} from "../../__tests__/_waitUtils";
+import { kInternal } from "../../internal";
+import type { ServerWireOp } from "../../protocol/Op";
+import { OpCode } from "../../protocol/Op";
+import { ServerMsgCode } from "../../protocol/ServerMsg";
+import type { StorageNode } from "../../protocol/StorageNode";
+import { CrdtType } from "../../protocol/StorageNode";
+import { WebsocketCloseCodes } from "../../types/IWebSocket";
+import type { LiveList } from "../LiveList";
+import type { LiveObject } from "../LiveObject";
+
+/**
+ * Injects server operations directly into the room's message handler,
+ * bypassing the MockWebSocket transport layer.
+ */
+function simulateRemoteOps(
+ room: { [kInternal]: { simulate: { incomingMessage(data: string): void } } },
+ ops: ServerWireOp[]
+) {
+ room[kInternal].simulate.incomingMessage(
+ JSON.stringify({ type: ServerMsgCode.UPDATE_STORAGE, ops })
+ );
+}
+
+describe("LiveList edge cases", () => {
+ describe("conflict", () => {
+ test("list conflicts", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
+ 1
+ );
+
+ const items = root.get("items");
+
+ // Register id = 1:0
+ items.push("0");
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:1",
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "1",
+ },
+ ]);
+
+ expectStorage({
+ items: ["1", "0"],
+ });
+
+ // Fix from backend
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:0",
+ parentKey: SECOND_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["1", "0"],
+ });
+ });
+
+ test("list conflicts 2", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
+ 1
+ );
+
+ const items = root.get("items");
+
+ items.push("x0"); // Register id = 1:0
+ items.push("x1"); // Register id = 1:1
+
+ // Should go to pending
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:0",
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "y0",
+ },
+ ]);
+
+ expectStorage({
+ items: ["y0", "x0", "x1"],
+ });
+
+ // Should go to pending
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:1",
+ parentId: "0:1",
+ parentKey: SECOND_POSITION,
+ data: "y1",
+ },
+ ]);
+
+ expectStorage({
+ items: ["y0", "x0", "y1", "x1"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:0",
+ parentKey: THIRD_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["y0", "y1", "x0", "x1"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:1",
+ parentKey: FOURTH_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["y0", "y1", "x0", "x1"],
+ });
+ });
+
+ test("list conflicts with offline", async () => {
+ const { room, root, expectStorage, wss } =
+ await prepareIsolatedStorageTest<{ items: LiveList }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+
+ // Register id = 1:0
+ items.push("0");
+
+ expectStorage({
+ items: ["0"],
+ });
+
+ replaceRemoteStorageAndReconnect(wss, [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ createSerializedRegister("2:0", "0:1", FIRST_POSITION, "1"),
+ ]);
+
+ await waitUntilStorageUpdate(room);
+ expectStorage({
+ items: ["1", "0"],
+ });
+
+ // Fix from backend
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:0",
+ parentKey: SECOND_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["1", "0"],
+ });
+ });
+
+ // This test uses the wss-based applyRemoteOperations (not injectRemoteOps)
+ // because the reconnection cycle affects how the room buffers ops.
+ test("list conflicts with undo redo and remote change", async () => {
+ const { root, expectStorage, applyRemoteOperations, room, wss } =
+ await prepareIsolatedStorageTest<{ items: LiveList }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ ],
+ 1
+ );
+
+ wss.last.close(
+ new CloseEvent("close", {
+ code: WebsocketCloseCodes.CLOSE_ABNORMAL,
+ wasClean: false,
+ })
+ );
+ await waitUntilStatus(room, "connected");
+
+ const items = root.get("items");
+
+ items.push("0");
+ expectStorage({ items: ["0"] });
+
+ room.history.undo();
+ expectStorage({ items: [] });
+
+ applyRemoteOperations([
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "1:1",
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "1",
+ },
+ ]);
+ expectStorage({ items: [] });
+
+ room.history.redo();
+ expectStorage({ items: ["0"] });
+
+ await waitUntilStorageUpdate(room);
+ expectStorage({ items: ["1", "0"] });
+ });
+
+ test("list conflicts - move", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
+ 1
+ );
+
+ const items = root.get("items");
+
+ // Register id = 1:0
+ items.push("A");
+ // Register id = 1:1
+ items.push("B");
+ // Register id = 1:2
+ items.push("C");
+
+ expectStorage({
+ items: ["A", "B", "C"],
+ });
+
+ items.move(0, 2);
+
+ expectStorage({
+ items: ["B", "C", "A"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:1",
+ parentKey: FOURTH_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["C", "B", "A"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "1:0",
+ parentKey: FIFTH_POSITION,
+ },
+ ]);
+
+ expectStorage({
+ items: ["C", "B", "A"],
+ });
+ });
+
+ test("list conflicts - ack has different position that local item", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:0", "root", "items")],
+ 1
+ );
+
+ const items = root.get("items");
+
+ items.push("B");
+
+ expectStorage({
+ items: ["B"],
+ });
+
+ // Other client created "A" at the same time but was processed first by the server.
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:0",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "A",
+ },
+ ]);
+ // B is shifted to SECOND_POSITION
+
+ // Other client deleted "A" right after creation.
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.DELETE_CRDT,
+ id: "2:0",
+ },
+ ]);
+
+ expectStorage({
+ items: ["B"], // "B" is at SECOND_POSITION
+ });
+
+ // Server sends ackownledgment for "B" creation with different position/
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "1:0",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ opId: "1:0", // Ack
+ },
+ ]);
+
+ expectStorage({
+ items: ["B"], // "B" should at FIRST_POSITION
+ });
+
+ // Other client creates an item at the SECOND_POSITION
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:0",
+ parentId: "0:0",
+ parentKey: SECOND_POSITION,
+ data: "C",
+ },
+ ]);
+
+ expectStorage({
+ items: ["B", "C"],
+ });
+ });
+
+ test("list conflicts - ack has different position that local and ack position is used", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:0", "root", "items")],
+ 1
+ );
+
+ const items = root.get("items");
+
+ items.push("B");
+
+ expectStorage({
+ items: ["B"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:0",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "A",
+ },
+ ]);
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.DELETE_CRDT,
+ id: "2:0",
+ },
+ ]);
+
+ items.insert("C", 0); // Insert at FIRST_POSITION
+
+ // Ack
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "1:0",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ opId: "1:0", // Ack
+ },
+ ]);
+
+ expectStorage({
+ items: ["B", "C"], // C position is shifted
+ });
+ });
+
+ // Regression test: #applySetChildKeyAck must return modified when restoring
+ // items from implicitlyDeletedItems, otherwise subscriptions won't fire.
+ test("restoring item from implicitlyDeletedItems triggers subscription", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:0", "root", "items"),
+ createSerializedRegister("0:1", "0:0", FIRST_POSITION, "a"),
+ createSerializedRegister("0:2", "0:0", SECOND_POSITION, "b"),
+ createSerializedRegister("0:3", "0:0", THIRD_POSITION, "c"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+ items.delete(0);
+ items.move(1, 0);
+ expectStorage({ items: ["c", "b"] });
+
+ // Remote set at "a"'s position moves "c" to implicitlyDeletedItems
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:0",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "X",
+ intent: "set",
+ deletedId: "0:1",
+ },
+ ]);
+ expectStorage({ items: ["X", "b"] });
+
+ // Start listening for subscription updates
+ const onStorage = vi.fn();
+ onTestFinished(room.events.storageBatch.subscribe(onStorage));
+
+ // Ack restores "c" from implicitlyDeletedItems - this MUST trigger subscription
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.SET_PARENT_KEY,
+ id: "0:3",
+ parentKey: SECOND_POSITION,
+ opId: "1:1",
+ },
+ ]);
+
+ expectStorage({ items: ["X", "c", "b"] });
+ expect(onStorage).toHaveBeenCalled();
+ });
+ });
+
+ describe("reconnect with remote changes and subscribe", () => {
+ test("register added to list", async () => {
+ const { expectStorage, room, root, wss } =
+ await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
+ ],
+ 1
+ );
+
+ const rootCallback = vi.fn();
+ const rootDeepCallback = vi.fn();
+ const listCallback = vi.fn();
+
+ const listItems = root.get("items");
+
+ onTestFinished(room.subscribe(root, rootCallback));
+ onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
+ onTestFinished(room.subscribe(listItems, listCallback));
+
+ expectStorage({ items: ["a"] });
+
+ const newInitStorage: StorageNode[] = [
+ ["root", { type: CrdtType.OBJECT, data: {} }],
+ ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
+ [
+ "0:2",
+ {
+ type: CrdtType.REGISTER,
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "a",
+ },
+ ],
+ [
+ "2:0",
+ {
+ type: CrdtType.REGISTER,
+ parentId: "0:1",
+ parentKey: SECOND_POSITION,
+ data: "b",
+ },
+ ],
+ ];
+
+ replaceRemoteStorageAndReconnect(wss, newInitStorage);
+
+ await waitUntilStorageUpdate(room);
+ expectStorage({
+ items: ["a", "b"],
+ });
+
+ listItems.push("c");
+
+ expectStorage({
+ items: ["a", "b", "c"],
+ });
+
+ expect(rootCallback).toHaveBeenCalledTimes(0);
+
+ expect(rootDeepCallback).toHaveBeenCalledTimes(2);
+
+ expect(rootDeepCallback).toHaveBeenCalledWith([
+ {
+ type: "LiveList",
+ node: listItems,
+ updates: [{ index: 1, item: "b", type: "insert" }],
+ },
+ ]);
+ expect(rootDeepCallback).toHaveBeenCalledWith([
+ {
+ type: "LiveList",
+ node: listItems,
+ updates: [{ index: 2, item: "c", type: "insert" }],
+ },
+ ]);
+ expect(listCallback).toHaveBeenCalledTimes(2);
+ });
+
+ test("register moved in list", async () => {
+ const { expectStorage, room, root, wss } =
+ await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
+ createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
+ ],
+ 1
+ );
+
+ const rootCallback = vi.fn();
+ const rootDeepCallback = vi.fn();
+ const listCallback = vi.fn();
+
+ const listItems = root.get("items");
+
+ onTestFinished(room.subscribe(root, rootCallback));
+ onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
+ onTestFinished(room.subscribe(listItems, listCallback));
+
+ expectStorage({ items: ["a", "b"] });
+
+ const newInitStorage: StorageNode[] = [
+ ["root", { type: CrdtType.OBJECT, data: {} }],
+ ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
+ [
+ "0:2",
+ {
+ type: CrdtType.REGISTER,
+ parentId: "0:1",
+ parentKey: SECOND_POSITION,
+ data: "a",
+ },
+ ],
+ [
+ "0:3",
+ {
+ type: CrdtType.REGISTER,
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "b",
+ },
+ ],
+ ];
+
+ replaceRemoteStorageAndReconnect(wss, newInitStorage);
+
+ await waitUntilStorageUpdate(room);
+ expectStorage({
+ items: ["b", "a"],
+ });
+
+ expect(rootCallback).toHaveBeenCalledTimes(0);
+
+ expect(rootDeepCallback).toHaveBeenCalledTimes(1);
+
+ expect(rootDeepCallback).toHaveBeenCalledWith([
+ {
+ type: "LiveList",
+ node: listItems,
+ updates: [{ index: 0, previousIndex: 1, item: "b", type: "move" }],
+ },
+ ]);
+
+ expect(listCallback).toHaveBeenCalledTimes(1);
+ });
+
+ test("register deleted from list", async () => {
+ const { expectStorage, room, root, wss } =
+ await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
+ createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
+ ],
+ 1
+ );
+
+ const rootCallback = vi.fn();
+ const rootDeepCallback = vi.fn();
+ const listCallback = vi.fn();
+
+ const listItems = root.get("items");
+
+ onTestFinished(room.subscribe(root, rootCallback));
+ onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
+ onTestFinished(room.subscribe(listItems, listCallback));
+
+ expectStorage({ items: ["a", "b"] });
+
+ const newInitStorage: StorageNode[] = [
+ ["root", { type: CrdtType.OBJECT, data: {} }],
+ ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
+ [
+ "0:2",
+ {
+ type: CrdtType.REGISTER,
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "a",
+ },
+ ],
+ ];
+
+ replaceRemoteStorageAndReconnect(wss, newInitStorage);
+
+ await waitUntilStorageUpdate(room);
+ expectStorage({
+ items: ["a"],
+ });
+
+ expect(rootCallback).toHaveBeenCalledTimes(0);
+
+ expect(rootDeepCallback).toHaveBeenCalledTimes(1);
+
+ expect(rootDeepCallback).toHaveBeenCalledWith([
+ {
+ type: "LiveList",
+ node: listItems,
+ updates: [{ index: 1, type: "delete", deletedItem: "b" }],
+ },
+ ]);
+
+ expect(listCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("internal methods", () => {
+ test("_detachChild", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ items: LiveList>;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION),
+ createSerializedObject("0:3", { a: 2 }, "0:1", SECOND_POSITION),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+ const secondItem = items.get(1);
+
+ const applyResult = items._detachChild(secondItem!);
+
+ expect(applyResult).toEqual({
+ modified: {
+ type: "LiveList",
+ node: items,
+ updates: [{ index: 1, type: "delete", deletedItem: secondItem }],
+ },
+ reverse: [
+ {
+ data: { a: 2 },
+ id: "0:3",
+ parentId: "0:1",
+ parentKey: SECOND_POSITION,
+ type: OpCode.CREATE_OBJECT,
+ },
+ ],
+ });
+ });
+
+ describe("apply CreateRegister", () => {
+ test('with intent "set" should replace existing item', async () => {
+ const { room, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:0", "root", "items"),
+ createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
+ ],
+ 1
+ );
+
+ expectStorage({
+ items: ["A"],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "0:2",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ intent: "set",
+ },
+ ]);
+
+ expectStorage({
+ items: ["B"],
+ });
+ });
+
+ test('with intent "set" should notify with a "set" update', async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:0", "root", "items"),
+ createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+
+ const callback = vi.fn();
+ onTestFinished(room.events.storageBatch.subscribe(callback));
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "0:2",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ intent: "set",
+ },
+ ]);
+
+ expect(callback).toHaveBeenCalledWith([
+ {
+ node: items,
+ type: "LiveList",
+ updates: [{ type: "set", index: 0, item: "B" }],
+ },
+ ]);
+ });
+
+ test('with intent "set" should insert item if conflict with a delete operation', async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:0", "root", "items"),
+ createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+
+ expectStorage({
+ items: ["A"],
+ });
+
+ items.delete(0);
+
+ expectStorage({
+ items: [],
+ });
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "0:2",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ intent: "set",
+ },
+ ]);
+
+ expectStorage({
+ items: ["B"],
+ });
+ });
+
+ test('with intent "set" should notify with a "insert" update if no item exists at this position', async () => {
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:0", "root", "items"),
+ createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+ items.delete(0);
+
+ const callback = vi.fn();
+ onTestFinished(room.subscribe(items, callback, { isDeep: true }));
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "0:2",
+ parentId: "0:0",
+ parentKey: FIRST_POSITION,
+ data: "B",
+ intent: "set",
+ },
+ ]);
+
+ expect(callback).toHaveBeenCalledWith([
+ {
+ node: items,
+ type: "LiveList",
+ updates: [{ type: "insert", index: 0, item: "B" }],
+ },
+ ]);
+ });
+
+ test("on existing position should give the right update", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ items: LiveList;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedList("0:1", "root", "items"),
+ ],
+ 1
+ );
+
+ const items = root.get("items");
+
+ // Register id = 1:0
+ items.push("0");
+
+ expectStorage({
+ items: ["0"],
+ });
+
+ const callback = vi.fn();
+ onTestFinished(room.subscribe(items, callback, { isDeep: true }));
+
+ simulateRemoteOps(room, [
+ {
+ type: OpCode.CREATE_REGISTER,
+ id: "2:1",
+ parentId: "0:1",
+ parentKey: FIRST_POSITION,
+ data: "1",
+ },
+ ]);
+
+ expectStorage({
+ items: ["1", "0"],
+ });
+
+ expect(callback).toHaveBeenCalledWith([
+ {
+ node: items,
+ type: "LiveList",
+ updates: [{ type: "insert", index: 0, item: "1" }],
+ },
+ ]);
+ });
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts
deleted file mode 100644
index a5c09af925..0000000000
--- a/packages/liveblocks-core/src/crdts/__tests__/LiveList.test.ts
+++ /dev/null
@@ -1,1679 +0,0 @@
-import { describe, expect, onTestFinished, test, vi } from "vitest";
-
-import {
- listUpdate,
- listUpdateDelete,
- listUpdateInsert,
- listUpdateMove,
-} from "../../__tests__/_updatesUtils";
-import {
- createSerializedList,
- createSerializedObject,
- createSerializedRegister,
- createSerializedRoot,
- FIFTH_POSITION,
- FIRST_POSITION,
- FOURTH_POSITION,
- prepareIsolatedStorageTest,
- prepareStorageTest,
- prepareStorageUpdateTest,
- replaceRemoteStorageAndReconnect,
- SECOND_POSITION,
- THIRD_POSITION,
-} from "../../__tests__/_utils";
-import {
- waitUntilStatus,
- waitUntilStorageUpdate,
-} from "../../__tests__/_waitUtils";
-import { kInternal } from "../../internal";
-import { Permission } from "../../protocol/AuthToken";
-import { OpCode } from "../../protocol/Op";
-import type { StorageNode } from "../../protocol/StorageNode";
-import { CrdtType } from "../../protocol/StorageNode";
-import { WebsocketCloseCodes } from "../../types/IWebSocket";
-import { LiveList } from "../LiveList";
-import { LiveMap } from "../LiveMap";
-import { LiveObject } from "../LiveObject";
-
-describe("LiveList", () => {
- describe("not attached", () => {
- test("basic operations with native objects", () => {
- const list = new LiveList(["first", "second", "third"]);
- expect(list.get(0)).toEqual("first");
- expect(list.length).toBe(3);
-
- expect(list.toArray()).toEqual(["first", "second", "third"]);
-
- expect(Array.from(list)).toEqual(["first", "second", "third"]);
-
- expect(list.map((item) => item.toUpperCase())).toEqual([
- "FIRST",
- "SECOND",
- "THIRD",
- ]);
-
- expect(list.filter((item) => item.endsWith("d"))).toEqual([
- "second",
- "third",
- ]);
-
- expect(list.findIndex((item) => item.startsWith("s"))).toEqual(1);
-
- expect(list.some((item) => item.startsWith("x"))).toEqual(false);
-
- expect(list.indexOf("quatre")).toEqual(-1);
- expect(list.indexOf("third")).toEqual(2);
-
- list.delete(0);
-
- expect(list.toArray()).toEqual(["second", "third"]);
- expect(list.get(2)).toBe(undefined);
- expect(list.length).toBe(2);
-
- list.clear();
- expect(list.toArray()).toEqual([]);
- });
- });
-
- describe("deserialization", () => {
- test("create document with list in root", async () => {
- const { expectStorage } = await prepareIsolatedStorageTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ]);
-
- expectStorage({
- items: [],
- });
- });
-
- test("init list with items", async () => {
- const { expectStorage } = await prepareIsolatedStorageTest<{
- items: LiveList>;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", { a: 0 }, "0:1", FIRST_POSITION),
- createSerializedObject("0:3", { a: 1 }, "0:1", SECOND_POSITION),
- createSerializedObject("0:4", { a: 2 }, "0:1", THIRD_POSITION),
- ]);
-
- expectStorage({
- items: [{ a: 0 }, { a: 1 }, { a: 2 }],
- });
- });
- });
-
- describe("push", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.push("first")).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- describe("updates", () => {
- test("push on empty list update", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ]);
-
- root.get("items").push("a");
- room.history.undo();
- room.history.redo();
-
- expectUpdates([
- [listUpdate(["a"], [listUpdateInsert(0, "a")])],
- [listUpdate([], [listUpdateDelete(0, "a")])],
- [listUpdate(["a"], [listUpdateInsert(0, "a")])],
- ]);
- });
- });
-
- test("push LiveObject on empty list", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expectStorage({
- items: [],
- });
-
- items.push(new LiveObject({ a: 0 }));
-
- expectStorage({
- items: [{ a: 0 }],
- });
-
- assertUndoRedo();
- });
-
- test("push number on empty list", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.toObject().items;
-
- expectStorage({ items: [] });
-
- items.push(0);
- expectStorage({ items: [0] });
-
- assertUndoRedo();
- });
-
- test("push LiveMap on empty list", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expectStorage({ items: [] });
-
- items.push(new LiveMap([["first", 0]]));
-
- expectStorage({ items: [new Map([["first", 0]])] });
-
- assertUndoRedo();
- });
-
- test("push already attached LiveObject should throw", async () => {
- const { root } = await prepareIsolatedStorageTest<{
- items: LiveList>;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
-
- const items = root.toObject().items;
-
- const object = new LiveObject({ a: 0 });
-
- items.push(object);
- expect(() => items.push(object)).toThrow();
- });
- });
-
- describe("insert", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.insert("first", 0)).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- describe("updates", () => {
- test("insert at the middle update", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "C"),
- ]);
-
- root.get("items").insert("B", 1);
- room.history.undo();
- room.history.redo();
-
- expectUpdates([
- [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])],
- [listUpdate(["A", "C"], [listUpdateDelete(1, "B")])],
- [listUpdate(["A", "B", "C"], [listUpdateInsert(1, "B")])],
- ]);
- });
- });
-
- test("insert LiveObject at position 0", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION),
- ],
- 1
- );
-
- expectStorage({
- items: [{ a: 1 }],
- });
-
- const root = storage.root;
- const items = root.toObject().items;
-
- items.insert(new LiveObject({ a: 0 }), 0);
-
- expectStorage({ items: [{ a: 0 }, { a: 1 }] });
-
- assertUndoRedo();
- });
- });
-
- describe("delete", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.delete(0)).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- describe("updates", () => {
- test("delete first update", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- ]);
-
- root.get("items").delete(0);
- room.history.undo();
- room.history.redo();
-
- expectUpdates([
- [listUpdate([], [listUpdateDelete(0, "A")])],
- [listUpdate(["A"], [listUpdateInsert(0, "A")])],
- [listUpdate([], [listUpdateDelete(0, "A")])],
- ]);
- });
- });
-
- test("delete first item", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- ]);
-
- const root = storage.root;
- const items = root.toObject().items;
-
- expectStorage({
- items: ["A", "B"],
- });
-
- items.delete(0);
-
- expectStorage({
- items: ["B"],
- });
-
- assertUndoRedo();
- });
-
- test("delete should remove descendants", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList }>>;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", {}, "0:1", "!"),
- createSerializedObject("0:3", { a: 0 }, "0:2", "child"),
- ]);
-
- expectStorage({
- items: [{ child: { a: 0 } }],
- });
-
- storage.root.toObject().items.delete(0);
-
- expectStorage({
- items: [],
- });
-
- // Ensure that LiveStructure are deleted properly
- expect(room[kInternal].nodeCount).toBe(2);
-
- assertUndoRedo();
- });
- });
-
- describe("move", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.move(0, 1)).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- describe("updates", () => {
- test("move at the end update", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- ]);
-
- root.get("items").move(0, 1);
- room.history.undo();
- room.history.redo();
-
- expectUpdates([
- [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])],
- [listUpdate(["A", "B"], [listUpdateMove(1, 0, "A")])],
- [listUpdate(["B", "A"], [listUpdateMove(0, 1, "A")])],
- ]);
- });
- });
-
- test("move after current position", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ]);
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- const root = storage.root;
- const items = root.toObject().items;
- items.move(0, 1);
-
- expectStorage({ items: ["B", "A", "C"] });
-
- assertUndoRedo();
- });
-
- test("move before current position", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ],
- 1
- );
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- const items = storage.root.get("items");
-
- items.move(0, 1);
- expectStorage({
- items: ["B", "A", "C"],
- });
-
- assertUndoRedo();
- });
-
- test("move at the end of the list", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ]);
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- const root = storage.root;
- const items = root.toObject().items;
- items.move(0, 2);
-
- expectStorage({
- items: ["B", "C", "A"],
- });
-
- assertUndoRedo();
- });
- });
-
- describe("clear", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{ items: LiveList }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.clear()).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- describe("updates", () => {
- test("clear updates", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
- items: LiveList;
- }>([
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- ]);
-
- root.get("items").clear();
- room.history.undo();
- room.history.redo();
-
- expectUpdates([
- [
- listUpdate(
- [],
- [listUpdateDelete(0, "A"), listUpdateDelete(0, "B")]
- ),
- ],
- [
- listUpdate(
- ["A", "B"],
- [listUpdateInsert(0, "A"), listUpdateInsert(1, "B")]
- ),
- ],
- // Because redo reverse the operations, we delete items from the end
- [
- listUpdate(
- [],
- [listUpdateDelete(1, "B"), listUpdateDelete(0, "A")]
- ),
- ],
- ]);
- });
- });
-
- test("clear should delete all items", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- items.clear();
- expectStorage({
- items: [],
- });
-
- assertUndoRedo();
- });
- });
-
- describe("batch", () => {
- test("batch multiple inserts", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = storage.root.get("items");
-
- expectStorage({ items: [] });
-
- room.batch(() => {
- items.push("A");
- items.push("B");
- });
-
- expectStorage(
- { items: ["A", "B"] }
- // Updates are not tested here because undo/redo is not symetric
- );
-
- assertUndoRedo();
- });
- });
-
- describe("set", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{ items: LiveList }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
- );
-
- const root = storage.root;
- const items = root.get("items");
-
- expect(() => items.set(0, "A")).toThrow(
- "Cannot write to storage with a read only user, please ensure the user has write permissions"
- );
- });
-
- test("set register on detached list", () => {
- const list = new LiveList(["A", "B", "C"]);
- list.set(0, "D");
- expect(list.toArray()).toEqual(["D", "B", "C"]);
- });
-
- test("set at invalid position should throw", () => {
- const list = new LiveList(["A", "B", "C"]);
- expect(() => list.set(-1, "D")).toThrow(
- 'Cannot set list item at index "-1". index should be between 0 and 2'
- );
- expect(() => list.set(3, "D")).toThrow(
- 'Cannot set list item at index "3". index should be between 0 and 2'
- );
- });
-
- test("set register", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "A"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "B"),
- createSerializedRegister("0:4", "0:1", THIRD_POSITION, "C"),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.toObject().items;
-
- expectStorage({ items: ["A", "B", "C"] });
-
- items.set(0, "D");
- expectStorage({ items: ["D", "B", "C"] });
-
- items.set(1, "E");
- expectStorage({ items: ["D", "E", "C"] });
-
- assertUndoRedo();
- });
-
- test("set nested object", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION),
- ],
- 1
- );
-
- const root = storage.root;
- const items = root.toObject().items;
-
- expectStorage({ items: [{ a: 1 }] });
-
- items.set(0, new LiveObject({ a: 2 }));
- expectStorage({ items: [{ a: 2 }] });
-
- assertUndoRedo();
- });
- });
-
- describe("conflict", () => {
- test("list conflicts", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- // Register id = 1:0
- items.push("0");
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:1",
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "1",
- },
- ]);
-
- expectStorage({
- items: ["1", "0"],
- });
-
- // Fix from backend
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:0",
- parentKey: SECOND_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["1", "0"],
- });
- });
-
- test("list conflicts 2", async () => {
- const { root, applyRemoteOperations, expectStorage } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- items.push("x0"); // Register id = 1:0
- items.push("x1"); // Register id = 1:1
-
- // Should go to pending
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:0",
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "y0",
- },
- ]);
-
- expectStorage({
- items: ["y0", "x0", "x1"],
- });
-
- // Should go to pending
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:1",
- parentId: "0:1",
- parentKey: SECOND_POSITION,
- data: "y1",
- },
- ]);
-
- expectStorage({
- items: ["y0", "x0", "y1", "x1"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:0",
- parentKey: THIRD_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["y0", "y1", "x0", "x1"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:1",
- parentKey: FOURTH_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["y0", "y1", "x0", "x1"],
- });
- });
-
- test("list conflicts with offline", async () => {
- const { room, root, expectStorage, applyRemoteOperations, wss } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- // Register id = 1:0
- items.push("0");
-
- expectStorage({
- items: ["0"],
- });
-
- replaceRemoteStorageAndReconnect(wss, [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("2:0", "0:1", FIRST_POSITION, "1"),
- ]);
-
- await waitUntilStorageUpdate(room);
- expectStorage({
- items: ["1", "0"],
- });
-
- // Fix from backend
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:0",
- parentKey: SECOND_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["1", "0"],
- });
- });
-
- test("list conflicts with undo redo and remote change", async () => {
- const { root, expectStorage, applyRemoteOperations, room, wss } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- wss.last.close(
- new CloseEvent("close", {
- code: WebsocketCloseCodes.CLOSE_ABNORMAL,
- wasClean: false,
- })
- );
- await waitUntilStatus(room, "connected");
-
- const items = root.get("items");
-
- items.push("0");
- expectStorage({ items: ["0"] });
-
- room.history.undo();
- expectStorage({ items: [] });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "1:1",
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "1",
- },
- ]);
- expectStorage({ items: [] });
-
- room.history.redo();
- expectStorage({ items: ["0"] });
-
- await waitUntilStorageUpdate(room);
- expectStorage({ items: ["1", "0"] });
- });
-
- test("list conflicts - move", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- // Register id = 1:0
- items.push("A");
- // Register id = 1:1
- items.push("B");
- // Register id = 1:2
- items.push("C");
-
- expectStorage({
- items: ["A", "B", "C"],
- });
-
- items.move(0, 2);
-
- expectStorage({
- items: ["B", "C", "A"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:1",
- parentKey: FOURTH_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["C", "B", "A"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "1:0",
- parentKey: FIFTH_POSITION,
- },
- ]);
-
- expectStorage({
- items: ["C", "B", "A"],
- });
- });
-
- test("list conflicts - ack has different position that local item", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- items.push("B");
-
- expectStorage({
- items: ["B"],
- });
-
- // Other client created "A" at the same time but was processed first by the server.
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:0",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "A",
- },
- ]);
- // B is shifted to SECOND_POSITION
-
- // Other client deleted "A" right after creation.
- applyRemoteOperations([
- {
- type: OpCode.DELETE_CRDT,
- id: "2:0",
- },
- ]);
-
- expectStorage({
- items: ["B"], // "B" is at SECOND_POSITION
- });
-
- // Server sends ackownledgment for "B" creation with different position/
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "1:0",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- opId: "1:0", // Ack
- },
- ]);
-
- expectStorage({
- items: ["B"], // "B" should at FIRST_POSITION
- });
-
- // Other client creates an item at the SECOND_POSITION
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:0",
- parentId: "0:0",
- parentKey: SECOND_POSITION,
- data: "C",
- },
- ]);
-
- expectStorage({
- items: ["B", "C"],
- });
- });
-
- test("list conflicts - ack has different position that local and ack position is used", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- items.push("B");
-
- expectStorage({
- items: ["B"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:0",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "A",
- },
- ]);
-
- applyRemoteOperations([
- {
- type: OpCode.DELETE_CRDT,
- id: "2:0",
- },
- ]);
-
- items.insert("C", 0); // Insert at FIRST_POSITION
-
- // Ack
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "1:0",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- opId: "1:0", // Ack
- },
- ]);
-
- expectStorage({
- items: ["B", "C"], // C position is shifted
- });
- });
-
- // Regression test: #applySetChildKeyAck must return modified when restoring
- // items from implicitlyDeletedItems, otherwise subscriptions won't fire.
- test("restoring item from implicitlyDeletedItems triggers subscription", async () => {
- const { room, root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- createSerializedRegister("0:1", "0:0", FIRST_POSITION, "a"),
- createSerializedRegister("0:2", "0:0", SECOND_POSITION, "b"),
- createSerializedRegister("0:3", "0:0", THIRD_POSITION, "c"),
- ],
- 1
- );
-
- const items = root.get("items");
- items.delete(0);
- items.move(1, 0);
- expectStorage({ items: ["c", "b"] });
-
- // Remote set at "a"'s position moves "c" to implicitlyDeletedItems
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:0",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "X",
- intent: "set",
- deletedId: "0:1",
- },
- ]);
- expectStorage({ items: ["X", "b"] });
-
- // Start listening for subscription updates
- const onStorage = vi.fn();
- onTestFinished(room.events.storageBatch.subscribe(onStorage));
-
- // Ack restores "c" from implicitlyDeletedItems - this MUST trigger subscription
- applyRemoteOperations([
- {
- type: OpCode.SET_PARENT_KEY,
- id: "0:3",
- parentKey: SECOND_POSITION,
- opId: "1:1",
- },
- ]);
-
- expectStorage({ items: ["X", "c", "b"] });
- expect(onStorage).toHaveBeenCalled();
- });
- });
-
- describe("subscriptions", () => {
- test("batch multiple actions", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- ],
- 1
- );
-
- const callback = vi.fn();
- onTestFinished(room.events.storageBatch.subscribe(callback));
-
- const root = storage.root;
- const liveList = root.get("items");
-
- room.batch(() => {
- liveList.push("b");
- liveList.push("c");
- });
-
- expectStorage({ items: ["a", "b", "c"] });
-
- expect(callback).toHaveBeenCalledTimes(1);
- expect(callback).toHaveBeenCalledWith([
- {
- node: liveList,
- type: "LiveList",
- updates: [
- { index: 1, item: "b", type: "insert" },
- { index: 2, item: "c", type: "insert" },
- ],
- },
- ]);
- });
-
- test("batch multiple inserts", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- ],
- 1
- );
-
- const callback = vi.fn();
- onTestFinished(room.events.storageBatch.subscribe(callback));
-
- const root = storage.root;
- const liveList = root.get("items");
-
- room.batch(() => {
- liveList.insert("b", 1);
- liveList.insert("c", 2);
- });
-
- expectStorage({ items: ["a", "b", "c"] });
-
- expect(callback).toHaveBeenCalledTimes(1);
- });
- });
-
- describe("reconnect with remote changes and subscribe", () => {
- test("register added to list", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- ],
- 1
- );
-
- const rootCallback = vi.fn();
- const rootDeepCallback = vi.fn();
- const listCallback = vi.fn();
-
- const listItems = root.get("items");
-
- onTestFinished(room.subscribe(root, rootCallback));
- onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
- onTestFinished(room.subscribe(listItems, listCallback));
-
- expectStorage({ items: ["a"] });
-
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
- [
- "0:2",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "a",
- },
- ],
- [
- "2:0",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: SECOND_POSITION,
- data: "b",
- },
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
-
- await waitUntilStorageUpdate(room);
- expectStorage({
- items: ["a", "b"],
- });
-
- listItems.push("c");
-
- expectStorage({
- items: ["a", "b", "c"],
- });
-
- expect(rootCallback).toHaveBeenCalledTimes(0);
-
- expect(rootDeepCallback).toHaveBeenCalledTimes(2);
-
- expect(rootDeepCallback).toHaveBeenCalledWith([
- {
- type: "LiveList",
- node: listItems,
- updates: [{ index: 1, item: "b", type: "insert" }],
- },
- ]);
- expect(rootDeepCallback).toHaveBeenCalledWith([
- {
- type: "LiveList",
- node: listItems,
- updates: [{ index: 2, item: "c", type: "insert" }],
- },
- ]);
- expect(listCallback).toHaveBeenCalledTimes(2);
- });
-
- test("register moved in list", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- ],
- 1
- );
-
- const rootCallback = vi.fn();
- const rootDeepCallback = vi.fn();
- const listCallback = vi.fn();
-
- const listItems = root.get("items");
-
- onTestFinished(room.subscribe(root, rootCallback));
- onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
- onTestFinished(room.subscribe(listItems, listCallback));
-
- expectStorage({ items: ["a", "b"] });
-
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
- [
- "0:2",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: SECOND_POSITION,
- data: "a",
- },
- ],
- [
- "0:3",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "b",
- },
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
-
- await waitUntilStorageUpdate(room);
- expectStorage({
- items: ["b", "a"],
- });
-
- expect(rootCallback).toHaveBeenCalledTimes(0);
-
- expect(rootDeepCallback).toHaveBeenCalledTimes(1);
-
- expect(rootDeepCallback).toHaveBeenCalledWith([
- {
- type: "LiveList",
- node: listItems,
- updates: [{ index: 0, previousIndex: 1, item: "b", type: "move" }],
- },
- ]);
-
- expect(listCallback).toHaveBeenCalledTimes(1);
- });
-
- test("register deleted from list", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedRegister("0:2", "0:1", FIRST_POSITION, "a"),
- createSerializedRegister("0:3", "0:1", SECOND_POSITION, "b"),
- ],
- 1
- );
-
- const rootCallback = vi.fn();
- const rootDeepCallback = vi.fn();
- const listCallback = vi.fn();
-
- const listItems = root.get("items");
-
- onTestFinished(room.subscribe(root, rootCallback));
- onTestFinished(room.subscribe(root, rootDeepCallback, { isDeep: true }));
- onTestFinished(room.subscribe(listItems, listCallback));
-
- expectStorage({ items: ["a", "b"] });
-
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- ["0:1", { type: CrdtType.LIST, parentId: "root", parentKey: "items" }],
- [
- "0:2",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "a",
- },
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
-
- await waitUntilStorageUpdate(room);
- expectStorage({
- items: ["a"],
- });
-
- expect(rootCallback).toHaveBeenCalledTimes(0);
-
- expect(rootDeepCallback).toHaveBeenCalledTimes(1);
-
- expect(rootDeepCallback).toHaveBeenCalledWith([
- {
- type: "LiveList",
- node: listItems,
- updates: [{ index: 1, type: "delete", deletedItem: "b" }],
- },
- ]);
-
- expect(listCallback).toHaveBeenCalledTimes(1);
- });
- });
-
- describe("internal methods", () => {
- test("_detachChild", async () => {
- const { root } = await prepareIsolatedStorageTest<{
- items: LiveList>;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- createSerializedObject("0:2", { a: 1 }, "0:1", FIRST_POSITION),
- createSerializedObject("0:3", { a: 2 }, "0:1", SECOND_POSITION),
- ],
- 1
- );
-
- const items = root.get("items");
- const secondItem = items.get(1);
-
- const applyResult = items._detachChild(secondItem!);
-
- expect(applyResult).toEqual({
- modified: {
- type: "LiveList",
- node: items,
- updates: [{ index: 1, type: "delete", deletedItem: secondItem }],
- },
- reverse: [
- {
- data: { a: 2 },
- id: "0:3",
- parentId: "0:1",
- parentKey: SECOND_POSITION,
- type: OpCode.CREATE_OBJECT,
- },
- ],
- });
- });
-
- describe("apply CreateRegister", () => {
- test('with intent "set" should replace existing item', async () => {
- const { expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
- ],
- 1
- );
-
- expectStorage({
- items: ["A"],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "0:2",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- intent: "set",
- },
- ]);
-
- expectStorage({
- items: ["B"],
- });
- });
-
- test('with intent "set" should notify with a "set" update', async () => {
- const { room, root, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- const callback = vi.fn();
- onTestFinished(room.events.storageBatch.subscribe(callback));
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "0:2",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- intent: "set",
- },
- ]);
-
- expect(callback).toHaveBeenCalledWith([
- {
- node: items,
- type: "LiveList",
- updates: [{ type: "set", index: 0, item: "B" }],
- },
- ]);
- });
-
- test('with intent "set" should insert item if conflict with a delete operation', async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- expectStorage({
- items: ["A"],
- });
-
- items.delete(0);
-
- expectStorage({
- items: [],
- });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "0:2",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- intent: "set",
- },
- ]);
-
- expectStorage({
- items: ["B"],
- });
- });
-
- test('with intent "set" should notify with a "insert" update if no item exists at this position', async () => {
- const { room, root, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:0", "root", "items"),
- createSerializedRegister("0:1", "0:0", FIRST_POSITION, "A"),
- ],
- 1
- );
-
- const items = root.get("items");
- items.delete(0);
-
- const callback = vi.fn();
- onTestFinished(room.subscribe(items, callback, { isDeep: true }));
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "0:2",
- parentId: "0:0",
- parentKey: FIRST_POSITION,
- data: "B",
- intent: "set",
- },
- ]);
-
- expect(callback).toHaveBeenCalledWith([
- {
- node: items,
- type: "LiveList",
- updates: [{ type: "insert", index: 0, item: "B" }],
- },
- ]);
- });
-
- test("on existing position should give the right update", async () => {
- const { room, root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ items: LiveList }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
-
- const items = root.get("items");
-
- // Register id = 1:0
- items.push("0");
-
- expectStorage({
- items: ["0"],
- });
-
- const callback = vi.fn();
- onTestFinished(room.subscribe(items, callback, { isDeep: true }));
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_REGISTER,
- id: "2:1",
- parentId: "0:1",
- parentKey: FIRST_POSITION,
- data: "1",
- },
- ]);
-
- expectStorage({
- items: ["1", "0"],
- });
-
- expect(callback).toHaveBeenCalledWith([
- {
- node: items,
- type: "LiveList",
- updates: [{ type: "insert", index: 0, item: "1" }],
- },
- ]);
- });
- });
- });
-});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts
similarity index 52%
rename from packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts
rename to packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts
index 8dc631daa8..0ef44b83f6 100644
--- a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.test.ts
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.devserver.test.ts
@@ -1,21 +1,17 @@
+/**
+ * LiveMap tests that run against the real dev server.
+ *
+ * For edge cases that require precise control over wire-level ops (internal
+ * methods), see LiveMap.mockserver.test.ts.
+ */
import { describe, expect, test, vi } from "vitest";
import {
- createSerializedList,
- createSerializedMap,
- createSerializedObject,
- createSerializedRegister,
- createSerializedRoot,
prepareIsolatedStorageTest,
prepareStorageTest,
- replaceRemoteStorageAndReconnect,
-} from "../../__tests__/_utils";
-import { waitUntilStorageUpdate } from "../../__tests__/_waitUtils";
+ replaceStorageAndReconnectDevServer,
+} from "../../__tests__/_devserver";
import { kInternal } from "../../internal";
-import { Permission } from "../../protocol/AuthToken";
-import { OpCode } from "../../protocol/Op";
-import type { StorageNode } from "../../protocol/StorageNode";
-import { CrdtType } from "../../protocol/StorageNode";
import { LiveList } from "../LiveList";
import { LiveMap } from "../LiveMap";
import { LiveObject } from "../LiveObject";
@@ -98,51 +94,63 @@ describe("LiveMap", () => {
});
test("create document with map in root", async () => {
- const { storage, expectStorage } = await prepareStorageTest<{
+ const { root, expectStorage } = await prepareIsolatedStorageTest<{
map: LiveMap>;
- }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")]);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- const root = storage.root;
- const map = root.toObject().map;
+ const map = root.get("map");
expect(Array.from(map.entries())).toEqual([]);
expectStorage({ map: new Map() });
});
- test("set throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("set throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
map: LiveMap>;
- }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")], 1, [
- Permission.Read,
- Permission.PresenceWrite,
- ]);
+ }>(
+ {
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
+ );
- const map = storage.root.get("map");
+ const map = root.get("map");
expect(() => map.set("key", new LiveObject({ a: 0 }))).toThrow(
"Cannot write to storage with a read only user, please ensure the user has write permissions"
);
});
test("init map with items", async () => {
- const { storage, expectStorage } = await prepareStorageTest<{
+ const { root, expectStorage } = await prepareIsolatedStorageTest<{
map: LiveMap>;
- }>([
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedObject("0:2", { a: 0 }, "0:1", "first"),
- createSerializedObject("0:3", { a: 1 }, "0:1", "second"),
- createSerializedObject("0:4", { a: 2 }, "0:1", "third"),
- ]);
-
- const root = storage.root;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: {
+ first: { liveblocksType: "LiveObject", data: { a: 0 } },
+ second: { liveblocksType: "LiveObject", data: { a: 1 } },
+ third: { liveblocksType: "LiveObject", data: { a: 2 } },
+ },
+ },
+ },
+ });
+
const map = root.get("map");
- expect(
- Array.from(map.entries()).map((entry) => [entry[0], entry[1].toObject()])
- ).toMatchObject([
- ["first", { a: 0 }],
- ["second", { a: 1 }],
- ["third", { a: 2 }],
- ]);
+ expect(map.toImmutable()).toEqual(
+ new Map([
+ ["first", { a: 0 }],
+ ["second", { a: 1 }],
+ ["third", { a: 2 }],
+ ])
+ );
expectStorage({
map: new Map([
@@ -154,26 +162,26 @@ describe("LiveMap", () => {
});
test("map.set object", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap;
- }>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- const root = storage.root;
- const map = root.toObject().map;
+ const root = storageA.root;
+ const map = root.get("map");
- expectStorage({ map: new Map() });
+ await expectStorage({ map: new Map() });
map.set("first", 0);
- expectStorage({
+ await expectStorage({
map: new Map([["first", 0]]),
});
map.set("second", 1);
- expectStorage({
+ await expectStorage({
map: new Map([
["first", 0],
["second", 1],
@@ -181,7 +189,7 @@ describe("LiveMap", () => {
});
map.set("third", 2);
- expectStorage({
+ await expectStorage({
map: new Map([
["first", 0],
["second", 1],
@@ -189,41 +197,47 @@ describe("LiveMap", () => {
]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
describe("delete", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
map: LiveMap;
}>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1,
- [Permission.Read, Permission.PresenceWrite]
+ {
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
);
- const map = storage.root.get("map");
+ const map = root.get("map");
expect(() => map.delete("key")).toThrow(
"Cannot write to storage with a read only user, please ensure the user has write permissions"
);
});
test("should delete LiveObject", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap;
- }>([
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedRegister("0:2", "0:1", "first", 0),
- createSerializedRegister("0:3", "0:1", "second", 1),
- createSerializedRegister("0:4", "0:1", "third", 2),
- ]);
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: 0, second: 1, third: 2 },
+ },
+ },
+ });
- const root = storage.root;
- const map = root.toObject().map;
+ const root = storageA.root;
+ const map = root.get("map");
- expectStorage({
+ await expectStorage({
map: new Map([
["first", 0],
["second", 1],
@@ -232,7 +246,7 @@ describe("LiveMap", () => {
});
map.delete("first");
- expectStorage({
+ await expectStorage({
map: new Map([
["second", 1],
["third", 2],
@@ -240,92 +254,99 @@ describe("LiveMap", () => {
});
map.delete("second");
- expectStorage({
+ await expectStorage({
map: new Map([["third", 2]]),
});
map.delete("third");
- expectStorage({
+ await expectStorage({
map: new Map(),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("should remove nested data structure from cache", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
+ const { roomA, storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap>;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedObject("0:2", { a: 0 }, "0:1", "first"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: {
+ first: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ },
+ },
+ });
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- const root = storage.root;
- const map = root.toObject().map;
+ const root = storageA.root;
+ const map = root.get("map");
- expect(room[kInternal].nodeCount).toBe(3);
+ expect(roomA[kInternal].nodeCount).toBe(3);
expect(map.delete("first")).toBe(true);
- expect(room[kInternal].nodeCount).toBe(2);
+ expect(roomA[kInternal].nodeCount).toBe(2);
- expectStorage({
+ await expectStorage({
map: new Map(),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("should delete live list", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{ map: LiveMap> }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedList("0:2", "0:1", "first"),
- createSerializedRegister("0:3", "0:2", "!", 0),
- ],
- 1
- );
+ const { roomA, storageA, expectStorage, assertUndoRedo } =
+ await prepareStorageTest<{
+ map: LiveMap>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: {
+ first: { liveblocksType: "LiveList", data: [0] },
+ },
+ },
+ },
+ });
- expectStorage({
+ await expectStorage({
map: new Map([["first", [0]]]),
});
- const root = storage.root;
- const map = root.toObject().map;
+ const root = storageA.root;
+ const map = root.get("map");
- expect(room[kInternal].nodeCount).toBe(4);
+ expect(roomA[kInternal].nodeCount).toBe(4);
expect(map.delete("first")).toBe(true);
- expect(room[kInternal].nodeCount).toBe(2);
+ expect(roomA[kInternal].nodeCount).toBe(2);
- expectStorage({
+ await expectStorage({
map: new Map(),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
// https://github.com/liveblocks/liveblocks/issues/95
test("should have deleted key when subscriber is called", async () => {
const { room, root } = await prepareIsolatedStorageTest<{
map: LiveMap;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedRegister("0:2", "0:1", "first", "a"),
- createSerializedRegister("0:3", "0:1", "second", "b"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: "a", second: "b" },
+ },
+ },
+ });
const map = root.get("map");
@@ -341,15 +362,15 @@ describe("LiveMap", () => {
test("should call subscribe when key is deleted", async () => {
const { room, root } = await prepareIsolatedStorageTest<{
map: LiveMap;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedRegister("0:2", "0:1", "first", "a"),
- createSerializedRegister("0:3", "0:1", "second", "b"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: "a", second: "b" },
+ },
+ },
+ });
const map = root.get("map");
@@ -366,15 +387,15 @@ describe("LiveMap", () => {
test("should not call subscribe when key is not deleted", async () => {
const { room, root } = await prepareIsolatedStorageTest<{
map: LiveMap;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedRegister("0:2", "0:1", "first", "a"),
- createSerializedRegister("0:3", "0:1", "second", "b"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: "a", second: "b" },
+ },
+ },
+ });
const map = root.get("map");
@@ -389,36 +410,38 @@ describe("LiveMap", () => {
});
test("map.set live object", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap>;
- }>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- const root = storage.root;
- const map = root.toObject().map;
- expectStorage({
+ const root = storageA.root;
+ const map = root.get("map");
+ await expectStorage({
map: new Map(),
});
map.set("first", new LiveObject({ a: 0 }));
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("map.set already attached live object should throw", async () => {
- const { storage } = await prepareStorageTest<{
+ const { root } = await prepareIsolatedStorageTest<{
map: LiveMap>;
- }>([createSerializedRoot(), createSerializedMap("0:1", "root", "map")]);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- const root = storage.root;
- const map = root.toObject().map;
+ const map = root.get("map");
const object = new LiveObject({ a: 0 });
@@ -427,12 +450,14 @@ describe("LiveMap", () => {
});
test("new Map with already attached live object should throw", async () => {
- const { storage } = await prepareStorageTest<{
+ const { root } = await prepareIsolatedStorageTest<{
child: LiveObject<{ a: number }> | null;
map: LiveMap> | null;
- }>([createSerializedRoot({ child: null, map: null })], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { child: null, map: null },
+ });
- const root = storage.root;
const child = new LiveObject({ a: 0 });
root.update({ child });
@@ -440,140 +465,153 @@ describe("LiveMap", () => {
});
test("map.set live object on existing key", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap>;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedObject("0:2", { a: 0 }, "0:1", "first"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: {
+ first: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ },
+ },
+ });
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- const root = storage.root;
- const map = root.toObject().map;
+ const root = storageA.root;
+ const map = root.get("map");
map.set("first", new LiveObject({ a: 1 }));
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 1 }]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("attach map with items to root", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map?: LiveMap;
- }>([createSerializedRoot()], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
- storage.root.set("map", new LiveMap([["first", { a: 0 }]]));
+ storageA.root.set("map", new LiveMap([["first", { a: 0 }]]));
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("attach map with live objects to root", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map?: LiveMap>;
- }>([createSerializedRoot()], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
- storage.root.set("map", new LiveMap([["first", new LiveObject({ a: 0 })]]));
+ storageA.root.set(
+ "map",
+ new LiveMap([["first", new LiveObject({ a: 0 })]])
+ );
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("attach map with objects to root", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map?: LiveMap;
- }>([createSerializedRoot()], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
- storage.root.set("map", new LiveMap([["first", { a: 0 }]]));
+ storageA.root.set("map", new LiveMap([["first", { a: 0 }]]));
- expectStorage({
+ await expectStorage({
map: new Map([["first", { a: 0 }]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("add list in map", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap>;
- }>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- expectStorage({ map: new Map() });
+ await expectStorage({ map: new Map() });
- const map = storage.root.get("map");
+ const map = storageA.root.get("map");
map.set("list", new LiveList(["itemA", "itemB", "itemC"]));
- expectStorage({
+ await expectStorage({
map: new Map([["list", ["itemA", "itemB", "itemC"]]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("add map in map", async () => {
- const { storage, expectStorage, assertUndoRedo } =
+ const { storageA, expectStorage, assertUndoRedo } =
await prepareStorageTest<{
map: LiveMap>;
- }>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
- expectStorage({ map: new Map() });
+ await expectStorage({ map: new Map() });
- const map = storage.root.get("map");
+ const map = storageA.root.get("map");
map.set("map", new LiveMap([["first", "itemA"]]));
- expectStorage({
+ await expectStorage({
map: new Map([["map", new Map([["first", "itemA"]])]]),
});
- assertUndoRedo();
+ await assertUndoRedo();
});
describe("subscriptions", () => {
test("simple action", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { room, root } = await prepareIsolatedStorageTest<{
map: LiveMap;
- }>(
- [createSerializedRoot(), createSerializedMap("0:1", "root", "map")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { map: { liveblocksType: "LiveMap", data: {} } },
+ });
const callback = vi.fn();
- const root = storage.root;
-
const liveMap = root.get("map");
room.subscribe(liveMap, callback);
@@ -585,20 +623,22 @@ describe("LiveMap", () => {
});
test("deep subscribe", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { room, root } = await prepareIsolatedStorageTest<{
map: LiveMap>;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedObject("0:2", { a: 0 }, "0:1", "mapElement"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: {
+ mapElement: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
const mapElement = root.get("map").get("mapElement");
const unsubscribe = room.subscribe(root.get("map"), callback, {
@@ -623,55 +663,42 @@ describe("LiveMap", () => {
});
describe("reconnect with remote changes and subscribe", () => {
- test("register added to map", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- map: LiveMap;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedRegister("0:2", "0:1", "first", "a"),
- ],
- 1
- );
+ // TODO: Needs atomic storage replacement in dev server
+ // See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature
+ test.skip("register added to map", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ map: LiveMap;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: "a" },
+ },
+ },
+ });
const rootDeepCallback = vi.fn();
const mapCallback = vi.fn();
- const listItems = root.get("map");
+ const map = root.get("map");
room.subscribe(root, rootDeepCallback, { isDeep: true });
- room.subscribe(listItems, mapCallback);
+ room.subscribe(map, mapCallback);
expectStorage({ map: new Map([["first", "a"]]) });
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- ["0:1", { type: CrdtType.MAP, parentId: "root", parentKey: "map" }],
- [
- "0:2",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: "first",
- data: "a",
+ await replaceStorageAndReconnectDevServer(room.id, {
+ liveblocksType: "LiveObject",
+ data: {
+ map: {
+ liveblocksType: "LiveMap",
+ data: { first: "a", second: "b" },
},
- ],
- [
- "2:0",
- {
- type: CrdtType.REGISTER,
- parentId: "0:1",
- parentKey: "second",
- data: "b",
- },
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
+ },
+ });
- await waitUntilStorageUpdate(room);
+ await vi.waitUntil(() => root.toImmutable().map.has("second"));
expectStorage({
map: new Map([
["first", "a"],
@@ -684,7 +711,7 @@ describe("LiveMap", () => {
expect(rootDeepCallback).toHaveBeenCalledWith([
{
type: "LiveMap",
- node: listItems,
+ node: map,
updates: { second: { type: "update" } },
},
]);
@@ -692,42 +719,4 @@ describe("LiveMap", () => {
expect(mapCallback).toHaveBeenCalledTimes(1);
});
});
-
- describe("internal methods", () => {
- test("_detachChild", async () => {
- const { root } = await prepareIsolatedStorageTest<{
- map: LiveMap>;
- }>(
- [
- createSerializedRoot(),
- createSerializedMap("0:1", "root", "map"),
- createSerializedObject("0:2", { a: 1 }, "0:1", "el1"),
- createSerializedObject("0:3", { a: 2 }, "0:1", "el2"),
- ],
- 1
- );
-
- const map = root.get("map");
- const secondItem = map.get("el2");
-
- const applyResult = map._detachChild(secondItem!);
-
- expect(applyResult).toEqual({
- modified: {
- node: map,
- type: "LiveMap",
- updates: { el2: { type: "delete", deletedItem: secondItem } },
- },
- reverse: [
- {
- data: { a: 2 },
- id: "0:3",
- parentId: "0:1",
- parentKey: "el2",
- type: OpCode.CREATE_OBJECT,
- },
- ],
- });
- });
- });
});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts
new file mode 100644
index 0000000000..a61189a94b
--- /dev/null
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveMap.mockserver.test.ts
@@ -0,0 +1,58 @@
+/**
+ * LiveMap tests that use a MockWebSocket server for precise control over
+ * wire-level operations. Covers internal methods that need deterministic
+ * node IDs.
+ *
+ * For normal storage/presence/history tests, see LiveMap.devserver.test.ts.
+ */
+import { describe, expect, test } from "vitest";
+
+import {
+ createSerializedMap,
+ createSerializedObject,
+ createSerializedRoot,
+ prepareIsolatedStorageTest,
+} from "../../__tests__/_MockWebSocketServer.setup";
+import { OpCode } from "../../protocol/Op";
+import type { LiveMap } from "../LiveMap";
+import type { LiveObject } from "../LiveObject";
+
+describe("LiveMap edge cases", () => {
+ describe("internal methods", () => {
+ test("_detachChild", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ map: LiveMap>;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedMap("0:1", "root", "map"),
+ createSerializedObject("0:2", { a: 1 }, "0:1", "el1"),
+ createSerializedObject("0:3", { a: 2 }, "0:1", "el2"),
+ ],
+ 1
+ );
+
+ const map = root.get("map");
+ const secondItem = map.get("el2");
+
+ const applyResult = map._detachChild(secondItem!);
+
+ expect(applyResult).toEqual({
+ modified: {
+ node: map,
+ type: "LiveMap",
+ updates: { el2: { type: "delete", deletedItem: secondItem } },
+ },
+ reverse: [
+ {
+ data: { a: 2 },
+ id: "0:3",
+ parentId: "0:1",
+ parentKey: "el2",
+ type: OpCode.CREATE_OBJECT,
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts
similarity index 55%
rename from packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts
rename to packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts
index eec2037718..ee8c570565 100644
--- a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.test.ts
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.devserver.test.ts
@@ -1,21 +1,30 @@
-import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
+/**
+ * LiveObject tests that run against the real dev server.
+ *
+ * For edge cases that require precise control over wire-level ops (ack
+ * mechanism, internal methods), see LiveObject.mockserver.test.ts.
+ */
+import {
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ onTestFinished,
+ test,
+ vi,
+} from "vitest";
-import { objectUpdate } from "../../__tests__/_updatesUtils";
import {
- createSerializedList,
- createSerializedObject,
- createSerializedRoot,
- prepareDisconnectedStorageUpdateTest,
prepareIsolatedStorageTest,
prepareStorageTest,
- replaceRemoteStorageAndReconnect,
-} from "../../__tests__/_utils";
-import { waitUntilStorageUpdate } from "../../__tests__/_waitUtils";
+ replaceStorageAndReconnectDevServer,
+} from "../../__tests__/_devserver";
+import {
+ type JsonStorageUpdate,
+ objectUpdate,
+ serializeUpdateToJson,
+} from "../../__tests__/_updatesUtils";
import { kInternal } from "../../internal";
-import { Permission } from "../../protocol/AuthToken";
-import { OpCode } from "../../protocol/Op";
-import type { StorageNode } from "../../protocol/StorageNode";
-import { CrdtType } from "../../protocol/StorageNode";
import { LiveList } from "../LiveList";
import { LiveObject } from "../LiveObject";
@@ -26,28 +35,24 @@ describe("LiveObject", () => {
});
test("should be the associated room id if attached", async () => {
- const { root } = await prepareIsolatedStorageTest(
- [createSerializedRoot()],
- 1
- );
+ const { root, room } = await prepareIsolatedStorageTest();
- expect(root.roomId).toBe("room-id");
+ expect(root.roomId).toBe(room.id);
});
test("should be null after being detached", async () => {
- const { root } = await prepareIsolatedStorageTest<{
+ const { root, room } = await prepareIsolatedStorageTest<{
child: LiveObject<{ a: number }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:0", { a: 0 }, "root", "child"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
const child = root.get("child");
- expect(child.roomId).toBe("room-id");
+ expect(child.roomId).toBe(room.id);
root.set("child", new LiveObject({ a: 1 }));
@@ -56,186 +61,200 @@ describe("LiveObject", () => {
});
test("update non existing property", async () => {
- const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest(
- [createSerializedRoot()]
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
storage.root.update({ a: 1 });
- expectStorage({
+ await expectStorage({
a: 1,
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("update non existing property with null", async () => {
- const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest(
- [createSerializedRoot()]
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
storage.root.update({ a: null });
- expectStorage({
+ await expectStorage({
a: null,
});
- assertUndoRedo();
+ await assertUndoRedo();
});
- test("update throws on read-only", async () => {
- const { storage } = await prepareStorageTest(
- [createSerializedRoot({ a: 0 })],
- 1,
- [Permission.Read, Permission.PresenceWrite]
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("update throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{ a: number }>(
+ { liveblocksType: "LiveObject", data: { a: 0 } },
+ { permissions: ["room:read", "room:presence:write"] }
);
- expect(() => storage.root.update({ a: 1 })).toThrow(
+ expect(() => root.update({ a: 1 })).toThrow(
"Cannot write to storage with a read only user, please ensure the user has write permissions"
);
});
test("update existing property", async () => {
- const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest(
- [createSerializedRoot({ a: 0 })]
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{ a: number }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
- expectStorage({ a: 0 });
+ await expectStorage({ a: 0 });
storage.root.update({ a: 1 });
- expectStorage({
+ await expectStorage({
a: 1,
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("update existing property with null", async () => {
- const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest(
- [createSerializedRoot({ a: 0 })]
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{ a: number | null }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
- expectStorage({ a: 0 });
+ await expectStorage({ a: 0 });
storage.root.update({ a: null });
- expectStorage({
+ await expectStorage({
a: null,
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("update root", async () => {
- const { storage, expectStorage, assertUndoRedo } = await prepareStorageTest(
- [createSerializedRoot({ a: 0 })]
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{ a: number; b?: number }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
- expectStorage({
+ await expectStorage({
a: 0,
});
storage.root.update({ a: 1 });
- expectStorage({
+ await expectStorage({
a: 1,
});
storage.root.update({ b: 1 });
- expectStorage({
+ await expectStorage({
a: 1,
b: 1,
});
- assertUndoRedo();
+ await assertUndoRedo();
});
- test("set throws on read-only", async () => {
- const { storage } = await prepareStorageTest([createSerializedRoot()], 1, [
- Permission.Read,
- Permission.PresenceWrite,
- ]);
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("set throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest(undefined, {
+ permissions: ["room:read", "room:presence:write"],
+ });
- expect(() => storage.root.set("a", 1)).toThrow(
+ expect(() => root.set("a", 1)).toThrow(
"Cannot write to storage with a read only user, please ensure the user has write permissions"
);
});
test("update with LiveObject", async () => {
- const { room, storage, expectStorage, operations, assertUndoRedo } =
- await prepareStorageTest<{ child: LiveObject<{ a: number }> | null }>(
- [createSerializedRoot({ child: null })],
- 1
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ child: LiveObject<{ a: number }> | null;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { child: null },
+ });
const root = storage.root;
- expectStorage({
+ await expectStorage({
child: null,
});
root.set("child", new LiveObject({ a: 0 }));
- expectStorage({
+ await expectStorage({
child: {
a: 0,
},
});
- expect(room[kInternal].undoStack[0]).toEqual([
- {
- type: OpCode.UPDATE_OBJECT,
- id: "root",
- data: {
- child: null,
- },
- },
- ]);
-
- expect(operations.length).toEqual(1);
- expect(operations).toEqual([
- {
- type: OpCode.CREATE_OBJECT,
- id: "1:0",
- opId: "1:1",
- data: { a: 0 },
- parentId: "root",
- parentKey: "child",
- },
- ]);
root.set("child", null);
- expectStorage({
+ await expectStorage({
child: null,
});
- expect(room[kInternal].undoStack[1]).toEqual([
- {
- type: OpCode.CREATE_OBJECT,
- id: "1:0",
- data: { a: 0 },
- parentId: "root",
- parentKey: "child",
- },
- ]);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("remove nested grand child record with update", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- a: number;
- child: LiveObject<{
- b: number;
- grandChild: LiveObject<{ c: number }>;
- }> | null;
- }>([
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "child"),
- createSerializedObject("0:2", { c: 0 }, "0:1", "grandChild"),
- ]);
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ a: number;
+ child: LiveObject<{
+ b: number;
+ grandChild: LiveObject<{ c: number }>;
+ }> | null;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ child: {
+ liveblocksType: "LiveObject",
+ data: {
+ b: 0,
+ grandChild: { liveblocksType: "LiveObject", data: { c: 0 } },
+ },
+ },
+ },
+ });
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 0,
@@ -247,26 +266,33 @@ describe("LiveObject", () => {
storage.root.update({ child: null });
- expectStorage({
+ await expectStorage({
a: 0,
child: null,
});
expect(room[kInternal].nodeCount).toBe(1);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("remove nested child record with update", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- a: number;
- child: LiveObject<{ b: number }> | null;
- }>([
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "child"),
- ]);
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ a: number;
+ child: LiveObject<{ b: number }> | null;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ child: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ });
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 0,
@@ -275,26 +301,33 @@ describe("LiveObject", () => {
storage.root.update({ child: null });
- expectStorage({
+ await expectStorage({
a: 0,
child: null,
});
expect(room[kInternal].nodeCount).toBe(1);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("add nested record with update", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest([createSerializedRoot()], 1);
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
storage.root.update({
child: new LiveObject({ a: 0 }),
});
- expectStorage({
+ await expectStorage({
child: {
a: 0,
},
@@ -302,20 +335,27 @@ describe("LiveObject", () => {
expect(room[kInternal].nodeCount).toBe(2);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("replace nested record with update", async () => {
- const { room, storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest([createSerializedRoot()], 1);
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest({
+ liveblocksType: "LiveObject",
+ data: {},
+ });
- expectStorage({});
+ await expectStorage({});
storage.root.update({
child: new LiveObject({ a: 0 }),
});
- expectStorage({
+ await expectStorage({
child: {
a: 0,
},
@@ -325,7 +365,7 @@ describe("LiveObject", () => {
child: new LiveObject({ a: 1 }),
});
- expectStorage({
+ await expectStorage({
child: {
a: 1,
},
@@ -333,23 +373,29 @@ describe("LiveObject", () => {
expect(room[kInternal].nodeCount).toBe(2);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("update nested record", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- a: number;
- child: LiveObject<{ b: number }>;
- }>([
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "child"),
- ]);
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ a: number;
+ child: LiveObject<{ b: number }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ child: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ });
const root = storage.root;
const child = root.toObject().child;
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 0,
@@ -357,28 +403,39 @@ describe("LiveObject", () => {
});
child.update({ b: 1 });
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 1,
},
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("update deeply nested record", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- a: number;
- child: LiveObject<{ b: number; grandChild: LiveObject<{ c: number }> }>;
- }>([
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "child"),
- createSerializedObject("0:2", { c: 0 }, "0:1", "grandChild"),
- ]);
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ a: number;
+ child: LiveObject<{ b: number; grandChild: LiveObject<{ c: number }> }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ child: {
+ liveblocksType: "LiveObject",
+ data: {
+ b: 0,
+ grandChild: { liveblocksType: "LiveObject", data: { c: 0 } },
+ },
+ },
+ },
+ });
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 0,
@@ -398,7 +455,7 @@ describe("LiveObject", () => {
grandChild.update({ c: 1 });
expect(grandChild.toObject()).toMatchObject({ c: 1 });
- expectStorage({
+ await expectStorage({
a: 0,
child: {
b: 0,
@@ -408,18 +465,31 @@ describe("LiveObject", () => {
},
});
- assertUndoRedo();
+ await assertUndoRedo();
});
describe("acknowledge mechanism", () => {
test("should not ignore history updates if the current op has not been acknowledged", async () => {
- const { room, root, expectUpdates } =
- await prepareDisconnectedStorageUpdateTest<{
- items: LiveObject<{ b?: string; a?: string }>;
- }>([
- createSerializedRoot(),
- createSerializedObject("0:1", { a: "initial" }, "root", "items"),
- ]);
+ const { room, root } = await prepareIsolatedStorageTest<{
+ items: LiveObject<{ b?: string; a?: string }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ items: {
+ liveblocksType: "LiveObject",
+ data: { a: "initial" },
+ },
+ },
+ });
+
+ const receivedUpdates: JsonStorageUpdate[][] = [];
+ onTestFinished(
+ room.subscribe(
+ root,
+ (updates) => receivedUpdates.push(updates.map(serializeUpdateToJson)),
+ { isDeep: true }
+ )
+ );
const items = root.get("items");
room.batch(() => {
@@ -428,7 +498,7 @@ describe("LiveObject", () => {
});
expect(items.toObject()).toEqual({ a: "A", b: "B" });
- expectUpdates([
+ expect(receivedUpdates).toEqual([
[
objectUpdate(
{ a: "A", b: "B" },
@@ -440,7 +510,7 @@ describe("LiveObject", () => {
room.history.undo();
expect(items.toObject()).toEqual({ a: "initial" });
- expectUpdates([
+ expect(receivedUpdates).toEqual([
[
objectUpdate(
{ a: "A", b: "B" },
@@ -455,108 +525,26 @@ describe("LiveObject", () => {
],
]);
});
-
- describe("should ignore incoming updates if the current op has not been acknowledged", () => {
- test("when value is not a crdt", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
-
- expectStorage({ a: 0 });
-
- root.set("a", 1);
-
- expectStorage({ a: 1 });
-
- applyRemoteOperations([
- {
- type: OpCode.UPDATE_OBJECT,
- data: { a: 2 },
- id: "root",
- },
- ]);
-
- expectStorage({ a: 1 });
- });
-
- test("when value is a LiveObject", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{
- a: LiveObject<{ subA: number }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { subA: 0 }, "root", "a"),
- ],
- 1
- );
-
- expectStorage({ a: { subA: 0 } });
-
- root.set("a", new LiveObject({ subA: 1 }));
-
- expectStorage({ a: { subA: 1 } });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_OBJECT,
- data: { subA: 2 },
- id: "2:0",
- parentKey: "a",
- parentId: "root",
- },
- ]);
-
- expectStorage({ a: { subA: 1 } });
- });
-
- test("when value is a LiveList with LiveObjects", async () => {
- const { root, expectStorage, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{
- a: LiveList>;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "a")],
- 1
- );
-
- expectStorage({ a: [] });
-
- const newList = new LiveList>([]);
- newList.push(new LiveObject({ b: 1 }));
- root.set("a", newList);
-
- expectStorage({ a: [{ b: 1 }] });
-
- applyRemoteOperations([
- {
- type: OpCode.CREATE_LIST,
- id: "2:0",
- parentKey: "a",
- parentId: "root",
- },
- ]);
-
- expectStorage({ a: [{ b: 1 }] });
- });
- });
});
describe("delete", () => {
- test("throws on read-only", async () => {
- const { storage } = await prepareStorageTest<{
+ // TODO: Needs read-only permission support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3528/dev-server-needs-support-for-read-only-rooms
+ test.skip("throws on read-only", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
child: LiveObject<{ a: number }>;
}>(
- [
- createSerializedRoot({ a: 1 }),
- createSerializedObject("0:1", { b: 2 }, "root", "child"),
- ],
- 1,
- [Permission.Read, Permission.PresenceWrite]
+ {
+ liveblocksType: "LiveObject",
+ data: {
+ a: 1,
+ child: { liveblocksType: "LiveObject", data: { b: 2 } },
+ },
+ },
+ { permissions: ["room:read", "room:presence:write"] }
);
- expect(() => storage.root.get("child").delete("a")).toThrow(
+ expect(() => root.get("child").delete("a")).toThrow(
"Cannot write to storage with a read only user, please ensure the user has write permissions"
);
});
@@ -568,39 +556,50 @@ describe("LiveObject", () => {
});
test("should delete property from the object", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- a?: number;
- }>([createSerializedRoot({ a: 0 })]);
- expectStorage({ a: 0 });
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ a?: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
+ await expectStorage({ a: 0 });
storage.root.delete("a");
- expectStorage({});
+ await expectStorage({});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("should delete nested crdt", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- child?: LiveObject<{ a: number }>;
- }>([
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "child"),
- ]);
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ child?: LiveObject<{ a: number }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
- expectStorage({ child: { a: 0 } });
+ await expectStorage({ child: { a: 0 } });
storage.root.delete("child");
- expectStorage({});
+ await expectStorage({});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("should not notify if property does not exist", async () => {
const { room, root } = await prepareIsolatedStorageTest<{
a?: number;
- }>([createSerializedRoot()]);
+ }>();
const callback = vi.fn();
room.subscribe(root, callback);
@@ -613,7 +612,10 @@ describe("LiveObject", () => {
test("should notify if property has been deleted", async () => {
const { room, root } = await prepareIsolatedStorageTest<{
a?: number;
- }>([createSerializedRoot({ a: 1 })]);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 1 },
+ });
const callback = vi.fn();
room.subscribe(root, callback);
@@ -625,75 +627,84 @@ describe("LiveObject", () => {
});
describe("applyDeleteObjectKey", () => {
- test("should not notify if property does not exist", async () => {
- const { room, root, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ a?: number }>([
- createSerializedRoot(),
- ]);
-
- const callback = vi.fn();
- room.subscribe(root, callback);
+ // When a remote DELETE_OBJECT_KEY arrives for a key that doesn't exist
+ // locally (because this client already deleted it), the subscription
+ // callback should NOT fire. We test this by having both clients delete
+ // an overlapping key "c" simultaneously:
+ //
+ // Start: { a, b, c } on both clients
+ // Client A: deletes a + c locally
+ // Client B: deletes b + c locally
+ //
+ // After sync, each client gets 3 notifications (not 4):
+ // - 2 from their own local deletes
+ // - 1 from the other client's non-overlapping delete
+ // - 0 from the other client's overlapping "delete c" (already gone)
+ test("should not notify for redundant remote delete", async () => {
+ const { roomA, roomB, storageA, storageB } = await prepareStorageTest<{
+ a?: number;
+ b?: number;
+ c?: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 1, b: 2, c: 3 },
+ });
- applyRemoteOperations([
- { type: OpCode.DELETE_OBJECT_KEY, id: "root", key: "a" },
- ]);
+ const callbackA = vi.fn();
+ const callbackB = vi.fn();
- expect(callback).toHaveBeenCalledTimes(0);
- });
+ roomA.subscribe(storageA.root, callbackA);
+ roomB.subscribe(storageB.root, callbackB);
- test("should notify if property has been deleted", async () => {
- const { room, root, applyRemoteOperations } =
- await prepareIsolatedStorageTest<{ a?: number }>([
- createSerializedRoot({ a: 1 }),
- ]);
+ // Both clients delete an overlapping key "c" simultaneously
+ storageA.root.delete("a");
+ storageA.root.delete("c");
- const callback = vi.fn();
- room.subscribe(root, callback);
+ storageB.root.delete("b");
+ storageB.root.delete("c");
- applyRemoteOperations([
- { type: OpCode.DELETE_OBJECT_KEY, id: "root", key: "a" },
- ]);
+ // Wait for both clients to fully sync
+ await vi.waitUntil(() => storageA.root.get("b") === undefined);
+ await vi.waitUntil(() => storageB.root.get("a") === undefined);
- expect(callback).toHaveBeenCalledTimes(1);
+ // Each client: 2 local deletes + 1 remote delete = 3
+ // The redundant remote "delete c" must NOT fire a 4th notification
+ expect(callbackA).toHaveBeenCalledTimes(3);
+ expect(callbackB).toHaveBeenCalledTimes(3);
});
});
describe("subscriptions", () => {
test("simple action", async () => {
- const { room, storage } = await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { room, root } = await prepareIsolatedStorageTest<{ a: number }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
const callback = vi.fn();
- const root = storage.root;
-
room.subscribe(root, callback);
root.set("a", 1);
expect(callback).toHaveBeenCalledTimes(1);
- expect(callback).toHaveBeenCalledWith(storage.root);
+ expect(callback).toHaveBeenCalledWith(root);
});
test("subscribe multiple actions", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { room, root } = await prepareIsolatedStorageTest<{
child: LiveObject<{ a: number }>;
child2: LiveObject<{ a: number }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "child"),
- createSerializedObject("0:2", { a: 0 }, "root", "child2"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: { liveblocksType: "LiveObject", data: { a: 0 } },
+ child2: { liveblocksType: "LiveObject", data: { a: 0 } },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
-
const unsubscribe = room.subscribe(root.get("child"), callback);
root.get("child").set("a", 1);
@@ -709,21 +720,23 @@ describe("LiveObject", () => {
});
test("deep subscribe", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { room, root } = await prepareIsolatedStorageTest<{
child: LiveObject<{ a: number; subchild: LiveObject<{ b: number }> }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "child"),
- createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: {
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ subchild: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
-
const unsubscribe = room.subscribe(root, callback, { isDeep: true });
root.get("child").set("a", 1);
@@ -751,132 +764,126 @@ describe("LiveObject", () => {
});
test("deep subscribe remote operation", async () => {
- const { room, storage, applyRemoteOperations } =
- await prepareStorageTest<{
- child: LiveObject<{
- a: number;
- subchild: LiveObject<{ b: number }>;
- }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "child"),
- createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"),
- ],
- 1
- );
+ const { roomA, storageA, storageB } = await prepareStorageTest<{
+ child: LiveObject<{
+ a: number;
+ subchild: LiveObject<{ b: number }>;
+ }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: {
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ subchild: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
+ const rootA = storageA.root;
- const unsubscribe = room.subscribe(root, callback, { isDeep: true });
+ const unsubscribe = roomA.subscribe(rootA, callback, { isDeep: true });
- root.get("child").set("a", 1);
+ rootA.get("child").set("a", 1);
- applyRemoteOperations([
- {
- type: OpCode.UPDATE_OBJECT,
- data: { b: 1 },
- id: "0:2",
- },
- ]);
+ // Remote change via client B
+ storageB.root.get("child").get("subchild").set("b", 1);
+ await vi.waitUntil(
+ () => rootA.get("child").get("subchild").get("b") === 1
+ );
unsubscribe();
- root.get("child").set("a", 2);
+ rootA.get("child").set("a", 2);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledWith([
{
type: "LiveObject",
- node: root.get("child"),
+ node: rootA.get("child"),
updates: { a: { type: "update" } },
},
]);
expect(callback).toHaveBeenCalledWith([
{
type: "LiveObject",
- node: root.get("child").get("subchild"),
+ node: rootA.get("child").get("subchild"),
updates: { b: { type: "update" } },
},
]);
});
test("subscribe subchild remote operation", async () => {
- const { room, storage, applyRemoteOperations } =
- await prepareStorageTest<{
- child: LiveObject<{
- a: number;
- subchild: LiveObject<{ b: number }>;
- }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 0 }, "root", "child"),
- createSerializedObject("0:2", { b: 0 }, "0:1", "subchild"),
- ],
- 1
- );
+ const { roomA, storageA, storageB } = await prepareStorageTest<{
+ child: LiveObject<{
+ a: number;
+ subchild: LiveObject<{ b: number }>;
+ }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: {
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ subchild: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
+ const rootA = storageA.root;
- const subchild = root.get("child").get("subchild");
+ const subchild = rootA.get("child").get("subchild");
- const unsubscribe = room.subscribe(subchild, callback);
+ const unsubscribe = roomA.subscribe(subchild, callback);
- applyRemoteOperations([
- {
- type: OpCode.UPDATE_OBJECT,
- data: { a: 1 },
- id: "0:1",
- },
- {
- type: OpCode.UPDATE_OBJECT,
- data: { b: 1 },
- id: "0:2",
- },
- ]);
+ // Remote changes via client B
+ storageB.root.get("child").set("a", 1);
+ storageB.root.get("child").get("subchild").set("b", 1);
+ await vi.waitUntil(
+ () => rootA.get("child").get("subchild").get("b") === 1
+ );
unsubscribe();
- root.get("child").get("subchild").set("b", 2);
+ rootA.get("child").get("subchild").set("b", 2);
expect(callback).toHaveBeenCalledTimes(1);
- expect(callback).toHaveBeenCalledWith(root.get("child").get("subchild"));
+ expect(callback).toHaveBeenCalledWith(rootA.get("child").get("subchild"));
});
test("deep subscribe remote and local operation - delete object key", async () => {
- const { room, storage, applyRemoteOperations } =
- await prepareStorageTest<{
- child: LiveObject<{ a?: number; b?: number }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: -1, b: -2 }, "root", "child"),
- ],
- 1
- );
+ const { roomA, storageA, storageB } = await prepareStorageTest<{
+ child: LiveObject<{ a?: number; b?: number }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ child: {
+ liveblocksType: "LiveObject",
+ data: { a: -1, b: -2 },
+ },
+ },
+ });
const callback = vi.fn();
- const root = storage.root;
+ const rootA = storageA.root;
- const unsubscribe = room.subscribe(root, callback, { isDeep: true });
+ const unsubscribe = roomA.subscribe(rootA, callback, { isDeep: true });
- // Remote deletion
- applyRemoteOperations([
- {
- type: OpCode.DELETE_OBJECT_KEY,
- key: "a",
- id: "0:1",
- },
- ]);
+ // Remote deletion via client B
+ storageB.root.get("child").delete("a");
+ await vi.waitUntil(() => rootA.get("child").get("a") === undefined);
// Local deletion
- root.get("child").delete("b");
+ rootA.get("child").delete("b");
unsubscribe();
@@ -884,32 +891,36 @@ describe("LiveObject", () => {
expect(callback).toHaveBeenNthCalledWith(1, [
{
type: "LiveObject",
- node: root.get("child"),
+ node: rootA.get("child"),
updates: { a: { type: "delete", deletedItem: -1 } },
},
]);
expect(callback).toHaveBeenNthCalledWith(2, [
{
type: "LiveObject",
- node: root.get("child"),
+ node: rootA.get("child"),
updates: { b: { type: "delete", deletedItem: -2 } },
},
]);
});
});
+ // TODO: Needs atomic storage replacement + reconnect support in dev server
+ // See https://linear.app/liveblocks/issue/LB-3529/dev-server-needs-support-for-a-crash-replace-storage-atomic-feature
+ //
+ // The dev server needs a new endpoint that atomically replaces a room's
+ // storage and disconnects all clients, forcing them to reconnect and
+ // reconcile the diff. Until then, these tests are skipped.
describe("reconnect with remote changes and subscribe", () => {
- test("LiveObject updated", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- obj: LiveObject<{ a: number }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 1 }, "root", "obj"),
- ],
- 1
- );
+ test.skip("LiveObject updated", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ obj: LiveObject<{ a: number }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ obj: { liveblocksType: "LiveObject", data: { a: 1 } },
+ },
+ });
const rootDeepCallback = vi.fn();
const liveObjectCallback = vi.fn();
@@ -919,22 +930,14 @@ describe("LiveObject", () => {
expectStorage({ obj: { a: 1 } });
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- [
- "0:1",
- {
- type: CrdtType.OBJECT,
- data: { a: 2 },
- parentId: "root",
- parentKey: "obj",
- },
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
+ await replaceStorageAndReconnectDevServer(room.id, {
+ liveblocksType: "LiveObject",
+ data: {
+ obj: { liveblocksType: "LiveObject", data: { a: 2 } },
+ },
+ });
- await waitUntilStorageUpdate(room);
+ await vi.waitUntil(() => root.get("obj").get("a") === 2);
expectStorage({
obj: { a: 2 },
});
@@ -952,17 +955,15 @@ describe("LiveObject", () => {
expect(liveObjectCallback).toHaveBeenCalledTimes(1);
});
- test("LiveObject updated nested", async () => {
- const { expectStorage, room, root, wss } =
- await prepareIsolatedStorageTest<{
- obj: LiveObject<{ a: number; subObj?: LiveObject<{ b: number }> }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", { a: 1 }, "root", "obj"),
- ],
- 1
- );
+ test.skip("LiveObject updated nested", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ obj: LiveObject<{ a: number; subObj?: LiveObject<{ b: number }> }>;
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ obj: { liveblocksType: "LiveObject", data: { a: 1 } },
+ },
+ });
const rootDeepCallback = vi.fn();
const liveObjectCallback = vi.fn();
@@ -972,31 +973,20 @@ describe("LiveObject", () => {
expectStorage({ obj: { a: 1 } });
- const newInitStorage: StorageNode[] = [
- ["root", { type: CrdtType.OBJECT, data: {} }],
- [
- "0:1",
- {
- type: CrdtType.OBJECT,
- data: { a: 1 },
- parentId: "root",
- parentKey: "obj",
- },
- ],
- [
- "0:2",
- {
- type: CrdtType.OBJECT,
- data: { b: 1 },
- parentId: "0:1",
- parentKey: "subObj",
+ await replaceStorageAndReconnectDevServer(room.id, {
+ liveblocksType: "LiveObject",
+ data: {
+ obj: {
+ liveblocksType: "LiveObject",
+ data: {
+ a: 1,
+ subObj: { liveblocksType: "LiveObject", data: { b: 1 } },
+ },
},
- ],
- ];
-
- replaceRemoteStorageAndReconnect(wss, newInitStorage);
+ },
+ });
- await waitUntilStorageUpdate(room);
+ await vi.waitUntil(() => root.get("obj").get("subObj") !== undefined);
expectStorage({
obj: { a: 1, subObj: { b: 1 } },
});
@@ -1021,7 +1011,10 @@ describe("LiveObject", () => {
test("subscription should gives the right update", async () => {
const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
a: number;
- }>([createSerializedRoot({ a: 0 })], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
expectStorage({ a: 0 });
root.set("a", 1);
@@ -1039,47 +1032,6 @@ describe("LiveObject", () => {
});
});
- describe("internal methods", () => {
- test("_detachChild", async () => {
- const { root } = await prepareIsolatedStorageTest<{
- obj: LiveObject<{
- a: LiveObject<{ subA: number }>;
- b: LiveObject<{ subA: number }>;
- }>;
- }>(
- [
- createSerializedRoot(),
- createSerializedObject("0:1", {}, "root", "obj"),
- createSerializedObject("0:2", { subA: 1 }, "0:1", "a"),
- createSerializedObject("0:3", { subA: 2 }, "0:1", "b"),
- ],
- 1
- );
-
- const obj = root.get("obj");
- const secondItem = obj.get("b");
-
- const applyResult = obj._detachChild(secondItem);
-
- expect(applyResult).toEqual({
- modified: {
- node: obj,
- type: "LiveObject",
- updates: { b: { type: "delete" } },
- },
- reverse: [
- {
- data: { subA: 2 },
- id: "0:3",
- parentId: "0:1",
- parentKey: "b",
- type: OpCode.CREATE_OBJECT,
- },
- ],
- });
- });
- });
-
describe("LiveObject.detectLargeObjects static property validation", () => {
// Create a large string of specified size in bytes
const createLargeData = (sizeInBytes: number) => {
diff --git a/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts
new file mode 100644
index 0000000000..a64403dc35
--- /dev/null
+++ b/packages/liveblocks-core/src/crdts/__tests__/LiveObject.mockserver.test.ts
@@ -0,0 +1,161 @@
+/**
+ * LiveObject tests that use a MockWebSocket server for precise control over
+ * wire-level operations. Covers ack mechanism edge cases and internal methods
+ * that need deterministic node IDs.
+ *
+ * For normal storage/presence/history tests, see LiveObject.devserver.test.ts.
+ */
+import { describe, expect, test } from "vitest";
+
+import {
+ createSerializedList,
+ createSerializedObject,
+ createSerializedRoot,
+ prepareIsolatedStorageTest,
+} from "../../__tests__/_MockWebSocketServer.setup";
+import { kInternal } from "../../internal";
+import type { ServerWireOp } from "../../protocol/Op";
+import { OpCode } from "../../protocol/Op";
+import { ServerMsgCode } from "../../protocol/ServerMsg";
+import { LiveList } from "../LiveList";
+import { LiveObject } from "../LiveObject";
+
+/**
+ * Injects server operations directly into the room's message handler,
+ * bypassing the MockWebSocket transport layer.
+ */
+function injectRemoteOps(
+ room: { [kInternal]: { simulate: { incomingMessage(data: string): void } } },
+ ops: ServerWireOp[]
+) {
+ room[kInternal].simulate.incomingMessage(
+ JSON.stringify({ type: ServerMsgCode.UPDATE_STORAGE, ops })
+ );
+}
+
+describe("LiveObject edge cases", () => {
+ describe("acknowledge mechanism", () => {
+ describe("should ignore incoming updates if the current op has not been acknowledged", () => {
+ test("when value is not a crdt", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ a: number;
+ }>([createSerializedRoot({ a: 0 })], 1);
+
+ expectStorage({ a: 0 });
+
+ root.set("a", 1);
+
+ expectStorage({ a: 1 });
+
+ injectRemoteOps(room, [
+ {
+ type: OpCode.UPDATE_OBJECT,
+ data: { a: 2 },
+ id: "root",
+ },
+ ]);
+
+ expectStorage({ a: 1 });
+ });
+
+ test("when value is a LiveObject", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ a: LiveObject<{ subA: number }>;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedObject("0:1", { subA: 0 }, "root", "a"),
+ ],
+ 1
+ );
+
+ expectStorage({ a: { subA: 0 } });
+
+ root.set("a", new LiveObject({ subA: 1 }));
+
+ expectStorage({ a: { subA: 1 } });
+
+ injectRemoteOps(room, [
+ {
+ type: OpCode.CREATE_OBJECT,
+ data: { subA: 2 },
+ id: "2:0",
+ parentKey: "a",
+ parentId: "root",
+ },
+ ]);
+
+ expectStorage({ a: { subA: 1 } });
+ });
+
+ test("when value is a LiveList with LiveObjects", async () => {
+ const { room, root, expectStorage } = await prepareIsolatedStorageTest<{
+ a: LiveList>;
+ }>(
+ [createSerializedRoot(), createSerializedList("0:1", "root", "a")],
+ 1
+ );
+
+ expectStorage({ a: [] });
+
+ const newList = new LiveList>([]);
+ newList.push(new LiveObject({ b: 1 }));
+ root.set("a", newList);
+
+ expectStorage({ a: [{ b: 1 }] });
+
+ injectRemoteOps(room, [
+ {
+ type: OpCode.CREATE_LIST,
+ id: "2:0",
+ parentKey: "a",
+ parentId: "root",
+ },
+ ]);
+
+ expectStorage({ a: [{ b: 1 }] });
+ });
+ });
+ });
+
+ describe("internal methods", () => {
+ test("_detachChild", async () => {
+ const { root } = await prepareIsolatedStorageTest<{
+ obj: LiveObject<{
+ a: LiveObject<{ subA: number }>;
+ b: LiveObject<{ subA: number }>;
+ }>;
+ }>(
+ [
+ createSerializedRoot(),
+ createSerializedObject("0:1", {}, "root", "obj"),
+ createSerializedObject("0:2", { subA: 1 }, "0:1", "a"),
+ createSerializedObject("0:3", { subA: 2 }, "0:1", "b"),
+ ],
+ 1
+ );
+
+ const obj = root.get("obj");
+ const secondItem = obj.get("b");
+
+ const applyResult = obj._detachChild(secondItem);
+
+ expect(applyResult).toEqual({
+ modified: {
+ node: obj,
+ type: "LiveObject",
+ updates: { b: { type: "delete" } },
+ },
+ reverse: [
+ {
+ data: { subA: 2 },
+ id: "0:3",
+ parentId: "0:1",
+ parentKey: "b",
+ type: OpCode.CREATE_OBJECT,
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts b/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts
index ddba103c44..605d39d4e5 100644
--- a/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts
+++ b/packages/liveblocks-core/src/crdts/__tests__/cloning.test.ts
@@ -1,26 +1,31 @@
import * as fc from "fast-check";
import { describe, expect, test } from "vitest";
+import { prepareStorageUpdateTest } from "../../__tests__/_devserver";
import {
listUpdate,
listUpdateInsert,
objectUpdate,
} from "../../__tests__/_updatesUtils";
-import {
- createSerializedList,
- createSerializedRoot,
- prepareStorageUpdateTest,
-} from "../../__tests__/_utils";
import { cloneLson } from "../../crdts/liveblocks-helpers";
+import type { LsonObject } from "../../crdts/Lson";
import type { LiveList } from "../LiveList";
+import { LiveObject } from "../LiveObject";
import { liveStructure, lson } from "./_arbitraries";
describe("cloning LiveStructures", () => {
test("basic cloning logic", async () => {
- const { root, expectUpdates, room } = await prepareStorageUpdateTest<{
+ const {
+ roomA: room,
+ rootA: root,
+ expectUpdates,
+ } = await prepareStorageUpdateTest<{
list1: LiveList;
list2: LiveList;
- }>([createSerializedRoot(), createSerializedList("0:1", "root", "list1")]);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { list1: { liveblocksType: "LiveList", data: [] } },
+ });
const list1 = root.get("list1");
list1.push("a");
@@ -32,7 +37,7 @@ describe("cloning LiveStructures", () => {
room.history.undo();
room.history.redo();
- expectUpdates([
+ await expectUpdates([
// List creation
[listUpdate(["a"], [listUpdateInsert(0, "a")])],
[listUpdate(["a", "b"], [listUpdateInsert(1, "b")])],
@@ -65,13 +70,11 @@ describe("cloning LiveStructures", () => {
test("[property] deep cloning of LiveStructures", () =>
fc.assert(
- fc.asyncProperty(
+ fc.property(
liveStructure,
- async (data) => {
- const { root } = await prepareStorageUpdateTest([
- createSerializedRoot(),
- ]);
+ (data) => {
+ const root = new LiveObject({});
// Clone "a" to "b"
root.set("a", data);
@@ -85,13 +88,11 @@ describe("cloning LiveStructures", () => {
test("[property] deep cloning of LiveStructures (twice)", () =>
fc.assert(
- fc.asyncProperty(
+ fc.property(
liveStructure,
- async (data) => {
- const { root } = await prepareStorageUpdateTest([
- createSerializedRoot(),
- ]);
+ (data) => {
+ const root = new LiveObject({});
// Clone "a" to "b"
root.set("a", data);
@@ -106,13 +107,11 @@ describe("cloning LiveStructures", () => {
test("[property] deep cloning of LSON data (= LiveStructures or JSON)", () =>
fc.assert(
- fc.asyncProperty(
+ fc.property(
lson,
- async (data) => {
- const { root } = await prepareStorageUpdateTest([
- createSerializedRoot(),
- ]);
+ (data) => {
+ const root = new LiveObject({});
// Clone "a" to "b"
root.set("a", data);
diff --git a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts
index c000e7c473..c94ae549aa 100644
--- a/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts
+++ b/packages/liveblocks-core/src/crdts/__tests__/doc.test.ts
@@ -1,13 +1,6 @@
import { describe, expect, test, vi } from "vitest";
-import {
- createSerializedList,
- createSerializedMap,
- createSerializedObject,
- createSerializedRoot,
- prepareStorageTest,
-} from "../../__tests__/_utils";
-import { OpCode } from "../../protocol/Op";
+import { prepareStorageTest } from "../../__tests__/_devserver";
import type { LiveList } from "../LiveList";
import type { LiveMap } from "../LiveMap";
import type { LiveObject } from "../LiveObject";
@@ -15,10 +8,12 @@ import type { LiveObject } from "../LiveObject";
describe("Storage", () => {
describe("subscribe generic", () => {
test("simple action", async () => {
- const { room, storage } = await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { roomA: room, storageA: storage } = await prepareStorageTest<{
+ a: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
const callback = vi.fn();
@@ -41,73 +36,81 @@ describe("Storage", () => {
});
test("remote action", async () => {
- const { room, storage, applyRemoteOperations } =
- await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { roomA, storageA, storageB } = await prepareStorageTest<{
+ a: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
const callback = vi.fn();
- const unsubscribe = room.subscribe(callback);
+ const unsubscribe = roomA.subscribe(callback);
- applyRemoteOperations([
- { type: OpCode.UPDATE_OBJECT, data: { a: 1 }, id: "root" },
- ]);
+ storageB.root.set("a", 1);
+ await vi.waitUntil(() => storageA.root.get("a") === 1);
unsubscribe();
- applyRemoteOperations([
- { type: OpCode.UPDATE_OBJECT, data: { a: 2 }, id: "root" },
- ]);
+ storageB.root.set("a", 2);
+ await vi.waitUntil(() => storageA.root.get("a") === 2);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith([
{
type: "LiveObject",
- node: storage.root,
+ node: storageA.root,
updates: { a: { type: "update" } },
},
]);
});
test("remote action with multipe updates on same object", async () => {
- const { room, storage, applyRemoteOperations } =
- await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { roomA, roomB, storageA, storageB } = await prepareStorageTest<{
+ a: number;
+ b?: number;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
const callback = vi.fn();
- const unsubscribe = room.subscribe(callback);
+ const unsubscribe = roomA.subscribe(callback);
- applyRemoteOperations([
- { type: OpCode.UPDATE_OBJECT, data: { a: 1 }, id: "root" },
- { type: OpCode.UPDATE_OBJECT, data: { b: 1 }, id: "root" },
- ]);
+ roomB.batch(() => {
+ storageB.root.set("a", 1);
+ storageB.root.set("b", 1);
+ });
+ await vi.waitUntil(() => storageA.root.get("a") === 1);
unsubscribe();
- applyRemoteOperations([
- { type: OpCode.UPDATE_OBJECT, data: { a: 2 }, id: "root" },
- ]);
+ storageB.root.set("a", 2);
+ await vi.waitUntil(() => storageA.root.get("a") === 2);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith([
{
type: "LiveObject",
- node: storage.root,
+ node: storageA.root,
updates: { a: { type: "update" }, b: { type: "update" } },
},
]);
});
test("batch actions on a single LiveObject", async () => {
- const { room, storage, assertUndoRedo } = await prepareStorageTest<{
+ const {
+ roomA: room,
+ storageA: storage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
a: number;
b: number;
- }>([createSerializedRoot({ a: 0, b: 0 })], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0, b: 0 },
+ });
const callback = vi.fn();
@@ -136,20 +139,20 @@ describe("Storage", () => {
},
]);
- assertUndoRedo();
+ await assertUndoRedo();
});
test("batch actions on multiple LiveObjects", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { roomA: room, storageA: storage } = await prepareStorageTest<{
a: number;
child: LiveObject<{ b: number }>;
- }>(
- [
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "child"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ child: { liveblocksType: "LiveObject", data: { b: 0 } },
+ },
+ });
const callback = vi.fn();
@@ -178,20 +181,20 @@ describe("Storage", () => {
});
test("batch actions on multiple Live types", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { roomA: room, storageA: storage } = await prepareStorageTest<{
a: number;
childObj: LiveObject<{ b: number }>;
childList: LiveList;
childMap: LiveMap;
- }>(
- [
- createSerializedRoot({ a: 0 }),
- createSerializedObject("0:1", { b: 0 }, "root", "childObj"),
- createSerializedList("0:2", "root", "childList"),
- createSerializedMap("0:3", "root", "childMap"),
- ],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: {
+ a: 0,
+ childObj: { liveblocksType: "LiveObject", data: { b: 0 } },
+ childList: { liveblocksType: "LiveList", data: [] },
+ childMap: { liveblocksType: "LiveMap", data: {} },
+ },
+ });
const callback = vi.fn();
@@ -234,12 +237,16 @@ describe("Storage", () => {
describe("batching", () => {
test("batching and undo", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ } = await prepareStorageTest<{
items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
const items = storage.root.get("items");
@@ -249,30 +256,34 @@ describe("Storage", () => {
items.push("C");
});
- expectStorage({
+ await expectStorage({
items: ["A", "B", "C"],
});
room.history.undo();
- expectStorage({
+ await expectStorage({
items: [],
});
room.history.redo();
- expectStorage({
+ await expectStorage({
items: ["A", "B", "C"],
});
});
test("nesting batches makes inner batches a no-op", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ } = await prepareStorageTest<{
items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
const items = storage.root.get("items");
@@ -296,30 +307,30 @@ describe("Storage", () => {
});
});
- expectStorage({
+ await expectStorage({
items: ["A", "B", "C"],
});
room.history.undo();
- expectStorage({
+ await expectStorage({
items: [],
});
room.history.redo();
- expectStorage({
+ await expectStorage({
items: ["A", "B", "C"],
});
});
test("batch callbacks can return a value", async () => {
- const { room, storage } = await prepareStorageTest<{
+ const { roomA: room, storageA: storage } = await prepareStorageTest<{
items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
const items = storage.root.get("items");
@@ -335,10 +346,10 @@ describe("Storage", () => {
});
test("calling undo during a batch should throw", async () => {
- const { room } = await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { roomA: room } = await prepareStorageTest<{ a: number }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
room.batch(() => {
expect(() => room.history.undo()).toThrow();
@@ -346,10 +357,10 @@ describe("Storage", () => {
});
test("calling redo during a batch should throw", async () => {
- const { room } = await prepareStorageTest<{ a: number }>(
- [createSerializedRoot({ a: 0 })],
- 1
- );
+ const { roomA: room } = await prepareStorageTest<{ a: number }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
room.batch(() => {
expect(() => room.history.redo()).toThrow();
@@ -359,82 +370,87 @@ describe("Storage", () => {
describe("undo / redo", () => {
test("list.push", async () => {
- const { storage, expectStorage, assertUndoRedo } =
- await prepareStorageTest<{
- items: LiveList;
- }>(
- [
- createSerializedRoot(),
- createSerializedList("0:1", "root", "items"),
- ],
- 1
- );
+ const {
+ storageA: storage,
+ expectStorage,
+ assertUndoRedo,
+ } = await prepareStorageTest<{
+ items: LiveList;
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
const items = storage.root.get("items");
- expectStorage({ items: [] });
+ await expectStorage({ items: [] });
items.push("A");
- expectStorage({
+ await expectStorage({
items: ["A"],
});
items.push("B");
- expectStorage({
+ await expectStorage({
items: ["A", "B"],
});
- assertUndoRedo();
+ await assertUndoRedo();
});
test("max undo-redo stack", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
+ const { roomA: room, storageA: storage } = await prepareStorageTest<{
a: number;
- }>([createSerializedRoot({ a: 0 })], 1);
+ }>({
+ liveblocksType: "LiveObject",
+ data: { a: 0 },
+ });
for (let i = 0; i < 100; i++) {
storage.root.set("a", i + 1);
- expectStorage({
- a: i + 1,
- });
}
+ expect(storage.root.toImmutable()).toEqual({ a: 100 });
+
for (let i = 0; i < 100; i++) {
room.history.undo();
}
- expectStorage({
- a: 50,
- });
+ // Max undo stack is 50, so undoing 100 times only goes back to 50
+ expect(storage.root.toImmutable()).toEqual({ a: 50 });
});
test("storage operation should clear redo stack", async () => {
- const { room, storage, expectStorage } = await prepareStorageTest<{
+ const {
+ roomA: room,
+ storageA: storage,
+ expectStorage,
+ } = await prepareStorageTest<{
items: LiveList;
- }>(
- [createSerializedRoot(), createSerializedList("0:1", "root", "items")],
- 1
- );
+ }>({
+ liveblocksType: "LiveObject",
+ data: { items: { liveblocksType: "LiveList", data: [] } },
+ });
const items = storage.root.get("items");
- expectStorage({ items: [] });
+ await expectStorage({ items: [] });
items.insert("A", 0);
- expectStorage({
+ await expectStorage({
items: ["A"],
});
room.history.undo();
items.insert("B", 0);
- expectStorage({
+ await expectStorage({
items: ["B"],
});
room.history.redo();
- expectStorage({
+ await expectStorage({
items: ["B"],
});
});
diff --git a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts
index efc5b210b9..39d0c11011 100644
--- a/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts
+++ b/packages/liveblocks-core/src/crdts/__tests__/liveblocks-helpers.test.ts
@@ -6,7 +6,7 @@ import {
FOURTH_POSITION,
SECOND_POSITION,
THIRD_POSITION,
-} from "../../__tests__/_utils";
+} from "../../__tests__/_MockWebSocketServer.setup";
import { OpCode } from "../../protocol/Op";
import type { NodeMap } from "../../protocol/StorageNode";
import { CrdtType } from "../../protocol/StorageNode";
diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts
index 37c655caf4..53d71fc9af 100644
--- a/packages/liveblocks-core/src/room.ts
+++ b/packages/liveblocks-core/src/room.ts
@@ -1112,10 +1112,11 @@ export type PrivateRoomApi = {
signal: AbortSignal;
}): Promise;
- // NOTE: These are only used in our e2e test app!
+ // NOTE: These are only used for some edge case unit tests or our e2e test app!
simulate: {
explicitClose(event: IWebSocketCloseEvent): void;
rawSend(data: string): void;
+ incomingMessage(data: string): void;
};
attachmentUrlsStore: BatchStore;
@@ -3068,9 +3069,10 @@ export function createRoom<
// prettier-ignore
simulate: {
- // These exist only for our E2E testing app
+ // These exist only for our E2E testing app and unit tests
explicitClose: (event) => managedSocket._privateSendMachineEvent({ type: "EXPLICIT_SOCKET_CLOSE", event }),
rawSend: (data) => managedSocket.send(data),
+ incomingMessage: (data) => handleServerMessage(new MessageEvent("message", { data })),
},
attachmentUrlsStore: httpClient.getOrCreateAttachmentUrlsStore(roomId),
diff --git a/packages/liveblocks-core/vitest.config.ts b/packages/liveblocks-core/vitest.config.ts
index 4f7fc4ef2d..826872546c 100644
--- a/packages/liveblocks-core/vitest.config.ts
+++ b/packages/liveblocks-core/vitest.config.ts
@@ -16,6 +16,7 @@ export default defaultLiveblocksVitestConfig({
coverage: {
provider: "istanbul",
exclude: ["**/__tests__/**"],
+ reporter: [["text", { maxCols: 100 }]],
},
},
});
diff --git a/tools/liveblocks-cli/src/dev-server/index.ts b/tools/liveblocks-cli/src/dev-server/index.ts
index 1db2f63ae8..52f60bec29 100644
--- a/tools/liveblocks-cli/src/dev-server/index.ts
+++ b/tools/liveblocks-cli/src/dev-server/index.ts
@@ -236,10 +236,10 @@ const dev: SubCommand = {
const status = resp.status;
const colorStatus =
status >= 500
- ? red(String(status))
+ ? red(status)
: status >= 400
- ? yellow(String(status))
- : green(String(status));
+ ? yellow(status)
+ : green(status);
console.log(`${colorStatus} ${route}`);
const warnMsg = resp.headers.get("X-LB-Warn") ?? undefined;
warn(warnMsg, !resp.ok);
diff --git a/tools/liveblocks-cli/src/lib/term-colors.ts b/tools/liveblocks-cli/src/lib/term-colors.ts
index 138487bf10..ace040cf2d 100644
--- a/tools/liveblocks-cli/src/lib/term-colors.ts
+++ b/tools/liveblocks-cli/src/lib/term-colors.ts
@@ -15,33 +15,38 @@
* along with this program. If not, see .
*/
+import { styleText } from "node:util";
+
+type Stringable = { toString(): string };
+
+// Node's styleText auto-strips ANSI when not a TTY, but Bun's doesn't,
+// even with { stream, validateStream: true } (broken as of Bun 1.3.8)
const enabled = process.stdout.isTTY;
-const RESET = "\x1b[0m";
-export function yellow(text: string): string {
- return enabled ? `\x1b[33m${text}${RESET}` : text;
+export function yellow(text: Stringable): string {
+ return enabled ? styleText("yellow", String(text)) : String(text);
}
-export function blue(text: string): string {
- return enabled ? `\x1b[34m${text}${RESET}` : text;
+export function blue(text: Stringable): string {
+ return enabled ? styleText("blue", String(text)) : String(text);
}
-export function magenta(text: string): string {
- return enabled ? `\x1b[35m${text}${RESET}` : text;
+export function magenta(text: Stringable): string {
+ return enabled ? styleText("magenta", String(text)) : String(text);
}
-export function green(text: string): string {
- return enabled ? `\x1b[32m${text}${RESET}` : text;
+export function green(text: Stringable): string {
+ return enabled ? styleText("green", String(text)) : String(text);
}
-export function red(text: string): string {
- return enabled ? `\x1b[31m${text}${RESET}` : text;
+export function red(text: Stringable): string {
+ return enabled ? styleText("red", String(text)) : String(text);
}
-export function bold(text: string): string {
- return enabled ? `\x1b[1m${text}${RESET}` : text;
+export function bold(text: Stringable): string {
+ return enabled ? styleText("bold", String(text)) : String(text);
}
-export function dim(text: string): string {
- return enabled ? `\x1b[2m${text}${RESET}` : text;
+export function dim(text: Stringable): string {
+ return enabled ? styleText("dim", String(text)) : String(text);
}