From 543f7aee2f7aed0175705803b02ed6a24050e043 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Wed, 15 Apr 2026 08:43:17 +0200 Subject: [PATCH 1/2] Fix `ToJson` failing to infer `Record` types (#3352) --- CHANGELOG.md | 7 + packages/liveblocks-core/src/crdts/Lson.ts | 24 +- .../liveblocks-core/test-d/ToJson.test-d.ts | 247 ++++++++++++++++++ 3 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 packages/liveblocks-core/test-d/ToJson.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed04f95c04c..3e1ebb2e28a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## vNEXT (not yet released) +## v3.18.2 + +### `@liveblocks/client` + +- Fix `ToJson` type losing specific value types for `Record` fields + in Storage + ## v3.18.1 ### `@liveblocks/react-ui` diff --git a/packages/liveblocks-core/src/crdts/Lson.ts b/packages/liveblocks-core/src/crdts/Lson.ts index c55aedf80e6..102380762d4 100644 --- a/packages/liveblocks-core/src/crdts/Lson.ts +++ b/packages/liveblocks-core/src/crdts/Lson.ts @@ -2,7 +2,7 @@ import type { LiveList } from "../crdts/LiveList"; import type { LiveMap } from "../crdts/LiveMap"; import type { LiveObject } from "../crdts/LiveObject"; import type { LiveRegister } from "../crdts/LiveRegister"; -import type { Json, ReadonlyJsonObject } from "../lib/Json"; +import type { Json, ReadonlyJson, ReadonlyJsonObject } from "../lib/Json"; export type LiveStructure = | LiveObject @@ -30,7 +30,7 @@ export type LiveNode = * A mapping of keys to Lson values. A Lson value is any valid JSON * value or a Live storage data structure (LiveMap, LiveList, etc.) */ -export type LsonObject = { [key: string]: Lson | undefined }; +export type LsonObject = Record; /** * Helper type to convert any valid Lson type to the equivalent Json type. @@ -50,15 +50,29 @@ export type LsonObject = { [key: string]: Lson | undefined }; // prettier-ignore export type ToJson = // A LiveList serializes to an equivalent JSON array - L extends LiveList ? readonly ToJson[] : + // Short-circuit fully opaque LiveList to avoid recursive expansion + L extends LiveList ? + Lson extends I ? readonly ReadonlyJson[] : + readonly ToJson[] : // A LiveObject serializes to an equivalent JSON object - L extends LiveObject ? ToJson : + // Short-circuit fully opaque LiveObject to avoid recursive expansion + // Otherwise, inline the mapped type here (instead of ToJson) so that + // Record> doesn't hit the LsonObject branch's guard. + L extends LiveObject ? + LsonObject extends O ? ReadonlyJsonObject : + { readonly [K in keyof O]: ToJson> + | (undefined extends O[K] ? undefined : never) } : // A LiveMap serializes to a JSON object with string-V pairs - L extends LiveMap ? { readonly [P in KS]: ToJson } : + // Short-circuit fully opaque LiveMap to avoid recursive expansion + L extends LiveMap ? + Lson extends V ? ReadonlyJsonObject : + { readonly [K in KS]: ToJson } : // Any LsonObject recursively becomes a JsonObject + // Short-circuit generic string-keyed objects to ReadonlyJsonObject to avoid + // ugly recursive expansion (e.g. ToJson or ToJson) L extends LsonObject ? string extends keyof L ? ReadonlyJsonObject : { readonly [K in keyof L]: ToJson> diff --git a/packages/liveblocks-core/test-d/ToJson.test-d.ts b/packages/liveblocks-core/test-d/ToJson.test-d.ts new file mode 100644 index 00000000000..39d24dae104 --- /dev/null +++ b/packages/liveblocks-core/test-d/ToJson.test-d.ts @@ -0,0 +1,247 @@ +import type { + Lson, + LsonObject, + ReadonlyJsonObject, + ToJson, +} from "@liveblocks/core"; +import { LiveList, LiveMap, LiveObject } from "@liveblocks/core"; +import { describe, expectTypeOf, test } from "vitest"; + +declare const str: string; +declare const num: number; +declare const bool: boolean; + +declare function or(a: T, b: U): T | U; +declare function maybe(a: T): T | undefined; + +declare function toJson(value: T): ToJson; + +describe("ToJson", () => { + // --------------------------------------------------------------------------- + // Json scalars + // --------------------------------------------------------------------------- + test("number passthrough", () => { + expectTypeOf(toJson(num)).toEqualTypeOf(); + }); + + test("number literal", () => { + expectTypeOf(toJson(42)).toEqualTypeOf<42>(); + }); + + test("string passthrough", () => { + expectTypeOf(toJson(str)).toEqualTypeOf(); + }); + + test("string literal", () => { + expectTypeOf(toJson("hi")).toEqualTypeOf<"hi">(); + }); + + test("boolean passthrough", () => { + expectTypeOf(toJson(bool)).toEqualTypeOf(); + }); + + test("boolean literal", () => { + expectTypeOf(toJson(true)).toEqualTypeOf(); + }); + + test("null passthrough", () => { + expectTypeOf(toJson(null)).toEqualTypeOf(); + }); + + // --------------------------------------------------------------------------- + // Unions of Json scalars + // --------------------------------------------------------------------------- + test("string | number", () => { + expectTypeOf(toJson(or(str, num))).toEqualTypeOf(); + }); + + // --------------------------------------------------------------------------- + // LiveList + // --------------------------------------------------------------------------- + test("LiveList", () => { + expectTypeOf(toJson(new LiveList([1, 2, 3]))).toEqualTypeOf< + readonly number[] + >(); + }); + + test("LiveList", () => { + expectTypeOf(toJson(new LiveList(["a", "b"]))).toEqualTypeOf< + readonly string[] + >(); + }); + + test("nested LiveList>", () => { + expectTypeOf(toJson(new LiveList([new LiveList([1, 2])]))).toEqualTypeOf< + readonly (readonly number[])[] + >(); + }); + + // --------------------------------------------------------------------------- + // LiveObject + // --------------------------------------------------------------------------- + test("LiveObject with scalar fields", () => { + expectTypeOf(toJson(new LiveObject({ a: 1, b: "" }))).toEqualTypeOf<{ + readonly a: number; + readonly b: string; + }>(); + }); + + test("LiveObject with optional field", () => { + expectTypeOf( + toJson(new LiveObject({ a: 1, b: maybe(str) })) + ).toEqualTypeOf<{ + readonly a: number; + readonly b: string | undefined; + }>(); + + }); + + test("LiveObject with mixed fields (docstring example)", () => { + expectTypeOf( + toJson( + new LiveObject({ + a: 1, + b: new LiveList(["x"]), + c: maybe(num), + }) + ) + ).toEqualTypeOf<{ + readonly a: number; + readonly b: readonly string[]; + readonly c: number | undefined; + }>(); + }); + + test("nested LiveObject", () => { + expectTypeOf( + toJson(new LiveObject({ inner: new LiveObject({ x: 42 }) })) + ).toEqualTypeOf<{ + readonly inner: { readonly x: number }; + }>(); + expectTypeOf( + toJson(new LiveObject({ inner: new LiveObject({ x: 42 as const }) })) + ).toEqualTypeOf<{ + readonly inner: { readonly x: 42 }; + }>(); + expectTypeOf( + toJson(new LiveObject({ inner: new LiveObject({ x: 42 as 42 | "foo" }) })) + ).toEqualTypeOf<{ + readonly inner: { readonly x: "foo" | 42 }; + }>(); + }); + + test("fully opaque LiveObject short-circuits to ReadonlyJsonObject", () => { + expectTypeOf( + toJson(new LiveObject({})) + ).toEqualTypeOf(); + }); + + // --------------------------------------------------------------------------- + // LiveMap + // --------------------------------------------------------------------------- + test("LiveMap", () => { + expectTypeOf(toJson(new LiveMap())).toEqualTypeOf<{ + readonly [key: string]: number; + }>(); + }); + + test("LiveMap>", () => { + expectTypeOf( + toJson(new LiveMap>()) + ).toEqualTypeOf<{ + readonly [key: string]: readonly number[]; + }>(); + }); + + // --------------------------------------------------------------------------- + // Unions involving Live types + // --------------------------------------------------------------------------- + test("string | LiveList", () => { + const value0 = or(str, new LiveList([])); + expectTypeOf(toJson(value0)).toEqualTypeOf(); + + const value1 = or(str, new LiveList([num])); + expectTypeOf(toJson(value1)).toEqualTypeOf(); + + const value2 = or(str, new LiveList([1, 2, 3])); + expectTypeOf(toJson(value2)).toEqualTypeOf< + string | readonly (1 | 2 | 3)[] + >(); + + const value3 = or(str, new LiveList([1, 2, 3] as const)); + expectTypeOf(toJson(value3)).toEqualTypeOf< + string | readonly (1 | 2 | 3)[] + >(); + }); + + // --------------------------------------------------------------------------- + // LsonObject (plain objects, not wrapped in LiveObject) + // --------------------------------------------------------------------------- + test("object with named keys", () => { + expectTypeOf(toJson({ x: 1, y: "a" })).toEqualTypeOf<{ + readonly x: number; + readonly y: string; + }>(); + }); + + // --------------------------------------------------------------------------- + // Generic string-keyed objects (regression from #3348) + // --------------------------------------------------------------------------- + test("Record through LiveObject", () => { + const liveObj = new LiveObject({} as Record); + expectTypeOf(liveObj).toEqualTypeOf< + LiveObject> + >(); + + expectTypeOf(toJson(liveObj)).toEqualTypeOf<{ + readonly [key: string]: { readonly prop: string }; + }>(); + }); + + test("Record through LiveObject", () => { + const liveObj = new LiveObject({} as Record); + // `any` takes both branches of the conditional, but the result is + // effectively `{ readonly [key: string]: any }`. Using toExtend + // because toEqualTypeOf doesn't handle `any` well. + expectTypeOf(toJson(liveObj)).toExtend<{ + readonly [key: string]: any; + }>(); + }); + + test("Record through LiveObject", () => { + const liveObj = new LiveObject({} as Record); + expectTypeOf(toJson(liveObj)).toEqualTypeOf<{ + readonly [key: string]: never; + }>(); + }); + + test("Record through LiveObject", () => { + const liveObj = new LiveObject({} as Record & LsonObject); + expectTypeOf(toJson(liveObj)).toEqualTypeOf(); + }); + + test("Record>", () => { + const liveObj = new LiveObject( + {} as Record> + ); + expectTypeOf(liveObj).toEqualTypeOf< + LiveObject>> + >(); + + expectTypeOf(toJson(liveObj)).toEqualTypeOf<{ + readonly [key: string]: { readonly prop: string }; + }>(); + }); + + test("Record through LiveList", () => { + const liveObj = new LiveObject({} as Record); + const liveList = new LiveList([liveObj]); + expectTypeOf(liveList).toEqualTypeOf< + LiveList>> + >(); + + expectTypeOf(toJson(liveList)).toEqualTypeOf< + readonly { readonly [key: string]: { readonly prop: string } }[] + >(); + }); +}); From b9b91d6df340f53d0e72becb69134d563cb0ed2c Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Wed, 15 Apr 2026 08:53:51 +0200 Subject: [PATCH 2/2] Promote client.history.disable() to an experimental API (#3353) --- CHANGELOG.md | 4 + .../pages/api-reference/liveblocks-client.mdx | 79 ++++++++++++ docs/pages/api-reference/liveblocks-react.mdx | 11 +- .../src/__tests__/room.devserver.test.ts | 114 ++++++++++++++++-- packages/liveblocks-core/src/room.ts | 28 ++++- .../liveblocks-react-flow/src/lib/flow.ts | 4 +- .../src/__tests__/_liveblocks.config.ts | 1 + .../src/__tests__/index.test.tsx | 33 +++++ packages/liveblocks-redux/src/index.ts | 4 +- packages/liveblocks-zustand/src/index.ts | 4 +- 10 files changed, 256 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1ebb2e28a..896575d69f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### `@liveblocks/client` +- New experimental `room.history.disable(fn)` API that allows running storage + mutations without them appearing on the undo/redo stacks. Intended for + background/async writes (e.g. writing back AI generation results) that should + not be undoable. - Fix `ToJson` type losing specific value types for `Record` fields in Storage diff --git a/docs/pages/api-reference/liveblocks-client.mdx b/docs/pages/api-reference/liveblocks-client.mdx index d9c59ab44c8..a437edc4c81 100644 --- a/docs/pages/api-reference/liveblocks-client.mdx +++ b/docs/pages/api-reference/liveblocks-client.mdx @@ -2771,6 +2771,85 @@ room.get("time"); _None_ +### Room.history.disable [#Room.history.disable] + + + +This API is experimental and may change or be removed in a future release +without following semver guarantees. + + + +Executes a callback with history tracking temporarily disabled. Any storage +mutations made inside the callback will be applied normally but will not appear +on the undo/redo stacks. This is useful for background writes that should not be +undoable, such as writing back results from agent updates or reconciling state +from an external source. + +If the callback throws, the undo/redo stacks are left unchanged (as if the +callback never ran). + +```ts +room.history.disable(() => { + root.set("generatedText", result); +}); +``` + +#### Batching + +When combining with [`room.batch`](#Room.batch), always place the batch _inside_ +the `disable` call. If `batch` wraps `disable`, the batched mutations will still +end up on the undo stack. + +```ts +// ✅ Disabling undo must happen around a batch +room.history.disable(() => { + room.batch(() => { + root.set("x", 1); + root.set("y", 2); + }); +}); + +// ❌ Batch wraps disable, mutations will still end up in the undo stack +room.batch(() => { + room.history.disable(() => { + root.set("x", 1); + root.set("y", 2); + }); +}); +``` + +#### Async + +The history API is synchronous. For long-running async tasks, call +`history.disable` at each synchronous step rather than wrapping the entire async +function: + +```ts +async function generateSummary() { + room.history.disable(() => root.set("status", "generating")); + const summary = await fetchSummaryFromAgent(); + room.history.disable(() => { + room.batch(() => { + root.set("summary", summary); + root.set("status", "done"); + }); + }); +} +``` + + + + The return value of the callback. + + + + + + The callback to execute while history is disabled. + + + ### Room.connect Connect the local room instance to the Liveblocks server. Does nothing if the diff --git a/docs/pages/api-reference/liveblocks-react.mdx b/docs/pages/api-reference/liveblocks-react.mdx index d21c281d696..7c326c63ad9 100644 --- a/docs/pages/api-reference/liveblocks-react.mdx +++ b/docs/pages/api-reference/liveblocks-react.mdx @@ -3774,15 +3774,20 @@ Returns the room’s history. See [`Room.history`][] for more information. ```ts import { useHistory } from "@liveblocks/react/suspense"; -const { undo, redo, pause, resume } = useHistory(); +const { undo, redo, pause, resume, disable } = useHistory(); ``` _None_ - The room's history object containing methods for undo, redo, pause, and - resume operations. + The room's history object containing methods for + [`undo`](/docs/api-reference/liveblocks-client#Room.history.undo), + [`redo`](/docs/api-reference/liveblocks-client#Room.history.redo), + [`pause`](/docs/api-reference/liveblocks-client#Room.history.pause), + [`resume`](/docs/api-reference/liveblocks-client#Room.history.resume), and + [`disable`](/docs/api-reference/liveblocks-client#Room.history.disable) + operations. diff --git a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts index 11c6222d7fe..76740beea71 100644 --- a/packages/liveblocks-core/src/__tests__/room.devserver.test.ts +++ b/packages/liveblocks-core/src/__tests__/room.devserver.test.ts @@ -9,7 +9,6 @@ import { describe, expect, onTestFinished, test } from "vitest"; import { LiveList } from "../crdts/LiveList"; import { LiveObject } from "../crdts/LiveObject"; -import { kInternal } from "../internal"; import { nn } from "../lib/assert"; import { prepareIsolatedStorageTest } from "./_devserver"; import type { JsonStorageUpdate } from "./_updatesUtils"; @@ -259,7 +258,7 @@ describe("room (dev server)", () => { }); }); - test("withoutHistory prevents mutations from appearing in undo stack", async () => { + test("history.disable prevents mutations from appearing in undo stack", async () => { const { room, root } = await prepareIsolatedStorageTest<{ x: number; }>({ @@ -267,7 +266,7 @@ describe("room (dev server)", () => { data: { x: 0 }, }); - room.history[kInternal].withoutHistory(() => { + room.history.disable(() => { root.set("x", 1); }); @@ -275,7 +274,7 @@ describe("room (dev server)", () => { expect(room.history.canUndo()).toBe(false); }); - test("withoutHistory returns the callback's return value", async () => { + test("history.disable returns the callback's return value", async () => { const { room } = await prepareIsolatedStorageTest<{ x: number; }>({ @@ -283,12 +282,12 @@ describe("room (dev server)", () => { data: { x: 0 }, }); - const result = room.history[kInternal].withoutHistory(() => 42); + const result = room.history.disable(() => 42); expect(result).toBe(42); }); - test("withoutHistory restores undo stack even if callback throws", async () => { + test("history.disable restores undo stack even if callback throws", async () => { const { room, root } = await prepareIsolatedStorageTest<{ x: number; }>({ @@ -301,13 +300,13 @@ describe("room (dev server)", () => { expect(room.history.canUndo()).toBe(true); expect(() => { - room.history[kInternal].withoutHistory(() => { + room.history.disable(() => { root.set("x", 2); throw new Error("boom"); }); }).toThrow("boom"); - // The mutation inside withoutHistory should not have added to the stack, + // The mutation inside history.disable should not have added to the stack, // but the pre-existing undo entry should still be there expect(room.history.canUndo()).toBe(true); room.history.undo(); @@ -315,7 +314,100 @@ describe("room (dev server)", () => { expect(room.history.canUndo()).toBe(false); }); - test("withoutHistory preserves the redo stack", async () => { + test("background write via history.disable does not interfere with user's undo history", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + userText: string; + generatedSummary: string; + status: string; + }>({ + liveblocksType: "LiveObject", + data: { userText: "", generatedSummary: "", status: "idle" }, + }); + + // User types something (undoable) + root.set("userText", "Hello world"); + + // A background task writes back a generation result (not undoable), + // using batch to group multiple mutations into a single message + room.history.disable(() => { + room.batch(() => { + root.set("generatedSummary", "AI-generated summary"); + root.set("status", "done"); + }); + }); + + expect(root.get("userText")).toBe("Hello world"); + expect(root.get("generatedSummary")).toBe("AI-generated summary"); + expect(root.get("status")).toBe("done"); + + // Undo should only revert the user's typing, not the background write + room.history.undo(); + expect(root.get("userText")).toBe(""); + expect(root.get("generatedSummary")).toBe("AI-generated summary"); + expect(root.get("status")).toBe("done"); + + // Nothing left to undo + expect(room.history.canUndo()).toBe(false); + }); + + test("disable must wrap batch, not the other way around", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + x: number; + y: number; + }>({ + liveblocksType: "LiveObject", + data: { x: 0, y: 0 }, + }); + + // batch(disable(...)) does NOT work — batch pushes to the undo stack + // in its finally block, after disable has already restored the lengths + room.batch(() => { + room.history.disable(() => { + root.set("x", 1); + root.set("y", 1); + }); + }); + + // The mutations leak onto the undo stack + expect(root.get("x")).toBe(1); + expect(room.history.canUndo()).toBe(true); + + // disable(batch(...)) works correctly + room.history.undo(); + room.history.disable(() => { + room.batch(() => { + root.set("x", 2); + root.set("y", 2); + }); + }); + + expect(root.get("x")).toBe(2); + expect(root.get("y")).toBe(2); + expect(room.history.canUndo()).toBe(false); + }); + + test("nested history.disable calls work correctly", async () => { + const { room, root } = await prepareIsolatedStorageTest<{ + x: number; + y: number; + }>({ + liveblocksType: "LiveObject", + data: { x: 0, y: 0 }, + }); + + room.history.disable(() => { + root.set("x", 1); + room.history.disable(() => { + root.set("y", 2); + }); + }); + + expect(root.get("x")).toBe(1); + expect(root.get("y")).toBe(2); + expect(room.history.canUndo()).toBe(false); + }); + + test("history.disable preserves the redo stack", async () => { const { room, root } = await prepareIsolatedStorageTest<{ x: number; }>({ @@ -329,8 +421,8 @@ describe("room (dev server)", () => { expect(root.get("x")).toBe(0); expect(room.history.canRedo()).toBe(true); - // Mutation inside withoutHistory should not wipe the redo stack - room.history[kInternal].withoutHistory(() => { + // Mutation inside history.disable should not wipe the redo stack + room.history.disable(() => { root.set("x", 99); }); diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts index db5671eaf72..4667949a3e8 100644 --- a/packages/liveblocks-core/src/room.ts +++ b/packages/liveblocks-core/src/room.ts @@ -306,9 +306,27 @@ export interface History { */ resume: () => void; - readonly [kInternal]: { - withoutHistory: (fn: () => T) => T; - }; + /** + * Executes a callback with history tracking temporarily disabled. Any + * storage mutations made inside the callback will be applied normally + * but will not appear on the undo/redo stacks. + * + * This is useful for background or async writes that should not be + * undoable, such as writing back results from an AI generation task + * or reconciling state from an external source. + * + * Returns the callback's return value. If the callback throws, the + * undo/redo stacks are left unchanged (as if the callback never ran). + * + * @example + * room.history.disable(() => { + * root.set("generatedText", result); + * }); + * + * @experimental This API is experimental and may change or be removed + * in a future release without following semver guarantees. + */ + disable: (fn: () => T) => T; } export type HistoryEvent = { @@ -3787,9 +3805,7 @@ export function createRoom< clear, pause: pauseHistory, resume: resumeHistory, - [kInternal]: { - withoutHistory, - }, + disable: withoutHistory, }, fetchYDoc, diff --git a/packages/liveblocks-react-flow/src/lib/flow.ts b/packages/liveblocks-react-flow/src/lib/flow.ts index 7347c904e2f..ec6f29fe131 100644 --- a/packages/liveblocks-react-flow/src/lib/flow.ts +++ b/packages/liveblocks-react-flow/src/lib/flow.ts @@ -5,7 +5,7 @@ import type { Resolve, ToJson, } from "@liveblocks/core"; -import { kInternal, LiveMap, LiveObject } from "@liveblocks/core"; +import { LiveMap, LiveObject } from "@liveblocks/core"; import { useHistory, useMutation, useStorage } from "@liveblocks/react"; import { useInitial, @@ -537,7 +537,7 @@ export function useLiveblocksFlow< useEffect(() => { if (isStorageLoaded) { - history[kInternal].withoutHistory(() => { + history.disable(() => { setInitialStorage(); }); } diff --git a/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts b/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts index 0d432435953..bcc684c4e75 100644 --- a/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts +++ b/packages/liveblocks-react/src/__tests__/_liveblocks.config.ts @@ -28,6 +28,7 @@ export const { RoomProvider, useCanRedo, useCanUndo, + useHistory, useIsInsideRoom, useMutation, useMyPresence, diff --git a/packages/liveblocks-react/src/__tests__/index.test.tsx b/packages/liveblocks-react/src/__tests__/index.test.tsx index 4157523597a..09e3bcab363 100644 --- a/packages/liveblocks-react/src/__tests__/index.test.tsx +++ b/packages/liveblocks-react/src/__tests__/index.test.tsx @@ -8,6 +8,7 @@ import { createRoomContext, useRoom as useRoomGlobal } from "../room"; import { useCanRedo, useCanUndo, + useHistory, useIsInsideRoom, useMutation, useMyPresence, @@ -458,3 +459,35 @@ describe("useCanUndo / useCanRedo", () => { expect(canRedo.result.current).toEqual(true); }); }); + +describe("useHistory", () => { + test("history.disable prevents mutations from being undoable", async () => { + const history = renderHook(() => useHistory()); + const canUndo = renderHook(() => useCanUndo()); + const mutation = renderHook(() => + useMutation( + ({ storage }) => storage.get("obj").set("a", Math.random()), + [] + ) + ); + + const sim = await websocketSimulator(); + act(() => sim.simulateExistingStorageLoaded()); + + // A normal mutation is undoable + act(() => mutation.result.current()); + expect(canUndo.result.current).toEqual(true); + + // Undo it to get back to clean state + act(() => history.result.current.undo()); + expect(canUndo.result.current).toEqual(false); + + // A mutation inside history.disable is not undoable + act(() => { + history.result.current.disable(() => { + mutation.result.current(); + }); + }); + expect(canUndo.result.current).toEqual(false); + }); +}); diff --git a/packages/liveblocks-redux/src/index.ts b/packages/liveblocks-redux/src/index.ts index 20dc1169b89..2914345e53d 100644 --- a/packages/liveblocks-redux/src/index.ts +++ b/packages/liveblocks-redux/src/index.ts @@ -9,7 +9,7 @@ import type { User, } from "@liveblocks/client"; import type { EnterOptions, OpaqueClient, OpaqueRoom } from "@liveblocks/core"; -import { detectDupes, kInternal } from "@liveblocks/core"; +import { detectDupes } from "@liveblocks/core"; import type { StoreEnhancer } from "redux"; import { @@ -260,7 +260,7 @@ const internalEnhancer = (options: { } } - room.history[kInternal].withoutHistory(() => { + room.history.disable(() => { maybeRoom!.batch(() => { root.reconcilePartially(missing); }); diff --git a/packages/liveblocks-zustand/src/index.ts b/packages/liveblocks-zustand/src/index.ts index a2a9941ae3f..2cfc63fd659 100644 --- a/packages/liveblocks-zustand/src/index.ts +++ b/packages/liveblocks-zustand/src/index.ts @@ -20,7 +20,7 @@ import type { OpaqueClient, OpaqueRoom, } from "@liveblocks/core"; -import { detectDupes, errorIf, kInternal } from "@liveblocks/core"; +import { detectDupes, errorIf } from "@liveblocks/core"; import type { StateCreator, StoreMutatorIdentifier } from "zustand"; import { PKG_FORMAT, PKG_NAME, PKG_VERSION } from "./version"; @@ -225,7 +225,7 @@ const middlewareImpl: InnerLiveblocksMiddleware = (config, options) => { } } - room.history[kInternal].withoutHistory(() => { + room.history.disable(() => { room.batch(() => { root.reconcilePartially(missing); });