From 7b7af472add52cbc549f60f74333e59d4008dbdd Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Wed, 19 Aug 2026 14:05:02 +0200 Subject: [PATCH] Add `useMutableStorage()` and deprecate `useStorageRoot()` (#3669) --- CHANGELOG.md | 6 + .../api-reference/liveblocks-codemirror.mdx | 30 ++--- .../api-reference/liveblocks-lexical.mdx | 26 +--- docs/pages/api-reference/liveblocks-react.mdx | 67 +++++++++- docs/pages/get-started/nextjs-codemirror.mdx | 24 +--- .../get-started/nextjs-lexical-storage.mdx | 20 +-- docs/pages/get-started/react-codemirror.mdx | 24 +--- .../get-started/react-lexical-storage.mdx | 20 +-- .../app/rooms/[roomId]/page.tsx | 36 ++---- .../app/rooms/[roomId]/page.tsx | 31 ++--- .../liveblocks-react/scripts/check-exports.ts | 1 + .../src/__tests__/_liveblocks.config.ts | 7 +- .../src/__tests__/useMutableStorage.test.tsx | 115 ++++++++++++++++++ packages/liveblocks-react/src/index.ts | 1 + packages/liveblocks-react/src/room.tsx | 81 +++++++++++- packages/liveblocks-react/src/suspense.ts | 1 + packages/liveblocks-react/src/types/index.ts | 39 +++++- .../test-d/factories.test-d.tsx | 8 ++ 18 files changed, 354 insertions(+), 183 deletions(-) create mode 100644 packages/liveblocks-react/src/__tests__/useMutableStorage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e5073338a76..c991b6f2992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## vNEXT (not yet released) +### `@liveblocks/react` + +- Add new hook `useMutableStorage()` to get direct access to the mutable Storage + root. See + [docs](https://liveblocks.io/docs/api-reference/liveblocks-react#useMutableStorage). + ## v3.24.0 This release introduces `LiveText` (beta), a collaborative rich-text data diff --git a/docs/pages/api-reference/liveblocks-codemirror.mdx b/docs/pages/api-reference/liveblocks-codemirror.mdx index caf593b9d75..f2eb23718d0 100644 --- a/docs/pages/api-reference/liveblocks-codemirror.mdx +++ b/docs/pages/api-reference/liveblocks-codemirror.mdx @@ -81,35 +81,26 @@ export default function App() { } ``` -Attach the plugins after Storage has loaded. Create the editor with the -`LiveText` content and both plugins in the initial extensions: +Use +[`useMutableStorage`](/docs/api-reference/liveblocks-react#useMutableStorage) to +get the `LiveText` once Storage has loaded, then create the editor with its +content and both plugins in the initial extensions: ```tsx file="Editor.tsx" "use client"; -import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useRef } from "react"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; -import type { LiveText, Room } from "@liveblocks/client"; import { createLiveblocksPresencePlugin, createLiveblocksSyncPlugin, } from "@liveblocks/codemirror"; -import { useRoom } from "@liveblocks/react/suspense"; +import { useMutableStorage, useRoom } from "@liveblocks/react/suspense"; export function Editor() { const room = useRoom(); - const root = useRoot(room); - - if (root == null) { - return
Loading…
; - } - - return ; -} - -function EditorInner({ text }: { text: LiveText }) { - const room = useRoom(); + const text = useMutableStorage().get("document"); const containerRef = useRef(null); useEffect(() => { @@ -135,13 +126,6 @@ function EditorInner({ text }: { text: LiveText }) { return
; } - -function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} ``` Add styles for remote carets and selections. This package does not ship a diff --git a/docs/pages/api-reference/liveblocks-lexical.mdx b/docs/pages/api-reference/liveblocks-lexical.mdx index 77edcd07406..b759ba86a11 100644 --- a/docs/pages/api-reference/liveblocks-lexical.mdx +++ b/docs/pages/api-reference/liveblocks-lexical.mdx @@ -111,7 +111,9 @@ export default function App() { } ``` -Wait for Storage to load, then nest +Use +[`useMutableStorage`](/docs/api-reference/liveblocks-react#useMutableStorage) to +get the document root once Storage has loaded, then nest [`LiveblocksCollaborationPlugin`](#LiveblocksCollaborationPlugin) inside [`LexicalComposer`](https://lexical.dev/docs/react/plugins). Optionally add [`RemoteCursorsPlugin`](#RemoteCursorsPlugin) as a child to show remote carets: @@ -119,7 +121,6 @@ Wait for Storage to load, then nest ```tsx file="Editor.tsx" "use client"; -import { useCallback, useSyncExternalStore } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; @@ -128,19 +129,11 @@ import { LiveblocksCollaborationPlugin, RemoteCursorsPlugin, } from "@liveblocks/lexical"; -import type { Room } from "@liveblocks/client"; -import { useRoom } from "@liveblocks/react/suspense"; +import { useMutableStorage } from "@liveblocks/react/suspense"; import "@liveblocks/lexical/styles.css"; export function Editor() { - const room = useRoom(); - const root = useRoot(room); - - if (root === null) { - return
Loading…
; - } - - const document = root.get("document"); + const document = useMutableStorage().get("document"); return ( ); } - -function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} ``` Import the package stylesheet so remote carets and selections are visible: @@ -199,7 +185,7 @@ import { LiveblocksCollaborationPlugin } from "@liveblocks/lexical"; The Storage root document for the editor. Typically - `root.get("document")` after Storage has loaded. + `useMutableStorage().get("document")`. Optional children. Place [`RemoteCursorsPlugin`](#RemoteCursorsPlugin) here diff --git a/docs/pages/api-reference/liveblocks-react.mdx b/docs/pages/api-reference/liveblocks-react.mdx index 4b81a5bb2c8..98bf4de8a7e 100644 --- a/docs/pages/api-reference/liveblocks-react.mdx +++ b/docs/pages/api-reference/liveblocks-react.mdx @@ -3937,14 +3937,14 @@ needs. This will avoid unnecessary rerenders that happen with overselection. In order to select one item from a LiveMap within the storage tree with the `useStorage` method, you can use the example below: -```ts +```tsx showLineNumbers={false} const key = "errands"; const myTodos = useStorage((root) => root.todoMap.get(key)); ``` In order to query a LiveMap, and filter for specific values: -```ts +```tsx showLineNumbers={false} const myTodos = useStorage( root => Array.from(root.todoMap.values()).filter(...), shallow, @@ -3971,6 +3971,69 @@ const myTodos = useStorage( +### useMutableStorage [@badge=RoomProvider] + +Returns the current room's _mutable_ Storage root. Always a +[`LiveObject`](/docs/api-reference/liveblocks-client#LiveObject). + +```tsx showLineNumbers={false} +import { useMutableStorage } from "@liveblocks/react/suspense"; + +function Editor() { + const root = useMutableStorage(); + const liveText = root.get("document"); + + // Pass `liveText` to your editor binding + // ... +} +``` + +Unlike [`useStorage`][], this hook is **not reactive**. Your component rerenders +only once, when Storage has finished loading, and never again when the contents +of the tree change. That’s the point: the `LiveText` you hand to an editor stays +referentially stable, so your editor isn’t torn down on every keystroke. + +```tsx showLineNumbers={false} +// Rerenders on every keystroke +const text = useStorage((root) => root.document); + +// Rerenders only once, after Storage has loaded +const liveText = useMutableStorage().get("document"); +``` + + + +If you make changes to Storage via `useMutableStorage`, you’re also responsible +for batching mutations when you make them. This is unlike +[`useMutation`](/docs/api-reference/liveblocks-react#useMutation), which +automatically does that for you. You should always wrap related changes in +[`Room.batch`](/docs/api-reference/liveblocks-client#Room.batch) yourself, so +they’re sent as one update and undoable as a single step. + +```tsx showLineNumbers={false} +const room = useRoom(); +const root = useMutableStorage(); + +room.batch(() => { + root.get("settings").set("theme", "dark"); + root.get("settings").set("fontSize", 14); +}); +``` + + + +The non-Suspense version returns `null` while Storage is still loading. The +[Suspense version][] suspends instead, and always returns the root. + +_None_ + + + + The mutable Storage root. Returns `null` while Storage is still loading (in + the non-Suspense version). + + + ### useUploadFile [@badge=RoomProvider] Returns a function that uploads a file to the current room. The function diff --git a/docs/pages/get-started/nextjs-codemirror.mdx b/docs/pages/get-started/nextjs-codemirror.mdx index 52fc666673d..82d20671ea4 100644 --- a/docs/pages/get-started/nextjs-codemirror.mdx +++ b/docs/pages/get-started/nextjs-codemirror.mdx @@ -158,29 +158,18 @@ collaboration to your Next.js application using the APIs from the ```tsx file="app/Editor.tsx" "use client"; - import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; + import { useEffect, useRef } from "react"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; - import type { LiveText, Room } from "@liveblocks/client"; import { createLiveblocksPresencePlugin, createLiveblocksSyncPlugin, } from "@liveblocks/codemirror"; - import { useRoom } from "@liveblocks/react/suspense"; + import { useMutableStorage, useRoom } from "@liveblocks/react/suspense"; export function Editor() { const room = useRoom(); - const root = useRoot(room); - - if (root == null) { - return
Loading…
; - } - - return ; - } - - function EditorInner({ text }: { text: LiveText }) { - const room = useRoom(); + const text = useMutableStorage().get("document"); const containerRef = useRef(null); useEffect(() => { @@ -206,13 +195,6 @@ collaboration to your Next.js application using the APIs from the return
; } - - function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - } ``` diff --git a/docs/pages/get-started/nextjs-lexical-storage.mdx b/docs/pages/get-started/nextjs-lexical-storage.mdx index 91370b2ad92..71ad3032fba 100644 --- a/docs/pages/get-started/nextjs-lexical-storage.mdx +++ b/docs/pages/get-started/nextjs-lexical-storage.mdx @@ -188,7 +188,6 @@ instead. ```tsx file="app/Editor.tsx" "use client"; - import { useCallback, useSyncExternalStore } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; @@ -197,19 +196,11 @@ instead. LiveblocksCollaborationPlugin, RemoteCursorsPlugin, } from "@liveblocks/lexical"; - import type { Room } from "@liveblocks/client"; - import { useRoom } from "@liveblocks/react/suspense"; + import { useMutableStorage } from "@liveblocks/react/suspense"; import "@liveblocks/lexical/styles.css"; export function Editor() { - const room = useRoom(); - const root = useRoot(room); - - if (root === null) { - return
Loading…
; - } - - const document = root.get("document"); + const document = useMutableStorage().get("document"); return ( ); } - - function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - } ``` diff --git a/docs/pages/get-started/react-codemirror.mdx b/docs/pages/get-started/react-codemirror.mdx index 354c372cdd7..4f3d5d73a7d 100644 --- a/docs/pages/get-started/react-codemirror.mdx +++ b/docs/pages/get-started/react-codemirror.mdx @@ -161,29 +161,18 @@ collaboration to your React application using the APIs from the ```tsx file="Editor.tsx" "use client"; - import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; + import { useEffect, useRef } from "react"; import { EditorView } from "@codemirror/view"; import { EditorState } from "@codemirror/state"; - import type { LiveText, Room } from "@liveblocks/client"; import { createLiveblocksPresencePlugin, createLiveblocksSyncPlugin, } from "@liveblocks/codemirror"; - import { useRoom } from "@liveblocks/react/suspense"; + import { useMutableStorage, useRoom } from "@liveblocks/react/suspense"; export function Editor() { const room = useRoom(); - const root = useRoot(room); - - if (root == null) { - return
Loading…
; - } - - return ; - } - - function EditorInner({ text }: { text: LiveText }) { - const room = useRoom(); + const text = useMutableStorage().get("document"); const containerRef = useRef(null); useEffect(() => { @@ -209,13 +198,6 @@ collaboration to your React application using the APIs from the return
; } - - function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - } ``` diff --git a/docs/pages/get-started/react-lexical-storage.mdx b/docs/pages/get-started/react-lexical-storage.mdx index 302ca1c640b..1fe99cfc4d5 100644 --- a/docs/pages/get-started/react-lexical-storage.mdx +++ b/docs/pages/get-started/react-lexical-storage.mdx @@ -212,7 +212,6 @@ instead. ```tsx file="Editor.tsx" "use client"; - import { useCallback, useSyncExternalStore } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; @@ -221,19 +220,11 @@ instead. LiveblocksCollaborationPlugin, RemoteCursorsPlugin, } from "@liveblocks/lexical"; - import type { Room } from "@liveblocks/client"; - import { useRoom } from "@liveblocks/react/suspense"; + import { useMutableStorage } from "@liveblocks/react/suspense"; import "@liveblocks/lexical/styles.css"; export function Editor() { - const room = useRoom(); - const root = useRoot(room); - - if (root === null) { - return
Loading…
; - } - - const document = root.get("document"); + const document = useMutableStorage().get("document"); return ( ); } - - function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => null, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - } ``` diff --git a/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx b/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx index 7873094fbe6..2eed06360bc 100644 --- a/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx +++ b/e2e/next-codemirror-liveblocks/app/rooms/[roomId]/page.tsx @@ -1,19 +1,17 @@ "use client"; -import { ClientSideSuspense, RoomProvider, useRoom } from "@liveblocks/react"; +import { + ClientSideSuspense, + RoomProvider, + useMutableStorage, + useRoom, +} from "@liveblocks/react/suspense"; import { createLiveblocksPresencePlugin, createLiveblocksSyncPlugin, } from "@liveblocks/codemirror"; -import { - use, - useCallback, - useEffect, - useRef, - useSyncExternalStore, -} from "react"; +import { use, useEffect, useRef } from "react"; import { EditorState } from "@codemirror/state"; -import { Room } from "@liveblocks/client"; import { LiveText } from "@liveblocks/core"; import { EditorView } from "@codemirror/view"; @@ -41,16 +39,7 @@ export default function RoomPage({ function Editor() { const room = useRoom(); - const root = useRoot(room); - if (root == null) { - return
Loading room data…
; - } - - return ; -} - -function EditorInner({ text }: { text: LiveText }) { - const room = useRoom(); + const text = useMutableStorage().get("document"); const container = useRef(null); const view = useRef(null); @@ -81,12 +70,3 @@ function EditorInner({ text }: { text: LiveText }) {
); } - -function useRoot(room: Room) { - const subscribe = room.events.storageDidLoad.subscribeOnce; - const getSnapshot = room.getStorageOrNull; - const getServerSnapshot = useCallback(() => { - return null; - }, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} diff --git a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx index 713b9f2a941..aad3b114598 100644 --- a/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx +++ b/e2e/next-lexical-liveblocks/app/rooms/[roomId]/page.tsx @@ -28,18 +28,22 @@ import { TablePlugin } from "@lexical/react/LexicalTablePlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; import { $insertNodeToNearestRoot } from "@lexical/utils"; -import { LiveList, LiveObject, LiveText, type Room } from "@liveblocks/client"; +import { LiveList, LiveObject, LiveText } from "@liveblocks/client"; import { LiveblocksCollaborationPlugin, RemoteCursorsPlugin, } from "@liveblocks/lexical"; -import { ClientSideSuspense, RoomProvider, useRoom } from "@liveblocks/react"; +import { + ClientSideSuspense, + RoomProvider, + useMutableStorage, +} from "@liveblocks/react/suspense"; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, } from "lexical"; -import { use, useCallback, useEffect, useSyncExternalStore } from "react"; +import { use, useEffect } from "react"; import { ImageNode } from "./nodes/ImageNode"; import { MentionNode } from "./nodes/MentionNode"; @@ -102,17 +106,7 @@ export default function RoomPage({ } function Editor() { - const room = useRoom(); - const root = useRoot(room); - if (root === null) { - return ( -
- Loading room data… -
- ); - } - - const document = root.get("document"); + const document = useMutableStorage().get("document"); return ( { - return null; - }, []); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -} - const THEME = { text: { bold: "font-bold", diff --git a/packages/liveblocks-react/scripts/check-exports.ts b/packages/liveblocks-react/scripts/check-exports.ts index f26741f44cb..9bb8051aad9 100755 --- a/packages/liveblocks-react/scripts/check-exports.ts +++ b/packages/liveblocks-react/scripts/check-exports.ts @@ -21,6 +21,7 @@ const ALLOW_DIFFERENT_JSDOCS = [ "useInboxNotifications", "useRoomInfo", "useSelf", + "useMutableStorage", "useThreads", "useFeeds", "useFeedMessages", diff --git a/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts b/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts index bcc684c4e75..865dad78f87 100644 --- a/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts +++ b/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts @@ -24,12 +24,15 @@ const client = createClient({ }, }); +const roomContext = createRoomContext(client); + export const { RoomProvider, useCanRedo, useCanUndo, useHistory, useIsInsideRoom, + useMutableStorage, useMutation, useMyPresence, useOthers, @@ -37,4 +40,6 @@ export const { useStorage, useUndo, useThreads, -} = createRoomContext(client); +} = roomContext; + +export const { suspense } = roomContext; diff --git a/packages/liveblocks-react/src/__tests__/useMutableStorage.test.tsx b/packages/liveblocks-react/src/__tests__/useMutableStorage.test.tsx new file mode 100644 index 00000000000..9654cfc625a --- /dev/null +++ b/packages/liveblocks-react/src/__tests__/useMutableStorage.test.tsx @@ -0,0 +1,115 @@ +import { LiveList, LiveObject } from "@liveblocks/client"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { Suspense } from "react"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; + +import { + RoomProvider, + suspense, + useMutableStorage, + useMutation, +} from "./_liveblocks.config"; +import MockWebSocket, { websocketSimulator } from "./_MockWebSocket"; +import { act, renderHook, screen } from "./_utils"; + +// Access token with perms: { "*": ["room:write"] } +const exampleToken = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NjQ1NjY0MTAsImV4cCI6MTY2NDU3MDAxMCwicGlkIjoiNjA1YTRmZDMxYTM2ZDVlYTdhMmUwOGYxIiwidWlkIjoidXNlcjEiLCJwZXJtcyI6eyIqIjpbInJvb206d3JpdGUiXX0sImsiOiJhY2MifQ.OwLJdtVzMmIwIGO4gVWEJSng3DaUFsljpFXKE0Jcl1OTSHKCpDqJDkHMkkhgHmpUbBPMMdf8QmYa-4h4tMAikxzZL_tFdWQ-5kr92jOFqXPscDQTk0_GCMhv7R6vFj4YjT-msYVNVPI5M0Jlmm9fU5U_s3ZssEYhQl6AYkZT0XErrFYch8WmCVCIQ3bmFuUg5WDtnGJFiQIuCvLr0RyalJh4aILKPZ7ii_u9Q04__rN5kUhIqh2NaXWqFwsITuKaFwn24PJfBz-GJNX5Jk-tlmfJItkPFuBFp3WY8J9r9m59rJF35W_UxMU1tBNYVYRs8c3pjJKdnBiSUDUjNPvxr"; + +const server = setupServer( + http.post("/api/auth", () => HttpResponse.json({ token: exampleToken })) +); + +beforeAll(() => server.listen()); +beforeEach(() => MockWebSocket.reset()); +afterEach(() => { + MockWebSocket.reset(); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +describe("useMutableStorage (non-Suspense version)", () => { + test("returns null before storage has loaded", () => { + const { result } = renderHook(() => useMutableStorage()); + expect(result.current).toBeNull(); + }); + + test("returns the mutable Storage root once storage has loaded", async () => { + const { result } = renderHook(() => useMutableStorage()); + + const sim = await websocketSimulator(); + act(() => sim.simulateStorageLoaded()); + + const root = result.current; + expect(root).toBeInstanceOf(LiveObject); + expect(root?.get("obj").get("a")).toBe(0); + expect(root?.get("obj").get("nested").toJSON()).toEqual(["foo", "bar"]); + }); + + test("does not re-render when the contents of Storage change", async () => { + let renders = 0; + const { result } = renderHook(() => { + renders++; + return useMutableStorage(); + }); + const { result: mut } = renderHook(() => + useMutation(({ storage }) => storage.get("obj").set("a", 1), []) + ); + + const sim = await websocketSimulator(); + act(() => sim.simulateStorageLoaded()); + + const rendersAfterLoading = renders; + const rootAfterLoading = result.current; + + act(() => mut.current()); + + expect(result.current?.get("obj").get("a")).toBe(1); + expect(renders).toBe(rendersAfterLoading); + expect(result.current).toBe(rootAfterLoading); // Referentially equal! + }); +}); + +describe("useMutableStorage (Suspense version)", () => { + test("suspends until storage has loaded, then returns the root", async () => { + const { result } = renderHook(() => suspense.useMutableStorage(), { + wrapper: ({ children }) => ( + ({ x: 1 })} + initialStorage={() => ({ + obj: new LiveObject({ a: 0, nested: new LiveList(["foo", "bar"]) }), + })} + > + Loading
}> +
Loaded
+ {children} + + + ), + }); + + await vi.waitFor(() => + expect(screen.getByText("Loading")).toBeInTheDocument() + ); + + const sim = await websocketSimulator(); + act(() => sim.simulateStorageLoaded()); + + await vi.waitFor(() => + expect(screen.getByText("Loaded")).toBeInTheDocument() + ); + expect(result.current).toBeInstanceOf(LiveObject); + expect(result.current.get("obj").get("a")).toBe(0); + }); +}); diff --git a/packages/liveblocks-react/src/index.ts b/packages/liveblocks-react/src/index.ts index 1f6c77e3c64..f9afcd67340 100644 --- a/packages/liveblocks-react/src/index.ts +++ b/packages/liveblocks-react/src/index.ts @@ -90,6 +90,7 @@ export { useOthersConnectionIds, useOthersMapped, useSelf, + useMutableStorage, useStorage, useThreads, useFeeds, diff --git a/packages/liveblocks-react/src/room.tsx b/packages/liveblocks-react/src/room.tsx index a347055c13d..0c52401a2c2 100644 --- a/packages/liveblocks-react/src/room.tsx +++ b/packages/liveblocks-react/src/room.tsx @@ -64,6 +64,7 @@ import { HttpError, kInternal, makePoller, + nn, ServerMsgCode, stableStringify, } from "@liveblocks/core"; @@ -1371,7 +1372,7 @@ function useOther

( /** * @internal */ -function useMutableStorageRoot_withRoomContext( +function useMutableStorage_withRoomContext( RoomContext: Context ): LiveObject | null { const room = useRoom_withRoomContext( @@ -1383,16 +1384,19 @@ function useMutableStorageRoot_withRoomContext( return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } +function useMutableStorage(): LiveObject | null { + return useMutableStorage_withRoomContext(GlobalRoomContext); +} + /** * @internal */ function useStorageRoot_withRoomContext( RoomContext: Context ): [root: LiveObject | null] { - return [useMutableStorageRoot_withRoomContext(RoomContext)]; + return [useMutableStorage_withRoomContext(RoomContext)]; } -// NOTE: This API exists for backward compatible reasons function useStorageRoot(): [root: LiveObject | null] { return useStorageRoot_withRoomContext(GlobalRoomContext); } @@ -1411,7 +1415,7 @@ function useStorage_withRoomContext( const room = useRoom_withRoomContext( RoomContext ); - const rootOrNull = useMutableStorageRoot_withRoomContext(RoomContext); + const rootOrNull = useMutableStorage_withRoomContext(RoomContext); const wrappedSelector = useCallback( (rootOrNull: Snapshot): Selection => @@ -3749,6 +3753,23 @@ export function useSuspendUntilStorageReady(): void { return useSuspendUntilStorageReady_withRoomContext(GlobalRoomContext); } +/** + * @internal + */ +function useMutableStorageSuspense_withRoomContext( + RoomContext: Context +): LiveObject { + useSuspendUntilStorageReady_withRoomContext(RoomContext); + return nn( + useMutableStorage_withRoomContext(RoomContext), + "Storage should be loaded here" + ); +} + +function useMutableStorageSuspense(): LiveObject { + return useMutableStorageSuspense_withRoomContext(GlobalRoomContext); +} + /** * @internal */ @@ -4240,6 +4261,10 @@ export function createRoomContext< return useCanRedo_withRoomContext(BoundRoomContext); } + function useMutableStorage_withBoundRoomContext() { + return useMutableStorage_withRoomContext(BoundRoomContext); + } + function useStorageRoot_withBoundRoomContext() { return useStorageRoot_withRoomContext(BoundRoomContext); } @@ -4250,6 +4275,10 @@ export function createRoomContext< return useStorage_withRoomContext(BoundRoomContext, ...args); } + function useMutableStorageSuspense_withBoundRoomContext() { + return useMutableStorageSuspense_withRoomContext(BoundRoomContext); + } + function useStorageSuspense_withBoundRoomContext( ...args: Parameters> ) { @@ -4568,6 +4597,8 @@ export function createRoomContext< // prettier-ignore useCanRedo: useCanRedo_withBoundRoomContext as TRoomBundle["useCanRedo"], + // prettier-ignore + useMutableStorage: useMutableStorage_withBoundRoomContext as TRoomBundle["useMutableStorage"], // prettier-ignore useStorageRoot: useStorageRoot_withBoundRoomContext as TRoomBundle["useStorageRoot"], // prettier-ignore @@ -4696,6 +4727,8 @@ export function createRoomContext< // prettier-ignore useCanRedo: useCanRedo_withBoundRoomContext as TRoomBundle["suspense"]["useCanRedo"], + // prettier-ignore + useMutableStorage: useMutableStorageSuspense_withBoundRoomContext as TRoomBundle["suspense"]["useMutableStorage"], // prettier-ignore useStorageRoot: useStorageRoot_withBoundRoomContext as TRoomBundle["suspense"]["useStorageRoot"], // prettier-ignore @@ -5299,6 +5332,37 @@ const _useStorage: TypedBundle["useStorage"] = useStorage; const _useStorageSuspense: TypedBundle["suspense"]["useStorage"] = useStorageSuspense; +/** + * Returns the mutable Storage root, or `null` while Storage is still loading. + * + * Unlike `useStorage()`, this hook is not reactive: your component will + * re-render only once, when Storage has finished loading. It will not + * re-render when the contents of the returned tree change. Use it when you + * need direct access to a mutable Live structure, for example to hand + * a `LiveText` node to a text editor binding. + * + * @example + * const root = useMutableStorage(); + * const liveText = root?.get("myLiveText"); + */ +const _useMutableStorage: TypedBundle["useMutableStorage"] = useMutableStorage; + +/** + * Returns the mutable Storage root, suspending until Storage has finished + * loading. + * + * Unlike `useStorage()`, this hook is not reactive: your component will not + * re-render when the contents of the returned tree change. Use it when you + * need direct access to a mutable Live structure, for example to hand + * a `LiveText` node to a text editor binding. + * + * @example + * const root = useMutableStorage(); + * const liveText = root.get("myLiveText"); + */ +const _useMutableStorageSuspense: TypedBundle["suspense"]["useMutableStorage"] = + useMutableStorageSuspense; + /** * Gets the current user once it is connected to the room. * @@ -5378,8 +5442,11 @@ function _useSelfSuspense(...args: any[]) { } /** - * Returns the mutable (!) Storage root. This hook exists for - * backward-compatible reasons. + * Returns the mutable (!) Storage root, wrapped in a 1-tuple. + * + * @deprecated Use {@link useMutableStorage} instead, which returns the root + * directly instead of wrapping it in a 1-tuple, and which does not return + * `null` in its Suspense version. * * @example * const [root] = useStorageRoot(); @@ -5453,6 +5520,8 @@ export { useMarkThreadAsResolved, useMarkThreadAsUnresolved, useMentionSuggestionsCache, + _useMutableStorage as useMutableStorage, + _useMutableStorageSuspense as useMutableStorageSuspense, _useMutation as useMutation, _useMyPresence as useMyPresence, _useOther as useOther, diff --git a/packages/liveblocks-react/src/suspense.ts b/packages/liveblocks-react/src/suspense.ts index 82efbcece07..51bfac28ee7 100644 --- a/packages/liveblocks-react/src/suspense.ts +++ b/packages/liveblocks-react/src/suspense.ts @@ -93,6 +93,7 @@ export { useOthersConnectionIdsSuspense as useOthersConnectionIds, useOthersMappedSuspense as useOthersMapped, useSelfSuspense as useSelf, + useMutableStorageSuspense as useMutableStorage, useStorageSuspense as useStorage, useThreadsSuspense as useThreads, useAttachmentUrlSuspense as useAttachmentUrl, diff --git a/packages/liveblocks-react/src/types/index.ts b/packages/liveblocks-react/src/types/index.ts index d155f205516..13592db3d86 100644 --- a/packages/liveblocks-react/src/types/index.ts +++ b/packages/liveblocks-react/src/types/index.ts @@ -788,8 +788,11 @@ type RoomContextBundleCommon< useCanRedo(): boolean; /** - * Returns the mutable (!) Storage root. This hook exists for - * backward-compatible reasons. + * Returns the mutable (!) Storage root, wrapped in a 1-tuple. + * + * @deprecated Use `useMutableStorage()` instead, which returns the root + * directly instead of wrapping it in a 1-tuple, and which does not return + * `null` in its Suspense version. * * @example * const [root] = useStorageRoot(); @@ -1189,6 +1192,22 @@ export type RoomContextBundle< isEqual?: (prev: T | null, curr: T | null) => boolean ): T | null; + /** + * Returns the mutable Storage root, or `null` while Storage is still + * loading. + * + * Unlike `useStorage()`, this hook is not reactive: your component will + * re-render only once, when Storage has finished loading. It will not + * re-render when the contents of the returned tree change. Use it when + * you need direct access to a mutable Live structure, for example to + * hand a `LiveText` node to a text editor binding. + * + * @example + * const root = useMutableStorage(); + * const liveText = root?.get("myLiveText"); + */ + useMutableStorage(): LiveObject | null; + /** * Gets the current user once it is connected to the room. * @@ -1451,6 +1470,22 @@ export type RoomContextBundle< isEqual?: (prev: T, curr: T) => boolean ): T; + /** + * Returns the mutable Storage root, suspending until Storage has + * finished loading. + * + * Unlike `useStorage()`, this hook is not reactive: your component + * will not re-render when the contents of the returned tree + * change. Use it when you need direct access to a mutable Live + * structure, for example to hand a `LiveText` node to a text + * editor binding. + * + * @example + * const root = useMutableStorage(); + * const liveText = root.get("myLiveText"); + */ + useMutableStorage(): LiveObject; + /** * Gets the current user once it is connected to the room. * diff --git a/packages/liveblocks-react/test-d/factories.test-d.tsx b/packages/liveblocks-react/test-d/factories.test-d.tsx index 61f513b0a8a..118091c478a 100644 --- a/packages/liveblocks-react/test-d/factories.test-d.tsx +++ b/packages/liveblocks-react/test-d/factories.test-d.tsx @@ -401,6 +401,10 @@ describe("createLiveblocksContext / createRoomContext factories", () => { readonly age: number; } | null>(); + expectTypeOf( + ctx.useMutableStorage() + ).toEqualTypeOf | null>(); + expectTypeOf(ctx.useStorageRoot()).toEqualTypeOf< [root: LiveObject | null] >(); @@ -418,6 +422,10 @@ describe("createLiveblocksContext / createRoomContext factories", () => { readonly age: number; }>(); + expectTypeOf(ctx.suspense.useMutableStorage()).toEqualTypeOf< + LiveObject + >(); + expectTypeOf(ctx.suspense.useStorageRoot()).toEqualTypeOf< [root: LiveObject | null] >();