diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f699ea69b..e7d1a737498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,23 @@ ## vNEXT (not yet published) +## v3.2.1 + ### `@liveblocks/react-ui` - Improve Markdown lists in `AiChat`: better spacing and support for arbitrary starting numbers in ordered lists. (e.g. `3.` instead of `1.`) +### `@liveblocks/react` + +- Fix `useSyncStatus` returning incorrect synchronization status for Y.js + provider. We now compare the hash of local and remote snapshot to check for + synchronization differences between local and remote Y.js document. + +### `@liveblocks/yjs` + +- Fix `LiveblocksYjsProvider.getStatus()` returning incorrect synchronization + status for Y.js provider. + ## v3.2.0 ### `@liveblocks/react-ui` diff --git a/docs/pages/platform/webhooks.mdx b/docs/pages/platform/webhooks.mdx index 06e8e3a5b3e..480c0db61fe 100644 --- a/docs/pages/platform/webhooks.mdx +++ b/docs/pages/platform/webhooks.mdx @@ -591,10 +591,46 @@ plug(Plug.Parsers, ...) ## Testing locally [#testing-locally] -Running webhooks locally can be difficult, but one way to do this is to use a -tool such as [`localtunnel`](https://www.npmjs.com/package/localtunnel) or -[`ngrok`](https://www.npmjs.com/package/ngrok) which allow you to temporarily -host your localhost server online. +Running webhooks locally can be difficult, but there are several tools that +allow you to temporarily host your localhost server online. + +### Using svix-cli + +The [`svix-cli`](https://github.com/svix/svix-webhooks/tree/main/svix-cli) +provides a `listen` command that creates a publicly accessible URL for testing +webhooks without requiring any account setup or network configuration changes. + +If your project is running on `localhost:3000`, you can run the following +command to generate a temporary URL: + +```bash +svix listen http://localhost:3000/api/liveblocks-webhook +``` + +This will output a unique URL that forwards all POST requests to your local +endpoint: + +``` +Webhook Relay is now listening at: +https://play.svix.com/in/c_tSdQhb4Q5PTF5m2juiWu8qFREqE/ + +All requests on this endpoint will be forwarded to your local URL: +http://localhost:3000/api/liveblocks-webhook + +View logs and debug information at: +https://play.svix.com/view/c_tSdQhb4Q5PTF5m2juiWu8qFREqE/ +``` + +The generated URL can be placed directly into the Liveblocks webhooks dashboard +for testing. This approach is particularly useful in enterprise environments +where tools like `localtunnel` or `ngrok` may be blocked by security policies. + +### Using localtunnel or ngrok + +Alternatively, you can use tools such as +[`localtunnel`](https://www.npmjs.com/package/localtunnel) or +[`ngrok`](https://www.npmjs.com/package/ngrok) which also allow you to +temporarily host your localhost server online. If your project is running on `localhost:3000`, you can run the following command to generate a temporary URL that’s available while your localhost server diff --git a/guides/pages/how-to-test-webhooks-on-localhost.mdx b/guides/pages/how-to-test-webhooks-on-localhost.mdx index be057d2f3e6..15692729cba 100644 --- a/guides/pages/how-to-test-webhooks-on-localhost.mdx +++ b/guides/pages/how-to-test-webhooks-on-localhost.mdx @@ -41,9 +41,40 @@ the following URL: ## Testing webhooks locally -Tools such as `localtunnel` and `ngrok` allow you to temporarily place your -localhost server online, by providing you with a temporary URL. Let’s take a -look at these two options. +There are several tools that allow you to temporarily place your localhost +server online, by providing you with a temporary URL. Let’s take a look at +these options. + +### svix-cli + +The [`svix-cli`](https://github.com/svix/svix-webhooks/tree/main/svix-cli) +provides a `listen` command that creates a publicly accessible URL for testing +webhooks. This approach is particularly useful in enterprise environments where +other tunneling tools may be blocked by security policies. + +The `listen` command acts as a proxy, forwarding any requests to your local URL +without requiring account setup or network configuration changes. + +If your project is running on `localhost:3000`, you can run the following +command to generate your URL: + +```bash +svix listen http://localhost:3000/api/liveblocks-webhook +``` + +The `svix-cli` will provide you with a unique URL and forward any POST requests +it receives to your local endpoint. To use this, copy the generated URL +directly into the Liveblocks webhooks dashboard. + +```shell +# The svix-cli generates a URL like this: +https://play.svix.com/in/c_tSdQhb4Q5PTF5m2juiWu8qFREqE/ + +# Use this URL directly in the webhooks dashboard +``` + +You now have a URL that can be used in the webhooks dashboard, along with a +debug interface to view logs and request details. ### localtunnel diff --git a/package-lock.json b/package-lock.json index b37713af5b6..1fbbe8b0687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5748,6 +5748,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -37305,10 +37317,10 @@ }, "packages/liveblocks-client": { "name": "@liveblocks/client", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.2.0" + "@liveblocks/core": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37317,7 +37329,7 @@ }, "packages/liveblocks-core": { "name": "@liveblocks/core", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37335,11 +37347,11 @@ }, "packages/liveblocks-emails": { "name": "@liveblocks/emails", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0" + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37453,10 +37465,10 @@ }, "packages/liveblocks-node": { "name": "@liveblocks/node", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.2.0", + "@liveblocks/core": "3.2.1", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" @@ -37471,11 +37483,11 @@ }, "packages/liveblocks-node-lexical": { "name": "@liveblocks/node-lexical", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0", + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1", "yjs": "^13.6.18" }, "devDependencies": { @@ -37492,11 +37504,11 @@ }, "packages/liveblocks-node-prosemirror": { "name": "@liveblocks/node-prosemirror", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0", + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1", "yjs": "^13.6.20" }, "devDependencies": { @@ -37706,11 +37718,11 @@ }, "packages/liveblocks-react": { "name": "@liveblocks/react", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37740,15 +37752,15 @@ }, "packages/liveblocks-react-blocknote": { "name": "@liveblocks/react-blocknote", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-tiptap": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-tiptap": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, @@ -37785,15 +37797,15 @@ }, "packages/liveblocks-react-lexical": { "name": "@liveblocks/react-lexical", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" @@ -38322,15 +38334,15 @@ }, "packages/liveblocks-react-tiptap": { "name": "@liveblocks/react-tiptap", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", @@ -38855,13 +38867,13 @@ }, "packages/liveblocks-react-ui": { "name": "@liveblocks/react-ui", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", @@ -40036,11 +40048,11 @@ }, "packages/liveblocks-redux": { "name": "@liveblocks/redux", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -40167,10 +40179,11 @@ }, "packages/liveblocks-yjs": { "name": "@liveblocks/yjs", - "version": "3.2.0", + "version": "3.2.1", "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" }, @@ -40247,11 +40260,11 @@ }, "packages/liveblocks-zustand": { "name": "@liveblocks/zustand", - "version": "3.2.0", + "version": "3.2.1", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 48547788f53..6ff2ff73c37 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.2.0", + "version": "3.2.1", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "jest --silent --verbose --color=always --passWithNoTests --watch" }, "dependencies": { - "@liveblocks/core": "3.2.0" + "@liveblocks/core": "3.2.1" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 0e03cf10cf0..06db397eb39 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.2.0", + "version": "3.2.1", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-core/src/protocol/ServerMsg.ts b/packages/liveblocks-core/src/protocol/ServerMsg.ts index 55ad227bf44..80065aa41b6 100644 --- a/packages/liveblocks-core/src/protocol/ServerMsg.ts +++ b/packages/liveblocks-core/src/protocol/ServerMsg.ts @@ -223,6 +223,7 @@ export type YDocUpdateServerMsg = { readonly stateVector: string | null; // server's state vector, sent in response to fetch readonly guid?: string; // an optional guid to identify which subdoc this update to readonly v2?: boolean; // whether this is a v2 update + readonly remoteSnapshotHash: string; // The hash of snapshot of server's document. Used to detect if the client has the latest version of the document. }; /** diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index 24f4d51fb8e..d75f438172c 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -3045,7 +3045,9 @@ export function createRoom< function yjsStatusDidChange(status: YjsSyncStatus) { return syncSourceForYjs.setSyncStatus( - status === "synchronizing" ? "synchronizing" : "synchronized" + status === "synchronizing" || status === "loading" + ? "synchronizing" + : "synchronized" ); } diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 19e7c4e75c9..bf37ff6e18a 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.2.0", + "version": "3.2.1", "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", "type": "module", @@ -35,8 +35,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0" + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1" }, "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 863d4c1e85d..c9370379530 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.2.0", + "version": "3.2.1", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0", + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1", "yjs": "^13.6.18" }, "peerDependencies": { diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 4f05a658bab..d5018052640 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.2.0", + "version": "3.2.1", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.2.0", - "@liveblocks/node": "3.2.0", + "@liveblocks/core": "3.2.1", + "@liveblocks/node": "3.2.1", "yjs": "^13.6.20" }, "peerDependencies": { diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 1b9325236a4..5bde2680a7e 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.2.0", + "version": "3.2.1", "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", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.2.0", + "@liveblocks/core": "3.2.1", "@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 daeb4a7404c..17ade4ad854 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.2.0", + "version": "3.2.1", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -42,12 +42,12 @@ "test:watch": "vitest" }, "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-tiptap": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-tiptap": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index 6088798fca9..34a42859402 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.2.0", + "version": "3.2.1", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 4d3a2d3686c..260df194fc0 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.2.0", + "version": "3.2.1", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", - "@liveblocks/react-ui": "3.2.0", - "@liveblocks/yjs": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", + "@liveblocks/react-ui": "3.2.1", + "@liveblocks/yjs": "3.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 2832c86d6f3..1418c128b39 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.2.0", + "version": "3.2.1", "description": "A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -76,9 +76,9 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", - "@liveblocks/react": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@liveblocks/react": "3.2.1", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 064051c73dc..ea16fb61efd 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.2.0", + "version": "3.2.1", "description": "A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -61,8 +61,8 @@ "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" }, "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "peerDependencies": { "@types/react": "*", diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 7a1e38e516f..70ebe0fcdfa 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.2.0", + "version": "3.2.1", "description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -33,8 +33,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "peerDependencies": { "redux": "^4 || ^5" diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 4d3ad742085..a8a519120bb 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.2.0", + "version": "3.2.1", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "icense": "Apache-2.0", "type": "module", @@ -33,8 +33,9 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0", + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1", + "@noble/hashes": "^1.8.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" }, diff --git a/packages/liveblocks-yjs/src/doc.ts b/packages/liveblocks-yjs/src/doc.ts index 83c504a0dc5..65147f286ce 100644 --- a/packages/liveblocks-yjs/src/doc.ts +++ b/packages/liveblocks-yjs/src/doc.ts @@ -1,3 +1,9 @@ +import { + DerivedSignal, + Signal as Signal, + type YjsSyncStatus, +} from "@liveblocks/core"; +import { sha256 } from "@noble/hashes/sha2"; import { Base64 } from "js-base64"; import { Observable } from "lib0/observable"; import { IndexeddbPersistence } from "y-indexeddb"; @@ -11,6 +17,13 @@ export default class yDocHandler extends Observable { private updateRoomDoc: (update: Uint8Array) => void; private fetchRoomDoc: (vector: string) => void; private useV2Encoding: boolean; + private localSnapshotHashΣ: Signal; + private remoteSnapshotHashΣ: Signal; + + private debounceTimer: ReturnType | null = null; + private static readonly DEBOUNCE_INTERVAL_MS = 200; + + private isLocalAndRemoteSnapshotEqualΣ: DerivedSignal; constructor({ doc, @@ -38,6 +51,26 @@ export default class yDocHandler extends Observable { }; this.syncDoc(); + + const encodedSnapshot = this.useV2Encoding + ? Y.encodeSnapshotV2(Y.snapshot(this.doc)) + : Y.encodeSnapshot(Y.snapshot(this.doc)); + + this.localSnapshotHashΣ = new Signal( + Base64.fromUint8Array(sha256(encodedSnapshot)) + ); + this.remoteSnapshotHashΣ = new Signal(null); + + this.isLocalAndRemoteSnapshotEqualΣ = DerivedSignal.from(() => { + const remoteSnapshotHash = this.remoteSnapshotHashΣ.get(); + if (remoteSnapshotHash === null) return false; + + const localSnapshotHash = this.localSnapshotHashΣ.get(); + if (localSnapshotHash !== remoteSnapshotHash) { + return false; + } + return true; + }); } public handleServerUpdate = ({ @@ -45,11 +78,13 @@ export default class yDocHandler extends Observable { stateVector, readOnly, v2, + remoteSnapshotHash, }: { update: Uint8Array; stateVector: string | null; readOnly: boolean; v2?: boolean; + remoteSnapshotHash: string; }): void => { // apply update from the server, updates from the server can be v1 or v2 const applyUpdate = v2 ? Y.applyUpdateV2 : Y.applyUpdate; @@ -77,6 +112,8 @@ export default class yDocHandler extends Observable { // calling `syncDoc` again will sync up the documents this.synced = true; } + + this.remoteSnapshotHashΣ.set(remoteSnapshotHash); }; public syncDoc = (): void => { @@ -101,10 +138,26 @@ export default class yDocHandler extends Observable { } } + private debounced_updateLocalSnapshot() { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + // Compute local snapshot and update the local snapshot state + const encodedSnapshot = this.useV2Encoding + ? Y.encodeSnapshotV2(Y.snapshot(this.doc)) + : Y.encodeSnapshot(Y.snapshot(this.doc)); + this.localSnapshotHashΣ.set( + Base64.fromUint8Array(sha256(encodedSnapshot)) + ); + this.debounceTimer = null; + }, yDocHandler.DEBOUNCE_INTERVAL_MS); + } + private updateHandler = ( update: Uint8Array, origin: string | IndexeddbPersistence ) => { + this.debounced_updateLocalSnapshot(); + // don't send updates from indexedb, those will get handled by sync const isFromLocal = origin instanceof IndexeddbPersistence; if (origin !== "backend" && !isFromLocal) { @@ -112,7 +165,19 @@ export default class yDocHandler extends Observable { } }; + experimental_getSyncStatus(): YjsSyncStatus { + const remoteSnapshotHash = this.remoteSnapshotHashΣ.get(); + if (remoteSnapshotHash === null) { + return "loading"; + } + if (!this.isLocalAndRemoteSnapshotEqualΣ.get()) { + return "synchronizing"; + } + return "synchronized"; + } + destroy(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); this.doc.off("update", this.updateHandler); this.unsubscribers.forEach((unsub) => unsub()); this._observers = new Map(); diff --git a/packages/liveblocks-yjs/src/provider.ts b/packages/liveblocks-yjs/src/provider.ts index 88764caace2..835867d59cc 100644 --- a/packages/liveblocks-yjs/src/provider.ts +++ b/packages/liveblocks-yjs/src/provider.ts @@ -1,9 +1,15 @@ -import type { IYjsProvider, OpaqueRoom, YjsSyncStatus } from "@liveblocks/core"; -import { ClientMsgCode, kInternal } from "@liveblocks/core"; +import { + DerivedSignal, + type IYjsProvider, + type OpaqueRoom, + type YjsSyncStatus, +} from "@liveblocks/core"; +import { ClientMsgCode, kInternal, MutableSignal } from "@liveblocks/core"; import { Base64 } from "js-base64"; import { Observable } from "lib0/observable"; import { IndexeddbPersistence } from "y-indexeddb"; -import { type Doc, parseUpdateMeta, PermanentUserData } from "yjs"; +import type { Doc } from "yjs"; +import { PermanentUserData } from "yjs"; import { Awareness } from "./awareness"; import yDocHandler from "./doc"; @@ -30,12 +36,13 @@ export class LiveblocksYjsProvider public readonly awareness: Awareness; public readonly rootDocHandler: yDocHandler; - public readonly subdocHandlers: Map = new Map(); + private readonly subdocHandlersΣ = new MutableSignal< + Map + >(new Map()); + private readonly syncStatusΣ: DerivedSignal; public readonly permanentUserData?: PermanentUserData; - private pending: string[] = []; - constructor(room: OpaqueRoom, doc: Doc, options: ProviderOptions = {}) { super(); this.rootDoc = doc; @@ -66,7 +73,6 @@ export class LiveblocksYjsProvider } else { this.rootDocHandler.synced = false; } - this.emit("status", [this.getStatus()]); }) ); @@ -77,23 +83,24 @@ export class LiveblocksYjsProvider // don't apply updates that came from the client return; } - const { stateVector, update: updateStr, guid, v2 } = message; + const { + stateVector, + update: updateStr, + guid, + v2, + remoteSnapshotHash, + } = message; const canWrite = this.room.getSelf()?.canWrite ?? true; const update = Base64.toUint8Array(updateStr); - const updateId = this.getUniqueUpdateId(update); - this.pending = this.pending.filter((pendingUpdate) => { - if (pendingUpdate === updateId) { - return false; - } - return true; - }); + // find the right doc and update if (guid !== undefined) { - this.subdocHandlers.get(guid)?.handleServerUpdate({ + this.subdocHandlersΣ.get().get(guid)?.handleServerUpdate({ update, stateVector, readOnly: !canWrite, v2, + remoteSnapshotHash, }); } else { this.rootDocHandler.handleServerUpdate({ @@ -101,10 +108,9 @@ export class LiveblocksYjsProvider stateVector, readOnly: !canWrite, v2, + remoteSnapshotHash, }); } - // notify any listeners that the status has changed - this.emit("status", [this.getStatus()]); }) ); @@ -115,15 +121,43 @@ export class LiveblocksYjsProvider // different consumers listen to sync and synced this.rootDocHandler.on("synced", () => { const state = this.rootDocHandler.synced; - for (const [_, handler] of this.subdocHandlers) { + for (const [_, handler] of this.subdocHandlersΣ.get()) { handler.syncDoc(); } this.emit("synced", [state]); this.emit("sync", [state]); - this.emit("status", [this.getStatus()]); }); this.rootDoc.on("subdocs", this.handleSubdocs); this.syncDoc(); + + this.syncStatusΣ = DerivedSignal.from(() => { + // If the root document is loading or synchronizing, we infer that the overall status is also loading or synchronizing. + const rootDocumentStatus = + this.rootDocHandler.experimental_getSyncStatus(); + if ( + rootDocumentStatus === "loading" || + rootDocumentStatus === "synchronizing" + ) { + return rootDocumentStatus; + } + + // If the root document is synchronized, we check if all subdocs are synchronized. If at least one subdoc is not synchronized, we are still synchronizing. + const subdocumentStatuses = Array.from( + this.subdocHandlersΣ.get().values() + ).map((handler) => handler.experimental_getSyncStatus()); + if (subdocumentStatuses.some((state) => state !== "synchronized")) { + return "synchronizing"; + } + return "synchronized"; + }); + + this.emit("status", [this.getStatus()]); + + this.unsubscribers.push( + this.syncStatusΣ.subscribe(() => { + this.emit("status", [this.getStatus()]); + }) + ); } private setupOfflineSupport = () => { @@ -151,37 +185,30 @@ export class LiveblocksYjsProvider added: Set; }) => { loaded.forEach(this.createSubdocHandler); + const subdocHandlers = this.subdocHandlersΣ.get(); if (this.options.autoloadSubdocs) { for (const subdoc of added) { - if (!this.subdocHandlers.has(subdoc.guid)) { + if (!subdocHandlers.has(subdoc.guid)) { subdoc.load(); } } } for (const subdoc of removed) { - if (this.subdocHandlers.has(subdoc.guid)) { - this.subdocHandlers.get(subdoc.guid)?.destroy(); - this.subdocHandlers.delete(subdoc.guid); + if (subdocHandlers.has(subdoc.guid)) { + subdocHandlers.get(subdoc.guid)?.destroy(); + subdocHandlers.delete(subdoc.guid); } } }; - private getUniqueUpdateId = (update: Uint8Array) => { - const clock = parseUpdateMeta(update).to.get(this.rootDoc.clientID) ?? "-1"; - return this.rootDoc.clientID + ":" + clock; - }; - private updateDoc = (update: Uint8Array, guid?: string) => { const canWrite = this.room.getSelf()?.canWrite ?? true; if (canWrite && !this.isPaused) { - const updateId = this.getUniqueUpdateId(update); - this.pending.push(updateId); this.room.updateYDoc( Base64.fromUint8Array(update), guid, this.useV2Encoding ); - this.emit("status", [this.getStatus()]); } }; @@ -190,9 +217,10 @@ export class LiveblocksYjsProvider }; private createSubdocHandler = (subdoc: Doc): void => { - if (this.subdocHandlers.has(subdoc.guid)) { + const subdocHandlers = this.subdocHandlersΣ.get(); + if (subdocHandlers.has(subdoc.guid)) { // if we already handle this subdoc, just fetch it again - this.subdocHandlers.get(subdoc.guid)?.syncDoc(); + subdocHandlers.get(subdoc.guid)?.syncDoc(); return; } const handler = new yDocHandler({ @@ -202,7 +230,7 @@ export class LiveblocksYjsProvider fetchDoc: this.fetchDoc, useV2Encoding: this.options.useV2Encoding_experimental ?? false, }); - this.subdocHandlers.set(subdoc.guid, handler); + subdocHandlers.set(subdoc.guid, handler); }; // attempt to load a subdoc of a given guid @@ -219,7 +247,7 @@ export class LiveblocksYjsProvider private syncDoc = () => { this.rootDocHandler.syncDoc(); - for (const [_, handler] of this.subdocHandlers) { + for (const [_, handler] of this.subdocHandlersΣ.get()) { handler.syncDoc(); } }; @@ -248,10 +276,7 @@ export class LiveblocksYjsProvider } public getStatus(): YjsSyncStatus { - if (!this.synced) { - return "loading"; - } - return this.pending.length === 0 ? "synchronized" : "synchronizing"; + return this.syncStatusΣ.get(); } destroy(): void { @@ -259,10 +284,10 @@ export class LiveblocksYjsProvider this.awareness.destroy(); this.rootDocHandler.destroy(); this._observers = new Map(); - for (const [_, handler] of this.subdocHandlers) { + for (const [_, handler] of this.subdocHandlersΣ.get()) { handler.destroy(); } - this.subdocHandlers.clear(); + this.subdocHandlersΣ.get().clear(); super.destroy(); } @@ -283,4 +308,17 @@ export class LiveblocksYjsProvider connect(): void { // This is a noop for liveblocks as connections are managed by the room } + + get subdocHandlers(): Map { + return this.subdocHandlersΣ.get(); + } + + set subdocHandlers(value: Map) { + this.subdocHandlersΣ.mutate((map) => { + map.clear(); + for (const [key, handler] of value) { + map.set(key, handler); + } + }); + } } diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index cfec9ce9fb6..286a04a2587 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.2.0", + "version": "3.2.1", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.2.0", - "@liveblocks/core": "3.2.0" + "@liveblocks/client": "3.2.1", + "@liveblocks/core": "3.2.1" }, "peerDependencies": { "zustand": "^5.0.1"