(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 0000000000..9654cfc625
--- /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 1f6c77e3c6..f9afcd6734 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 a347055c13..0c52401a2c 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 82efbcece0..51bfac28ee 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 d155f20551..13592db3d8 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 61f513b0a8..118091c478 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]
>();