Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
## vNEXT (not yet released)

## v3.18.2

### `@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<string, T>` fields
in Storage

## v3.18.1

### `@liveblocks/react-ui`
Expand Down
79 changes: 79 additions & 0 deletions docs/pages/api-reference/liveblocks-client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2771,6 +2771,85 @@ room.get("time");

<PropertiesListEmpty title="Arguments">_None_</PropertiesListEmpty>

### Room.history.disable [#Room.history.disable]

<Banner title="Experimental API" type="warning">

This API is experimental and may change or be removed in a future release
without following semver guarantees.

</Banner>

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");
});
});
}
```

<PropertiesList title="Returns">
<PropertiesListItem name="return" type="T">
The return value of the callback.
</PropertiesListItem>
</PropertiesList>

<PropertiesList title="Arguments">
<PropertiesListItem name="fn" type="() => T" required>
The callback to execute while history is disabled.
</PropertiesListItem>
</PropertiesList>

### Room.connect

Connect the local room instance to the Liveblocks server. Does nothing if the
Expand Down
11 changes: 8 additions & 3 deletions docs/pages/api-reference/liveblocks-react.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```

<PropertiesListEmpty title="Arguments">_None_</PropertiesListEmpty>

<PropertiesList title="Returns">
<PropertiesListItem name="history" type="History">
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.
</PropertiesListItem>
</PropertiesList>

Expand Down
114 changes: 103 additions & 11 deletions packages/liveblocks-core/src/__tests__/room.devserver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -259,36 +258,36 @@ 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;
}>({
liveblocksType: "LiveObject",
data: { x: 0 },
});

room.history[kInternal].withoutHistory(() => {
room.history.disable(() => {
root.set("x", 1);
});

expect(root.get("x")).toBe(1);
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;
}>({
liveblocksType: "LiveObject",
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;
}>({
Expand All @@ -301,21 +300,114 @@ 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();
expect(root.get("x")).toBe(0);
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;
}>({
Expand All @@ -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);
});

Expand Down
24 changes: 19 additions & 5 deletions packages/liveblocks-core/src/crdts/Lson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LsonObject>
Expand Down Expand Up @@ -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<string, Lson | undefined>;

/**
* Helper type to convert any valid Lson type to the equivalent Json type.
Expand All @@ -50,15 +50,29 @@ export type LsonObject = { [key: string]: Lson | undefined };
// prettier-ignore
export type ToJson<L extends Lson | LsonObject> =
// A LiveList serializes to an equivalent JSON array
L extends LiveList<infer I> ? readonly ToJson<I>[] :
// Short-circuit fully opaque LiveList<Lson> to avoid recursive expansion
L extends LiveList<infer I extends Lson> ?
Lson extends I ? readonly ReadonlyJson[] :
readonly ToJson<I>[] :

// A LiveObject serializes to an equivalent JSON object
L extends LiveObject<infer O> ? ToJson<O> :
// Short-circuit fully opaque LiveObject<LsonObject> to avoid recursive expansion
// Otherwise, inline the mapped type here (instead of ToJson<O>) so that
// Record<string, LiveObject<...>> doesn't hit the LsonObject branch's guard.
L extends LiveObject<infer O extends LsonObject> ?
LsonObject extends O ? ReadonlyJsonObject :
{ readonly [K in keyof O]: ToJson<Exclude<O[K], undefined>>
| (undefined extends O[K] ? undefined : never) } :

// A LiveMap serializes to a JSON object with string-V pairs
L extends LiveMap<infer KS, infer V> ? { readonly [P in KS]: ToJson<V> } :
// Short-circuit fully opaque LiveMap<string, Lson> to avoid recursive expansion
L extends LiveMap<infer KS extends string, infer V extends Lson> ?
Lson extends V ? ReadonlyJsonObject :
{ readonly [K in KS]: ToJson<V> } :

// Any LsonObject recursively becomes a JsonObject
// Short-circuit generic string-keyed objects to ReadonlyJsonObject to avoid
// ugly recursive expansion (e.g. ToJson<LsonObject> or ToJson<JsonObject>)
L extends LsonObject ?
string extends keyof L ? ReadonlyJsonObject :
{ readonly [K in keyof L]: ToJson<Exclude<L[K], undefined>>
Expand Down
28 changes: 22 additions & 6 deletions packages/liveblocks-core/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,27 @@ export interface History {
*/
resume: () => void;

readonly [kInternal]: {
withoutHistory: <T>(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: <T>(fn: () => T) => T;
}

export type HistoryEvent = {
Expand Down Expand Up @@ -3787,9 +3805,7 @@ export function createRoom<
clear,
pause: pauseHistory,
resume: resumeHistory,
[kInternal]: {
withoutHistory,
},
disable: withoutHistory,
},

fetchYDoc,
Expand Down
Loading
Loading