;
};
+
+ // When `history.resume()` runs inside `room.batch()`, flushing paused
+ // history must wait until after the batch’s `reverseOps` are merged
+ // otherwise those ops become a second undo step.
+ scheduleHistoryResume: boolean;
} | null;
// A registry of yet-unacknowledged Ops. These Ops have already been
@@ -1715,9 +1724,13 @@ export function createRoom<
}
context.activeBatch.reverseOps.pushLeft(reverse);
} else {
- addToUndoStack(reverse);
- context.redoStack.length = 0;
- dispatchOps(ops);
+ if (reverse.length > 0) {
+ addToUndoStack(reverse);
+ }
+ if (ops.length > 0) {
+ context.redoStack.length = 0;
+ dispatchOps(ops);
+ }
notify({ storageUpdates });
}
}
@@ -1859,22 +1872,20 @@ export function createRoom<
const canWrite = self.get()?.canWrite ?? true;
// Populate missing top-level keys using `initialStorage`
- const stackSizeBefore = context.undoStack.length;
- for (const key in context.initialStorage) {
- if (context.root.get(key) === undefined) {
- if (canWrite) {
- context.root.set(key, cloneLson(context.initialStorage[key]));
- } else {
- console.warn(
- `Attempted to populate missing storage key '${key}', but current user has no write access`
- );
+ const root = context.root;
+ withoutHistory(() => {
+ for (const key in context.initialStorage) {
+ if (root.get(key) === undefined) {
+ if (canWrite) {
+ root.set(key, cloneLson(context.initialStorage[key]));
+ } else {
+ console.warn(
+ `Attempted to populate missing storage key '${key}', but current user has no write access`
+ );
+ }
}
}
- }
-
- // Initial storage is populated using normal "set" operations in the loop
- // above, those updates can end up in the undo stack, so let's prune it.
- context.undoStack.length = stackSizeBefore;
+ });
}
function _addToRealUndoStack(frames: Stackframe[]) {
@@ -3275,6 +3286,7 @@ export function createRoom<
others: [],
},
reverseOps: new Deque(),
+ scheduleHistoryResume: false,
};
try {
returnValue = callback();
@@ -3288,6 +3300,10 @@ export function createRoom<
addToUndoStack(Array.from(currentBatch.reverseOps));
}
+ if (currentBatch.scheduleHistoryResume) {
+ commitPausedHistoryToUndoStack();
+ }
+
if (currentBatch.ops.length > 0) {
// Only clear the redo stack if something has changed during a batch
// Clear the redo stack because batch is always called from a local operation
@@ -3311,7 +3327,7 @@ export function createRoom<
}
}
- function resumeHistory() {
+ function commitPausedHistoryToUndoStack() {
const frames = context.pausedHistory;
context.pausedHistory = null;
if (frames !== null && frames.length > 0) {
@@ -3319,6 +3335,25 @@ export function createRoom<
}
}
+ function resumeHistory() {
+ if (context.activeBatch !== null) {
+ context.activeBatch.scheduleHistoryResume = true;
+ return;
+ }
+ commitPausedHistoryToUndoStack();
+ }
+
+ function withoutHistory(fn: () => T): T {
+ const undoBefore = context.undoStack.length;
+ const redoBefore = context.redoStack.length;
+ try {
+ return fn();
+ } finally {
+ context.undoStack.length = undoBefore;
+ context.redoStack.length = redoBefore;
+ }
+ }
+
// Register a global source of pending changes for Storage™, so that the
// useSyncStatus() hook will be able to report this to end users
const syncSourceForStorage = config.createSyncSource();
@@ -3752,6 +3787,9 @@ export function createRoom<
clear,
pause: pauseHistory,
resume: resumeHistory,
+ [kInternal]: {
+ withoutHistory,
+ },
},
fetchYDoc,
diff --git a/packages/liveblocks-core/src/types/KnownKeys.ts b/packages/liveblocks-core/src/types/KnownKeys.ts
new file mode 100644
index 00000000000..7fa2e003e43
--- /dev/null
+++ b/packages/liveblocks-core/src/types/KnownKeys.ts
@@ -0,0 +1,9 @@
+/**
+ * Extracts only the explicitly-named string keys of a type, filtering out
+ * any index signature (e.g. `[key: string]: ...`).
+ */
+export type KnownKeys = keyof {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ [K in keyof T as {} extends Record ? never : K]: true;
+} &
+ string;
diff --git a/packages/liveblocks-core/src/types/Patchable.ts b/packages/liveblocks-core/src/types/Patchable.ts
index d5a149bb33c..e0ef5116c6e 100644
--- a/packages/liveblocks-core/src/types/Patchable.ts
+++ b/packages/liveblocks-core/src/types/Patchable.ts
@@ -1,9 +1,18 @@
-type OptionalKeys = {
- [K in keyof T]-?: undefined extends T[K] ? K : never;
-}[keyof T];
+/**
+ * Extracts the optional keys (whose values are allowed to be `undefined`).
+ */
+type OptionalKeys = Extract<
+ { [K in keyof T]-?: undefined extends T[K] ? K : never }[keyof T],
+ string
+>;
type MakeOptionalFieldsNullable = {
[K in keyof T]: K extends OptionalKeys ? T[K] | null : T[K];
};
+/**
+ * Like Partial, but also allows `null` for optional fields. Useful for
+ * representing patches where `null` means "remove this field" and `undefined`
+ * means "leave this field unchanged".
+ */
export type Patchable = Partial>;
diff --git a/packages/liveblocks-core/test-d/LiveObject-setLocal.test-d.ts b/packages/liveblocks-core/test-d/LiveObject-setLocal.test-d.ts
new file mode 100644
index 00000000000..59af5b03750
--- /dev/null
+++ b/packages/liveblocks-core/test-d/LiveObject-setLocal.test-d.ts
@@ -0,0 +1,64 @@
+import { LiveList, LiveMap, LiveObject } from "@liveblocks/core";
+import { expectError, expectType } from "tsd";
+
+// Schema with various key types
+type Schema = {
+ required: string;
+ optionalJson?: number;
+ optionalString?: string;
+ optionalArray?: number[];
+ optionalObject?: { nested: string; count: number };
+ optionalLiveObject?: LiveObject<{ x: number }>;
+ optionalLiveList?: LiveList;
+ optionalLiveMap?: LiveMap;
+ optionalJsonOrUndefined: string | undefined;
+};
+
+declare const obj: LiveObject;
+
+// Allowed: optional Json keys
+obj.setLocal("optionalJson", 42);
+obj.setLocal("optionalString", "hello");
+obj.setLocal("optionalJsonOrUndefined", "hello");
+obj.setLocal("optionalArray", [1, 2, 3]);
+obj.setLocal("optionalObject", { nested: "hi", count: 5 });
+
+// Disallowed: required key (not optional)
+expectError(obj.setLocal("required", "value"));
+
+// Disallowed: optional LiveStructure keys
+expectError(obj.setLocal("optionalLiveObject", new LiveObject({ x: 1 })));
+expectError(obj.setLocal("optionalLiveList", new LiveList([1])));
+expectError(obj.setLocal("optionalLiveMap", new LiveMap([["a", "b"]])));
+
+// Disallowed: wrong value type
+expectError(obj.setLocal("optionalJson", "not a number"));
+
+// Disallowed: undefined as value
+expectError(obj.setLocal("optionalJson", undefined));
+
+// Disallowed: nonexistent key
+expectError(obj.setLocal("nonexistent", 42));
+
+// Return type of get() includes local values
+expectType(obj.get("optionalJson"));
+expectType(obj.get("required"));
+
+// Index signature with optional values
+type IndexedSchema = {
+ [key: string]: string | number | undefined;
+ required: string;
+ localOnly?: string;
+};
+
+declare const indexed: LiveObject;
+
+// Allowed: the named optional Json key
+indexed.setLocal("localOnly", "hello");
+
+// Disallowed: we know that undefined isn't a legal value for 'required' field here, so don't allow it
+expectError(indexed.setLocal("required", "hello"));
+expectError(indexed.setLocal("unknownKey", "hello")); // Despite the index signature, we don't allow _any_ string key here. We require users to use explicitly-optional keys only.
+
+// Disallowed: wrong value type for localOnly
+expectError(indexed.setLocal("localOnly", 42));
diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json
index fe9259c4b8d..11dd1287c79 100644
--- a/packages/liveblocks-emails/package.json
+++ b/packages/liveblocks-emails/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/emails",
- "version": "3.16.0",
+ "version": "3.17.0",
"description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.",
"license": "Apache-2.0",
"author": "Liveblocks Inc.",
@@ -37,8 +37,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/core": "3.16.0",
- "@liveblocks/node": "3.16.0"
+ "@liveblocks/core": "3.17.0",
+ "@liveblocks/node": "3.17.0"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc"
diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json
index d25e1ee6ecb..c2b187f6bd6 100644
--- a/packages/liveblocks-node-lexical/package.json
+++ b/packages/liveblocks-node-lexical/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/node-lexical",
- "version": "3.16.0",
+ "version": "3.17.0",
"description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.",
"license": "Apache-2.0",
"author": "Liveblocks Inc.",
@@ -36,8 +36,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/core": "3.16.0",
- "@liveblocks/node": "3.16.0",
+ "@liveblocks/core": "3.17.0",
+ "@liveblocks/node": "3.17.0",
"yjs": "^13.6.18"
},
"peerDependencies": {
diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json
index b345a34f6b3..284d4e2951e 100644
--- a/packages/liveblocks-node-prosemirror/package.json
+++ b/packages/liveblocks-node-prosemirror/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/node-prosemirror",
- "version": "3.16.0",
+ "version": "3.17.0",
"description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.",
"license": "Apache-2.0",
"author": "Liveblocks Inc.",
@@ -36,8 +36,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/core": "3.16.0",
- "@liveblocks/node": "3.16.0",
+ "@liveblocks/core": "3.17.0",
+ "@liveblocks/node": "3.17.0",
"yjs": "^13.6.20"
},
"peerDependencies": {
diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json
index c2dd1a8e8e4..e9b0cd2b39e 100644
--- a/packages/liveblocks-node/package.json
+++ b/packages/liveblocks-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/node",
- "version": "3.16.0",
+ "version": "3.17.0",
"description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.",
"license": "Apache-2.0",
"author": "Liveblocks Inc.",
@@ -36,7 +36,7 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/core": "3.16.0",
+ "@liveblocks/core": "3.17.0",
"@stablelib/base64": "^1.0.1",
"fast-sha256": "^1.3.0",
"node-fetch": "^2.6.1"
diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json
index ceb29e94c8f..36ec69e78de 100644
--- a/packages/liveblocks-react-blocknote/package.json
+++ b/packages/liveblocks-react-blocknote/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/react-blocknote",
- "version": "3.16.0",
+ "version": "3.17.0",
"description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.",
"license": "Apache-2.0",
"author": "Liveblocks Inc.",
@@ -44,12 +44,12 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/client": "3.16.0",
- "@liveblocks/core": "3.16.0",
- "@liveblocks/react": "3.16.0",
- "@liveblocks/react-tiptap": "3.16.0",
- "@liveblocks/react-ui": "3.16.0",
- "@liveblocks/yjs": "3.16.0",
+ "@liveblocks/client": "3.17.0",
+ "@liveblocks/core": "3.17.0",
+ "@liveblocks/react": "3.17.0",
+ "@liveblocks/react-tiptap": "3.17.0",
+ "@liveblocks/react-ui": "3.17.0",
+ "@liveblocks/yjs": "3.17.0",
"@tiptap/core": "^3.19.0",
"vitest-tsconfig-paths": "^3.4.1"
},
diff --git a/packages/liveblocks-react-flow/.envrc b/packages/liveblocks-react-flow/.envrc
new file mode 100644
index 00000000000..14c4cad0af4
--- /dev/null
+++ b/packages/liveblocks-react-flow/.envrc
@@ -0,0 +1,5 @@
+# Read the top-level .envrc file as well
+source_up
+
+# Automatically put node_modules/.bin in your $PATH
+layout node
diff --git a/packages/liveblocks-react-flow/.eslintrc.cjs b/packages/liveblocks-react-flow/.eslintrc.cjs
new file mode 100644
index 00000000000..ec54ae38f96
--- /dev/null
+++ b/packages/liveblocks-react-flow/.eslintrc.cjs
@@ -0,0 +1,62 @@
+const commonRestrictedSyntax = require("@liveblocks/eslint-config/restricted-syntax");
+
+module.exports = {
+ root: true,
+ extends: ["@liveblocks/eslint-config"],
+ plugins: ["react-hooks"],
+ rules: {
+ // -------------------------------
+ // Custom syntax we want to forbid
+ // -------------------------------
+ "no-restricted-syntax": [
+ "error",
+ ...commonRestrictedSyntax,
+ {
+ selector:
+ "ImportDeclaration[source.value='react'] ImportSpecifier[imported.name='use']",
+ message: "use is only available on React >=19.",
+ },
+ {
+ selector:
+ "ImportDeclaration[source.value='react'] ImportSpecifier[imported.name='useLayoutEffect']",
+ message:
+ "useLayoutEffect triggers a warning when executed on the server on React <=18.2.0. Import it from './lib/use-layout-effect' instead.",
+ },
+ ],
+
+ // ----------------------------------------------------------------------
+ // Overrides from default rule config used in all other projects!
+ // ----------------------------------------------------------------------
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/unbound-method": "off",
+
+ // ----------------------------------------------------------------------
+ // Extra rules for this project specifically
+ // ----------------------------------------------------------------------
+
+ // Enforce React best practices
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": [
+ "error",
+ { additionalHooks: "useMutation" },
+ ],
+ },
+
+ overrides: [
+ {
+ files: ["src/**/__tests__/**"],
+
+ rules: {
+ // Ideally, enable these lint rules again later, as they are useful to
+ // catch bugs
+ "@typescript-eslint/no-unsafe-argument": "off",
+ "@typescript-eslint/no-unsafe-assignment": "off",
+ "@typescript-eslint/no-unsafe-return": "off",
+ "@typescript-eslint/unbound-method": "off",
+ "@typescript-eslint/no-floating-promises": "off",
+ },
+ },
+ ],
+};
diff --git a/packages/liveblocks-react-flow/.gitignore b/packages/liveblocks-react-flow/.gitignore
new file mode 100644
index 00000000000..9863290dac6
--- /dev/null
+++ b/packages/liveblocks-react-flow/.gitignore
@@ -0,0 +1,5 @@
+/scripts/*.js
+**/*.css
+**/*.css.map
+!/src/**/*.css
+!/src/**/*.css.map
\ No newline at end of file
diff --git a/packages/liveblocks-react-flow/.stylelintrc.cjs b/packages/liveblocks-react-flow/.stylelintrc.cjs
new file mode 100644
index 00000000000..22c91e64fa8
--- /dev/null
+++ b/packages/liveblocks-react-flow/.stylelintrc.cjs
@@ -0,0 +1,15 @@
+module.exports = {
+ extends: ["stylelint-config-standard"],
+ plugins: ["stylelint-order", "stylelint-plugin-logical-css"],
+ rules: {
+ "custom-property-pattern": /^lb-[a-z-]+$/,
+ "selector-class-pattern": /^lb-[a-z-:]+$/,
+ "keyframes-name-pattern": /^lb-[a-z-:]+$/,
+ "selector-max-specificity": "0,1,1",
+ "order/order": [
+ ["dollar-variables", "custom-properties", "declarations", "rules"],
+ ],
+ "plugin/use-logical-properties-and-values": true,
+ "plugin/use-logical-units": true,
+ },
+};
diff --git a/packages/liveblocks-react-flow/CHANGELOG.md b/packages/liveblocks-react-flow/CHANGELOG.md
new file mode 120000
index 00000000000..699cc9e7b7c
--- /dev/null
+++ b/packages/liveblocks-react-flow/CHANGELOG.md
@@ -0,0 +1 @@
+../../CHANGELOG.md
\ No newline at end of file
diff --git a/packages/liveblocks-react-flow/README.md b/packages/liveblocks-react-flow/README.md
new file mode 100644
index 00000000000..6bea44a7abd
--- /dev/null
+++ b/packages/liveblocks-react-flow/README.md
@@ -0,0 +1,57 @@
+
+
+
+
+
+# `@liveblocks/react-flow`
+
+
+
+
+
+
+
+`@liveblocks/react-flow` provides [React](https://reactjs.org/) APIs to
+integrate [React Flow](https://reactflow.dev/) diagrams with Liveblocks—a
+platform to build, host, and scale collaborative applications with zero
+configuration, no maintenance required.
+
+## Installation
+
+```
+npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-flow
+```
+
+## Documentation
+
+Read the
+[documentation](https://liveblocks.io/docs/api-reference/liveblocks-react-flow)
+for guides and API references.
+
+## Examples
+
+Explore our [collaborative examples](https://liveblocks.io/examples) to help you
+get started.
+
+> All examples are open-source and live in this repository, within
+> [`/examples`](../../examples).
+
+## Releases
+
+See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or
+learn more about
+[upcoming releases](https://github.com/liveblocks/liveblocks/milestones).
+
+## Community
+
+- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks
+ community, ask questions and share tips.
+- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts,
+ and general Liveblocks tips.
+
+## License
+
+Licensed under the Apache License 2.0, Copyright © 2021-present
+[Liveblocks](https://liveblocks.io).
+
+See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information.
diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json
new file mode 100644
index 00000000000..ce76f9c2230
--- /dev/null
+++ b/packages/liveblocks-react-flow/package.json
@@ -0,0 +1,109 @@
+{
+ "name": "@liveblocks/react-flow",
+ "version": "3.17.0",
+ "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.",
+ "license": "Apache-2.0",
+ "author": "Liveblocks Inc.",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "types": "./dist/index.d.cts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "module": "./dist/index.js",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "./styles.css": {
+ "types": "./styles.css.d.cts",
+ "default": "./styles.css"
+ }
+ },
+ "files": [
+ "dist/**",
+ "**/*.css",
+ "**/*.css.d.cts",
+ "**/*.css.d.ts",
+ "**/*.css.map",
+ "README.md"
+ ],
+ "scripts": {
+ "dev": "rollup --config rollup.config.js --watch",
+ "build": "rollup --config rollup.config.js",
+ "start": "npm run dev",
+ "format": "(eslint --fix src/ || true) && stylelint --fix src/styles/ && prettier --write src/",
+ "lint": "eslint src/ && stylelint src/styles/",
+ "test": "npx liveblocks dev -p 1154 -c 'vitest run --coverage'",
+ "test:ci": "vitest run",
+ "test:types": "ls test-d/* | xargs -n1 tsd --files",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@liveblocks/client": "3.17.0",
+ "@liveblocks/core": "3.17.0",
+ "@liveblocks/react": "3.17.0",
+ "@liveblocks/react-ui": "3.17.0"
+ },
+ "peerDependencies": {
+ "@xyflow/react": "^12",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@liveblocks/eslint-config": "*",
+ "@liveblocks/rollup-config": "*",
+ "@liveblocks/vitest-config": "*",
+ "@testing-library/jest-dom": "^6.4.6",
+ "@testing-library/react": "^13.1.1",
+ "@xyflow/react": "^12.10.1",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "stylelint": "^15.10.2",
+ "stylelint-config-standard": "^34.0.0",
+ "stylelint-order": "^6.0.3",
+ "stylelint-plugin-logical-css": "^0.13.2"
+ },
+ "sideEffects": false,
+ "bugs": {
+ "url": "https://github.com/liveblocks/liveblocks/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/liveblocks/liveblocks.git",
+ "directory": "packages/liveblocks-react-flow"
+ },
+ "homepage": "https://liveblocks.io",
+ "keywords": [
+ "xyflow",
+ "react-flow",
+ "react",
+ "comments",
+ "threads",
+ "liveblocks",
+ "real-time",
+ "toolkit",
+ "multiplayer",
+ "websockets",
+ "collaboration",
+ "collaborative",
+ "presence",
+ "crdts",
+ "synchronize",
+ "rooms",
+ "documents",
+ "conflict resolution"
+ ]
+}
diff --git a/packages/liveblocks-react-flow/rollup.config.js b/packages/liveblocks-react-flow/rollup.config.js
new file mode 100644
index 00000000000..f10ab31ea41
--- /dev/null
+++ b/packages/liveblocks-react-flow/rollup.config.js
@@ -0,0 +1,17 @@
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+import { createConfig } from "@liveblocks/rollup-config";
+
+import pkg from "./package.json" with { type: "json" };
+
+export default createConfig({
+ pkg,
+ entries: ["src/index.ts"],
+ styles: [
+ {
+ entry: "src/styles/index.css",
+ destination: "styles.css",
+ },
+ ],
+});
diff --git a/packages/liveblocks-react-flow/src/__tests__/_utils.tsx b/packages/liveblocks-react-flow/src/__tests__/_utils.tsx
new file mode 100644
index 00000000000..dcb73e8aa51
--- /dev/null
+++ b/packages/liveblocks-react-flow/src/__tests__/_utils.tsx
@@ -0,0 +1,132 @@
+import { createClient, nanoid, type PlainLsonObject } from "@liveblocks/core";
+import { createRoomContext } from "@liveblocks/react";
+import {
+ render,
+ renderHook,
+ type RenderHookOptions,
+ type RenderOptions,
+} from "@testing-library/react";
+import type { ReactNode } from "react";
+import { onTestFinished } from "vitest";
+
+const DEV_SERVER = "http://localhost:1154";
+
+/**
+ * Creates a room on the dev server and optionally initializes its storage.
+ */
+export async function initRoom(storage?: PlainLsonObject): Promise {
+ const roomId = `room-${nanoid()}`;
+
+ await fetch(`${DEV_SERVER}/v2/rooms`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id: roomId }),
+ });
+
+ 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) {
+ const res = await fetch(`${DEV_SERVER}/v2/authorize-user`, {
+ method: "POST",
+ headers: {
+ // ⚠️ WARNING ⚠️
+ // DO NOT USE THIS IN PRODUCTION!
+ // Never expose your secret key on the client in production this way!
+ // We only do this here because these tests don't have a backend.
+ // Do not treat this setup as a reference for how to implement
+ // authentication in your app.
+ Authorization: "Bearer sk_localdev",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ userId: `user-${nanoid()}`,
+ userInfo: { name: "Testy McTester" },
+ permissions: { [roomId!]: ["room:write"] },
+ }),
+ });
+
+ return (await res.json()) as { token: string };
+}
+
+/**
+ * Creates a fresh Liveblocks client + RoomContext pointing at the local dev
+ * server. Call this once per test so each test gets an isolated client.
+ */
+export function createTestRoomContext() {
+ const client = createClient({
+ baseUrl: DEV_SERVER,
+ authEndpoint: UNSAFE_generateAccessToken,
+ polyfills: { WebSocket: globalThis.WebSocket },
+ // @ts-expect-error internal option to disable throttling for tests
+ __DANGEROUSLY_disableThrottling: true,
+ });
+
+ return createRoomContext(client);
+}
+
+/**
+ * Creates a RoomProvider wrapper for the given room. Call once per test so each
+ * test gets an isolated client.
+ */
+export function createRoomProviderWrapper(roomId: string) {
+ const { RoomProvider } = createTestRoomContext();
+
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+/**
+ * Renders a hook with a RoomProvider connected to the dev server. Handles
+ * initRoom, wrapper setup, and onTestFinished(unmount) for cleanup.
+ */
+async function customRenderHook(
+ hook: (initialProps: Props) => Result,
+ options?: RenderHookOptions & { initialStorage?: PlainLsonObject }
+) {
+ const roomId = await initRoom(options?.initialStorage);
+ const wrapper = createRoomProviderWrapper(roomId);
+ const result = renderHook(hook, { wrapper });
+
+ onTestFinished(result.unmount);
+
+ return { ...result, roomId };
+}
+
+/**
+ * Renders UI with a RoomProvider connected to the dev server. Handles
+ * initRoom, wrapper setup, and onTestFinished(unmount) for cleanup.
+ */
+async function customRender(
+ ui: ReactNode,
+ options?: RenderOptions & { initialStorage?: PlainLsonObject }
+) {
+ const roomId = await initRoom(options?.initialStorage);
+ const Wrapper = createRoomProviderWrapper(roomId);
+ const result = render({ui});
+
+ onTestFinished(result.unmount);
+
+ return { ...result, roomId };
+}
+
+export * from "@testing-library/react";
+export { customRender as render, customRenderHook as renderHook };
diff --git a/packages/liveblocks-react-flow/src/__tests__/flow.test.tsx b/packages/liveblocks-react-flow/src/__tests__/flow.test.tsx
new file mode 100644
index 00000000000..76a1334acf7
--- /dev/null
+++ b/packages/liveblocks-react-flow/src/__tests__/flow.test.tsx
@@ -0,0 +1,607 @@
+import type { PlainLsonObject } from "@liveblocks/core";
+import { useMutation } from "@liveblocks/react";
+import { act, screen, waitFor } from "@testing-library/react";
+import type { BuiltInEdge, BuiltInNode } from "@xyflow/react";
+import { Suspense } from "react";
+import { describe, expect, test } from "vitest";
+
+import { useLiveblocksFlow } from "../index";
+import type { LiveblocksFlow } from "../types";
+import { render, renderHook } from "./_utils";
+
+const NODES: BuiltInNode[] = [
+ {
+ // TODO We can remove this "type" field again once @xyflow/react's release is out that includes this type fix: https://github.com/xyflow/xyflow/pull/5735
+ type: "default",
+ id: "1",
+ position: { x: 0, y: 0 },
+ data: { label: "Node 1" },
+ },
+ {
+ // TODO We can remove this "type" field again once @xyflow/react's release is out that includes this type fix: https://github.com/xyflow/xyflow/pull/5735
+ type: "default",
+ id: "2",
+ position: { x: 100, y: 100 },
+ data: { label: "Node 2" },
+ },
+];
+const EDGES: BuiltInEdge[] = [{ id: "e1-2", source: "1", target: "2" }];
+
+describe("useLiveblocksFlow", () => {
+ test("should return loading state before storage is ready", async () => {
+ const { result } = await renderHook(() => useLiveblocksFlow());
+
+ expect(result.current.nodes).toBeNull();
+ expect(result.current.edges).toBeNull();
+ expect(result.current.isLoading).toBe(true);
+ expect(typeof result.current.onNodesChange).toBe("function");
+ expect(typeof result.current.onEdgesChange).toBe("function");
+ expect(typeof result.current.onConnect).toBe("function");
+ });
+
+ test("should load initial nodes and edges from options.initial", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.nodes).toHaveLength(2);
+ expect(result.current.edges).toHaveLength(1);
+ expect(result.current.nodes?.[0]).toMatchObject({
+ id: "1",
+ data: { label: "Node 1" },
+ });
+ expect(result.current.edges?.[0]).toMatchObject({
+ id: "e1-2",
+ source: "1",
+ target: "2",
+ });
+ });
+
+ test("should return empty arrays when storage is empty and no initial provided", async () => {
+ const { result } = await renderHook(() => useLiveblocksFlow());
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.nodes).toEqual([]);
+ expect(result.current.edges).toEqual([]);
+ });
+
+ test("should use server storage when it exists over options.initial", async () => {
+ const serverStorage: PlainLsonObject = {
+ liveblocksType: "LiveObject",
+ data: {
+ flow: {
+ liveblocksType: "LiveObject",
+ data: {
+ nodes: {
+ liveblocksType: "LiveMap",
+ data: {
+ "server-1": {
+ liveblocksType: "LiveObject",
+ data: {
+ id: "server-1",
+ position: { x: 99, y: 99 },
+ data: {
+ liveblocksType: "LiveObject",
+ data: { label: "From Server" },
+ },
+ },
+ },
+ },
+ },
+ edges: { liveblocksType: "LiveMap", data: {} },
+ },
+ },
+ },
+ };
+
+ const { result } = await renderHook(
+ () =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ }),
+ { initialStorage: serverStorage }
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.nodes).toHaveLength(1);
+ expect(result.current.nodes?.[0]).toMatchObject({
+ id: "server-1",
+ data: { label: "From Server" },
+ });
+ expect(result.current.edges).toHaveLength(0);
+ });
+
+ test("should add node to flow when onNodesChange add is called", async () => {
+ const { result } = await renderHook(() => useLiveblocksFlow());
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ const newNode = {
+ id: "n1",
+ // TODO We can remove this "type" field again once @xyflow/react's release is out that includes this type fix: https://github.com/xyflow/xyflow/pull/5735
+ type: "default",
+ position: { x: 10, y: 20 },
+ data: { label: "New" },
+ selected: true,
+ } satisfies BuiltInNode;
+
+ act(() => {
+ result.current.onNodesChange([{ type: "add", item: newNode }]);
+ });
+
+ await waitFor(() => expect(result.current.nodes).toHaveLength(1));
+ expect(result.current.nodes?.[0]).toMatchObject({
+ id: "n1",
+ position: { x: 10, y: 20 },
+ data: { label: "New" },
+ selected: true,
+ });
+ });
+
+ test("should add edge to flow when onEdgesChange add is called", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ const newEdge = {
+ id: "e1-2",
+ source: "1",
+ target: "2",
+ selected: true,
+ } satisfies BuiltInEdge;
+
+ act(() => {
+ result.current.onEdgesChange([{ type: "add", item: newEdge }]);
+ });
+
+ await waitFor(() => expect(result.current.edges).toHaveLength(1));
+ expect(result.current.edges?.[0]).toMatchObject({
+ source: "1",
+ target: "2",
+ selected: true,
+ });
+ });
+
+ test("should remove edge from flow when onDelete is called", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.edges).toHaveLength(1);
+
+ act(() => {
+ result.current.onDelete({
+ nodes: [],
+ edges: [{ id: "e1-2", source: "1", target: "2" }],
+ });
+ });
+
+ await waitFor(() => expect(result.current.edges).toHaveLength(0));
+ });
+
+ test("should remove node from flow when onDelete is called", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.nodes).toHaveLength(2);
+
+ act(() => {
+ result.current.onDelete({
+ nodes: [NODES[0]!],
+ edges: [],
+ });
+ });
+
+ await waitFor(() => expect(result.current.nodes).toHaveLength(1));
+
+ expect(result.current.nodes?.[0]).toMatchObject({ id: "2" });
+ });
+
+ test("should remove node from flow when another client deletes it", async () => {
+ function useFlowWithDelete() {
+ const flow = useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ });
+ const deleteNodeFromStorage = useMutation(({ storage }) => {
+ const flow = storage.get("flow") as LiveblocksFlow;
+
+ if (flow) {
+ flow.get("nodes").delete("1");
+ }
+ }, []);
+ return { ...flow, deleteNodeFromStorage };
+ }
+
+ const { result } = await renderHook(() => useFlowWithDelete());
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ { type: "select", id: "1", selected: true },
+ ]);
+ });
+
+ act(() => {
+ result.current.deleteNodeFromStorage();
+ });
+
+ await waitFor(() => expect(result.current.nodes).toHaveLength(1));
+ expect(result.current.nodes?.[0]).toMatchObject({ id: "2" });
+ });
+
+ test("should persist node position when onNodesChange position is called", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ { type: "position", id: "1", position: { x: 50, y: 75 } },
+ ]);
+ });
+
+ await waitFor(() =>
+ expect(result.current.nodes?.[0]?.position).toEqual({ x: 50, y: 75 })
+ );
+ });
+
+ test("should merge local dragging state when onNodesChange position includes dragging", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ {
+ type: "position",
+ id: "1",
+ position: { x: 50, y: 75 },
+ dragging: true,
+ },
+ ]);
+ });
+
+ await waitFor(() =>
+ expect(result.current.nodes?.[0]).toMatchObject({ dragging: true })
+ );
+ });
+
+ test("should leave flow unchanged when position update targets non-existent node", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ {
+ type: "position",
+ id: "nonexistent",
+ position: { x: 100, y: 100 },
+ },
+ ]);
+ });
+
+ expect(result.current.nodes).toHaveLength(2);
+ });
+
+ test("should persist node dimensions when onNodesChange dimensions is called", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ {
+ type: "dimensions",
+ id: "1",
+ dimensions: { width: 200, height: 100 },
+ setAttributes: true,
+ resizing: true,
+ },
+ ]);
+ });
+
+ await waitFor(() =>
+ expect(result.current.nodes?.[0]).toMatchObject({
+ width: 200,
+ height: 100,
+ resizing: true,
+ })
+ );
+ });
+
+ test("should show node as selected when selected", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ { type: "select", id: "1", selected: true },
+ ]);
+ });
+
+ await waitFor(() =>
+ expect(result.current.nodes?.[0]).toMatchObject({ selected: true })
+ );
+ });
+
+ test("should deselect node when selection is cleared", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onNodesChange([
+ { type: "select", id: "1", selected: true },
+ ]);
+ });
+
+ act(() => {
+ result.current.onNodesChange([
+ { type: "select", id: "1", selected: false },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(result.current.nodes?.[0]?.selected).toBeFalsy();
+ });
+ });
+
+ test("should show edge as selected when selected", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onEdgesChange([
+ { type: "select", id: "e1-2", selected: true },
+ ]);
+ });
+
+ await waitFor(() =>
+ expect(result.current.edges?.[0]).toMatchObject({ selected: true })
+ );
+ });
+
+ test("should add edge on onConnect and skip duplicate connections", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onConnect({
+ source: "1",
+ target: "2",
+ sourceHandle: null,
+ targetHandle: null,
+ });
+ });
+
+ await waitFor(() => expect(result.current.edges).toHaveLength(1));
+
+ act(() => {
+ result.current.onConnect({
+ source: "1",
+ target: "2",
+ sourceHandle: null,
+ targetHandle: null,
+ });
+ });
+
+ expect(result.current.edges).toHaveLength(1);
+ });
+
+ test("should add multiple edges between same nodes when using different handles", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ act(() => {
+ result.current.onConnect({
+ source: "1",
+ target: "2",
+ sourceHandle: "a",
+ targetHandle: null,
+ });
+ });
+
+ await waitFor(() => expect(result.current.edges).toHaveLength(1));
+
+ act(() => {
+ result.current.onConnect({
+ source: "1",
+ target: "2",
+ sourceHandle: "b",
+ targetHandle: null,
+ });
+ });
+
+ await waitFor(() => expect(result.current.edges).toHaveLength(2));
+
+ const handles = result.current.edges?.map((e) => e.sourceHandle) ?? [];
+ expect(handles).toContain("a");
+ expect(handles).toContain("b");
+ });
+
+ test("should keep stable references for unchanged nodes across rerenders", async () => {
+ const { result, rerender } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ const nodes1 = result.current.nodes;
+
+ rerender();
+
+ const nodes2 = result.current.nodes;
+ expect(nodes2).toEqual(nodes1);
+ expect(nodes2?.[0]).toBe(nodes1?.[0]);
+ expect(nodes2?.[1]).toBe(nodes1?.[1]);
+ });
+
+ test("should keep stable references for unchanged edges across rerenders", async () => {
+ const { result, rerender } = await renderHook(() =>
+ useLiveblocksFlow({
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ const edges1 = result.current.edges;
+
+ rerender();
+
+ const edges2 = result.current.edges;
+ expect(edges2).toEqual(edges1);
+ expect(edges2?.[0]).toBe(edges1?.[0]);
+ });
+
+ test("should use custom storage key when provided", async () => {
+ const { result } = await renderHook(() =>
+ useLiveblocksFlow({ storageKey: "myFlow", nodes: { initial: NODES } })
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.nodes).toHaveLength(2);
+ });
+});
+
+describe("useLiveblocksFlow (Suspense)", () => {
+ test("should suspend until storage is ready, then return nodes and edges", async () => {
+ function Flow() {
+ const { nodes, edges, isLoading } = useLiveblocksFlow({
+ suspense: true,
+ nodes: { initial: NODES },
+ edges: { initial: EDGES },
+ });
+
+ return (
+
+ {String(isLoading)}
+ {nodes.length}
+ {edges.length}
+
+ );
+ }
+
+ await render(
+ Loading…