;
+ readonly history: Observable<
+ | { action: "push"; id: number }
+ | { action: "undo"; id: number }
+ | { action: "redo"; id: number }
+ | { action: "clear" }
+ | { action: "discard"; ids: number[] }
+ >;
};
function connectionAccessFromScopes(scopes: string[]): {
@@ -1348,6 +1388,11 @@ type PresenceStackframe = {
readonly data: P;
};
+type HistoryStackItem
= {
+ id: number;
+ frames: Stackframe
[];
+};
+
type IdFactory = () => string;
export type StaticSessionInfo = {
@@ -1409,8 +1454,8 @@ type RoomState<
pool: ManagedPool;
root: LiveObject | undefined;
- undoStack: Stackframe
[][];
- redoStack: Stackframe
[][];
+ undoStack: HistoryStackItem
[];
+ redoStack: HistoryStackItem
[];
/**
* When history is paused, all operations will get queued up here. When
@@ -1435,6 +1480,10 @@ type RoomState<
// history must wait until after the batch’s `reverseOps` are merged
// otherwise those ops become a second undo step.
scheduleHistoryResume: boolean;
+
+ // LiveText can dispatch with empty `ops` while UPDATE_TEXT is in-flight
+ // (queued edits) but still request redo clearing via DispatchOptions.
+ clearRedoStack?: boolean;
} | null;
// A registry of yet-unacknowledged Ops. These Ops have already been
@@ -1699,6 +1748,10 @@ export function createRoom<
unacknowledgedOps,
};
+ let nextHistoryItemId = 0;
+ // Depth counter for nested history.disable() calls, 0 means history is not disabled
+ let historyDisabled = 0;
+
// Accumulates nodes as initial storage arrives in chunks via
// STORAGE_CHUNK messages. Once the final chunk arrives (with
// done: true), the complete map is passed to processInitialStorage().
@@ -1826,7 +1879,8 @@ export function createRoom<
function onDispatch(
ops: ClientWireOp[],
reverse: Op[],
- storageUpdates: Map
+ storageUpdates: Map,
+ options?: DispatchOptions
): void {
if (context.activeBatch) {
for (const op of ops) {
@@ -1842,12 +1896,20 @@ export function createRoom<
);
}
context.activeBatch.reverseOps.pushLeft(reverse);
+ // LiveText may dispatch with empty `ops` while an UPDATE_TEXT is
+ // in-flight (queued edits), but still pass `clearRedoStack: true`.
+ // Honor that here — the batch finally-block only sees ops.length.
+ if (options?.clearRedoStack) {
+ context.activeBatch.clearRedoStack = true;
+ }
} else {
if (reverse.length > 0) {
addToUndoStack(reverse);
}
+ if (options?.clearRedoStack ?? ops.length > 0) {
+ clearRedoStack();
+ }
if (ops.length > 0) {
- context.redoStack.length = 0;
dispatchOps(ops);
}
notify({ storageUpdates });
@@ -1873,6 +1935,7 @@ export function createRoom<
others: makeEventSource>(),
storageBatch: makeEventSource(),
history: makeEventSource(),
+ privateHistory: makeEventSource(),
storageDidLoad: makeEventSource(),
storageStatus: makeEventSource(),
ydoc: makeEventSource(),
@@ -1989,12 +2052,15 @@ export function createRoom<
// Serializes the current live Storage into a NodeMap and diffs it against
// `target`, returning the ops that make the live tree match `target`. Shared
// by storage load (applied remotely) and restore (applied locally).
- function diffCurrentStorageAgainst(target: NodeMap): Op[] {
+ function diffCurrentStorageAgainst(
+ target: NodeMap,
+ options?: { includeLiveTextUpdates?: boolean }
+ ): Op[] {
const current: NodeMap = new Map();
for (const [id, crdt] of context.pool.nodes) {
current.set(id, crdt._serialize());
}
- return diffNodeMap(current, target);
+ return diffNodeMap(current, target, options);
}
function createOrUpdateRootFromMessage(nodes: NodeMap) {
@@ -2004,6 +2070,32 @@ export function createRoom<
if (context.root !== undefined) {
const result = applyRemoteOps(diffCurrentStorageAgainst(nodes));
+
+ // LiveText nodes are not covered by the op diff above (their op path
+ // carries pending-op transformation semantics that don't apply to
+ // authoritative snapshots). Reconcile them against the snapshot
+ // directly; locally pending text ops are preserved on top and re-sent
+ // by the offline-ops replay.
+ for (const [id, crdt] of nodes) {
+ if (crdt.type === CrdtType.TEXT) {
+ const node = context.pool.nodes.get(id);
+ if (node !== undefined && isLiveText(node)) {
+ // An authoritative snapshot from the server, so whatever it
+ // changes locally is a remote change as far as subscribers go.
+ const update = node._resyncText(crdt.data, crdt.version, REMOTE);
+ if (update !== undefined) {
+ result.updates.storageUpdates.set(
+ id,
+ mergeStorageUpdates(
+ result.updates.storageUpdates.get(id),
+ update
+ )
+ );
+ }
+ }
+ }
+ }
+
notify(result.updates);
} else {
context.root = LiveObject._fromItems(
@@ -2032,6 +2124,18 @@ export function createRoom<
});
}
+ function notifyPrivateHistory(event: PrivateHistoryEvent) {
+ if (historyDisabled > 0) return;
+ eventHub.privateHistory.notify(event);
+ }
+
+ function clearRedoStack() {
+ if (context.redoStack.length === 0) return;
+ const ids = context.redoStack.map((item) => item.id);
+ context.redoStack.length = 0;
+ notifyPrivateHistory({ action: "discard", ids });
+ }
+
/**
* Reconciles the live Storage so it matches the given target nodes (e.g. a
* historic version snapshot): diffs them against the current state and applies
@@ -2048,7 +2152,8 @@ export function createRoom<
}
const ops = diffCurrentStorageAgainst(
- new Map(nodes)
+ new Map(nodes),
+ { includeLiveTextUpdates: true }
);
if (ops.length === 0) {
return; // Already identical -- nothing to do.
@@ -2072,10 +2177,15 @@ export function createRoom<
function _addToRealUndoStack(frames: Stackframe[]) {
// If undo stack is too large, we remove the older item
if (context.undoStack.length >= 50) {
- context.undoStack.shift();
+ const evicted = context.undoStack.shift();
+ if (evicted !== undefined) {
+ notifyPrivateHistory({ action: "discard", ids: [evicted.id] });
+ }
}
- context.undoStack.push(frames);
+ const id = nextHistoryItemId++;
+ context.undoStack.push({ id, frames });
+ notifyPrivateHistory({ action: "push", id });
onHistoryChange();
}
@@ -2110,7 +2220,12 @@ export function createRoom<
}
if (storageUpdates !== undefined && storageUpdates.size > 0) {
- const updates = Array.from(storageUpdates.values());
+ // This is the only place Storage updates reach subscribers, so it's
+ // where the internal `optimistic` flag gets dropped. See OpSource.
+ const updates = Array.from(storageUpdates.values(), (update) => ({
+ ...update,
+ source: toUpdateSource(update.source),
+ }));
eventHub.storageBatch.notify(updates);
}
notifyStorageStatus();
@@ -2127,7 +2242,26 @@ export function createRoom<
);
}
- function applyLocalOps(frames: readonly Stackframe
[]): {
+ /**
+ * How each still-unacknowledged op was made, for the ops where that isn't a
+ * plain edit. Lets an ack be reported with the same `via` as the change it
+ * confirms, instead of every ack looking like a fresh edit.
+ */
+ const viaByOpId = new Map();
+
+ function viaOfAckedOp(opId: string): Via {
+ const via = viaByOpId.get(opId);
+ if (via === undefined) {
+ return "edit";
+ }
+ viaByOpId.delete(opId);
+ return via;
+ }
+
+ function applyLocalOps(
+ frames: readonly Stackframe[],
+ localSource: Extract = LOCAL_EDIT
+ ): {
opsToEmit: ClientWireOp[]; // Ops to send over the wire afterwards
reverse: Stackframe[]; // Reverse ops to add to the undo stack aftwards
// Updates to notify about afterwards
@@ -2141,17 +2275,89 @@ export function createRoom<
(f): f is PresenceStackframe
=> f.type === "presence"
);
+ // Restoring a detached LiveText starts a new server-side text timeline.
+ // Reusing the old ID could let operations from the deleted timeline apply
+ // to this new lifetime without being rebased. Ops replayed after reconnect
+ // already have opIds and must keep their original IDs.
+ const restoredTextIds = new Map();
+ for (const op of ops) {
+ if (
+ op.type === OpCode.CREATE_TEXT &&
+ op.opId === undefined &&
+ context.pool.nodes.get(op.id) === undefined &&
+ !restoredTextIds.has(op.id)
+ ) {
+ restoredTextIds.set(op.id, context.pool.generateId());
+ }
+ }
+
+ const remappedOps =
+ restoredTextIds.size === 0
+ ? ops
+ : ops.map((op): Op => {
+ if (op.opId !== undefined) {
+ return op;
+ }
+
+ const id = restoredTextIds.get(op.id);
+
+ if (isCreateOp(op)) {
+ const parentId = restoredTextIds.get(op.parentId);
+ const deletedId =
+ op.deletedId === undefined
+ ? undefined
+ : restoredTextIds.get(op.deletedId);
+
+ if (
+ id === undefined &&
+ parentId === undefined &&
+ deletedId === undefined
+ ) {
+ return op;
+ }
+
+ if (op.type === OpCode.CREATE_TEXT && id !== undefined) {
+ return {
+ ...op,
+ id,
+ version: 0,
+ ...(parentId === undefined ? {} : { parentId }),
+ ...(deletedId === undefined ? {} : { deletedId }),
+ };
+ }
+
+ return {
+ ...op,
+ ...(id === undefined ? {} : { id }),
+ ...(parentId === undefined ? {} : { parentId }),
+ ...(deletedId === undefined ? {} : { deletedId }),
+ };
+ }
+
+ return id === undefined ? op : { ...op, id };
+ });
+
// Ensure all local ops have opIds assigned before applying them
- const opsWithOpIds = ops.map((op: Op) =>
+ const opsWithOpIds = remappedOps.map((op: Op) =>
op.opId === undefined
? { ...op, opId: context.pool.generateOpId() }
: (op as ClientWireOp)
);
+ // Remember how these ops came about, so that when the server acks them we
+ // can report the ack the same way. Only history replays are recorded; a
+ // plain edit is what an unrecorded opId means (see viaOfAckedOp).
+ if (localSource.via !== "edit") {
+ for (const op of opsWithOpIds) {
+ viaByOpId.set(op.opId, localSource.via);
+ }
+ }
+
const { reverse, updates } = applyOps(
pframes,
opsWithOpIds,
- /* isLocal */ true
+ /* isLocal */ true,
+ localSource
);
return { opsToEmit: opsWithOpIds, reverse, updates };
}
@@ -2169,7 +2375,8 @@ export function createRoom<
function applyOps(
pframes: readonly PresenceStackframe[],
ops: readonly Op[],
- isLocal: boolean
+ isLocal: boolean,
+ localSource: Extract = LOCAL_EDIT
): {
reverse: Stackframe[];
updates: {
@@ -2214,14 +2421,20 @@ export function createRoom<
let source: OpSource;
if (isLocal) {
- source = OpSource.LOCAL;
+ source = { ...localSource, optimistic: true };
} else if (op.opId !== undefined) {
context.unacknowledgedOps.delete(op.opId);
- source = OpSource.OURS;
+ // The server echoing back our own op. It describes a change this
+ // client made, now confirmed, so it keeps the `via` it was made with.
+ source = {
+ origin: "local",
+ via: viaOfAckedOp(op.opId),
+ optimistic: false,
+ };
} else {
// Remotely generated Ops (and fix Ops as a special case of that)
// don't have opId anymore.
- source = OpSource.THEIRS;
+ source = REMOTE;
}
const applyOpResult = applyOp(op, source);
@@ -2246,6 +2459,7 @@ export function createRoom<
op.type === OpCode.CREATE_LIST ||
op.type === OpCode.CREATE_MAP ||
op.type === OpCode.CREATE_OBJECT ||
+ op.type === OpCode.CREATE_TEXT ||
op.type === OpCode.CREATE_FILE
) {
createdNodeIds.add(op.id);
@@ -2271,13 +2485,14 @@ export function createRoom<
switch (op.type) {
case OpCode.DELETE_OBJECT_KEY:
case OpCode.UPDATE_OBJECT:
+ case OpCode.UPDATE_TEXT:
case OpCode.DELETE_CRDT: {
const node = context.pool.nodes.get(op.id);
if (node === undefined) {
return { modified: false };
}
- return node._apply(op, source === OpSource.LOCAL);
+ return node._apply(op, source);
}
case OpCode.SET_PARENT_KEY: {
@@ -2298,6 +2513,7 @@ export function createRoom<
case OpCode.CREATE_OBJECT:
case OpCode.CREATE_LIST:
case OpCode.CREATE_MAP:
+ case OpCode.CREATE_TEXT:
case OpCode.CREATE_FILE:
case OpCode.CREATE_REGISTER: {
if (op.parentId === undefined) {
@@ -2635,17 +2851,45 @@ export function createRoom<
break;
}
- // Receiving a RejectedOps message in the client means that the server is no
- // longer in sync with the client. Trying to synchronize the client again by
- // rolling back particular Ops may be hard/impossible. It's fine to not try and
- // accept the out-of-sync reality and throw an error.
+ // Receiving a RejectedOps message means the server refused some of
+ // our ops, so our optimistic local state is out of sync with the
+ // server. For LiveText ops this is a normal (if rare) situation —
+ // e.g. a client that was offline long enough to fall outside the
+ // server's retained history window — and we can recover: drop the
+ // rejected pending state and re-fetch the authoritative storage
+ // snapshot. For other ops (e.g. permission rejections), rolling back
+ // particular Ops is hard/impossible, so we keep the old behavior of
+ // accepting the out-of-sync reality and surfacing an error.
case ServerMsgCode.REJECT_STORAGE_OP: {
console.errorWithTitle(
"Storage mutation rejection error",
message.reason
);
- if (process.env.NODE_ENV !== "production") {
+ let needsStorageResync = false;
+ for (const opId of message.opIds) {
+ const rejectedOp = context.unacknowledgedOps.get(opId);
+ context.unacknowledgedOps.delete(opId);
+ context.buffer.storageOperations =
+ context.buffer.storageOperations.filter((op) => op.opId !== opId);
+ viaByOpId.delete(opId);
+
+ if (
+ rejectedOp !== undefined &&
+ rejectedOp.type === OpCode.UPDATE_TEXT
+ ) {
+ const node = context.pool.nodes.get(rejectedOp.id);
+ if (node !== undefined && isLiveText(node)) {
+ node._rejectPendingOp(opId);
+ needsStorageResync = true;
+ }
+ }
+ }
+
+ if (needsStorageResync) {
+ refreshStorage();
+ flushNowOrSoon();
+ } else if (process.env.NODE_ENV !== "production") {
throw new Error(
`Storage mutations rejected by server: ${message.reason}`
);
@@ -3396,16 +3640,17 @@ export function createRoom<
if (context.activeBatch) {
throw new Error("undo is not allowed during a batch");
}
- const frames = context.undoStack.pop();
- if (frames === undefined) {
+ const item = context.undoStack.pop();
+ if (item === undefined) {
return;
}
context.pausedHistory = null;
- const result = applyLocalOps(frames);
+ const result = applyLocalOps(item.frames, LOCAL_UNDO);
+ context.redoStack.push({ id: item.id, frames: result.reverse });
+ notifyPrivateHistory({ action: "undo", id: item.id });
notify(result.updates);
- context.redoStack.push(result.reverse);
onHistoryChange();
for (const op of result.opsToEmit) {
@@ -3419,16 +3664,17 @@ export function createRoom<
throw new Error("redo is not allowed during a batch");
}
- const frames = context.redoStack.pop();
- if (frames === undefined) {
+ const item = context.redoStack.pop();
+ if (item === undefined) {
return;
}
context.pausedHistory = null;
- const result = applyLocalOps(frames);
+ const result = applyLocalOps(item.frames, LOCAL_REDO);
+ context.undoStack.push({ id: item.id, frames: result.reverse });
+ notifyPrivateHistory({ action: "redo", id: item.id });
notify(result.updates);
- context.undoStack.push(result.reverse);
onHistoryChange();
for (const op of result.opsToEmit) {
@@ -3440,6 +3686,8 @@ export function createRoom<
function clear() {
context.undoStack.length = 0;
context.redoStack.length = 0;
+ notifyPrivateHistory({ action: "clear" });
+ onHistoryChange();
}
function batch(callback: () => T): T {
@@ -3478,10 +3726,11 @@ export function createRoom<
commitPausedHistoryToUndoStack();
}
- if (currentBatch.ops.length > 0) {
- // Only clear the redo stack if something has changed during a batch
- // Clear the redo stack because batch is always called from a local operation
- context.redoStack.length = 0;
+ if (currentBatch.ops.length > 0 || currentBatch.clearRedoStack) {
+ // Clear redo when the batch mutated storage, or when a nested
+ // dispatch explicitly requested it (e.g. LiveText queued edits
+ // with empty `ops` but `clearRedoStack: true`).
+ clearRedoStack();
}
if (currentBatch.ops.length > 0) {
@@ -3517,14 +3766,11 @@ export function createRoom<
commitPausedHistoryToUndoStack();
}
- // Depth counter for nested history.disable() calls, 0 means history is not disabled
- let historyDisabled = 0;
-
function disableHistory(fn: () => T): T {
const origUndo = context.undoStack;
const origRedo = context.redoStack;
- const tempUndo: Stackframe[][] = [];
- const tempRedo: Stackframe
[][] = [];
+ const tempUndo: HistoryStackItem
[] = [];
+ const tempRedo: HistoryStackItem
[] = [];
context.undoStack = tempUndo;
context.redoStack = tempRedo;
historyDisabled++;
@@ -3890,9 +4136,26 @@ export function createRoom<
{
[kInternal]: {
get presenceBuffer() { return deepClone(context.buffer.presenceUpdates?.data ?? null) }, // prettier-ignore
- get undoStack() { return deepClone(context.undoStack) }, // prettier-ignore
+ get undoStack() {
+ return structuredClone(
+ context.undoStack.map((item) => ({
+ id: item.id,
+ frames: item.frames,
+ }))
+ );
+ }, // prettier-ignore
+ get redoStack() {
+ return structuredClone(
+ context.redoStack.map((item) => ({
+ id: item.id,
+ frames: item.frames,
+ }))
+ );
+ }, // prettier-ignore
get nodeCount() { return context.pool.nodes.size }, // prettier-ignore
+ history: eventHub.privateHistory.observable,
+
getYjsProvider() {
return context.yjsProvider;
},
diff --git a/packages/liveblocks-core/src/types/PlainLson.ts b/packages/liveblocks-core/src/types/PlainLson.ts
index 565916034ba..2d7d06f8888 100644
--- a/packages/liveblocks-core/src/types/PlainLson.ts
+++ b/packages/liveblocks-core/src/types/PlainLson.ts
@@ -41,6 +41,7 @@
*/
import type { Json } from "../lib/Json";
+import type { LiveTextData } from "../protocol/Op";
import type { LiveFileData } from "../protocol/StorageNode";
export type PlainLsonFields = Record;
@@ -60,6 +61,12 @@ export type PlainLsonList = {
data: PlainLson[];
};
+export type PlainLsonText = {
+ liveblocksType: "LiveText";
+ data: LiveTextData;
+ version?: number;
+};
+
export type PlainLsonFile = {
liveblocksType: "LiveFile";
data: LiveFileData;
@@ -69,6 +76,7 @@ export type PlainLson =
| PlainLsonObject
| PlainLsonMap
| PlainLsonList
+ | PlainLsonText
| PlainLsonFile
// Any "normal" Json value, as long as it's not an object with
diff --git a/packages/liveblocks-core/test-d/ToJson.test-d.ts b/packages/liveblocks-core/test-d/ToJson.test-d.ts
index d72a434520e..d4ec989c275 100644
--- a/packages/liveblocks-core/test-d/ToJson.test-d.ts
+++ b/packages/liveblocks-core/test-d/ToJson.test-d.ts
@@ -1,10 +1,11 @@
import type {
Lson,
LsonObject,
+ LiveTextData,
ReadonlyJsonObject,
ToJson,
} from "@liveblocks/core";
-import { LiveList, LiveMap, LiveObject } from "@liveblocks/core";
+import { LiveList, LiveMap, LiveObject, LiveText } from "@liveblocks/core";
import { describe, expectTypeOf, test } from "vitest";
declare const str: string;
@@ -152,6 +153,13 @@ describe("ToJson", () => {
}>();
});
+ // ---------------------------------------------------------------------------
+ // LiveText
+ // ---------------------------------------------------------------------------
+ test("LiveText", () => {
+ expectTypeOf(toJson(new LiveText("hello"))).toEqualTypeOf();
+ });
+
// ---------------------------------------------------------------------------
// Unions involving Live types
// ---------------------------------------------------------------------------
diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json
index 2022039d33e..9ae3af7b2bc 100644
--- a/packages/liveblocks-emails/package.json
+++ b/packages/liveblocks-emails/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/emails",
- "version": "3.23.1",
+ "version": "3.24.0",
"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",
"author": "Liveblocks Inc.",
diff --git a/packages/liveblocks-lexical/.gitignore b/packages/liveblocks-lexical/.gitignore
new file mode 100644
index 00000000000..5e4eff3eb02
--- /dev/null
+++ b/packages/liveblocks-lexical/.gitignore
@@ -0,0 +1,5 @@
+/scripts/*.js
+**/*.css
+**/*.css.map
+!/src/**/*.css
+!/src/**/*.css.map
diff --git a/packages/liveblocks-lexical/.stylelintrc.cjs b/packages/liveblocks-lexical/.stylelintrc.cjs
new file mode 100644
index 00000000000..0568d5a0d4d
--- /dev/null
+++ b/packages/liveblocks-lexical/.stylelintrc.cjs
@@ -0,0 +1,7 @@
+module.exports = {
+ extends: ["stylelint-config-standard"],
+ rules: {
+ "custom-property-pattern": /^lb-[a-z-]+$/,
+ "selector-class-pattern": /^lb-[a-z-:]+$/,
+ },
+};
diff --git a/packages/liveblocks-lexical/README.md b/packages/liveblocks-lexical/README.md
new file mode 100644
index 00000000000..32653acb867
--- /dev/null
+++ b/packages/liveblocks-lexical/README.md
@@ -0,0 +1,56 @@
+
+
+
+
+
+# `@liveblocks/lexical`
+
+
+
+
+
+
+
+`@liveblocks/lexical` provides APIs to integrate [Lexical](https://lexical.dev/)
+text editors with Liveblocks—a platform to build, host, and scale collaborative
+applications with zero configuration, no maintenance required.
+
+## Installation
+
+```
+npm install @liveblocks/client @liveblocks/react @liveblocks/lexical lexical @lexical/react @lexical/selection @lexical/utils
+```
+
+## Documentation
+
+Read the
+[documentation](https://liveblocks.io/docs/api-reference/liveblocks-lexical)
+for guides and API references.
+
+## Examples
+
+Explore our [collaborative examples](https://liveblocks.io/examples) to help you
+get started.
+
+> All examples are open-source and live in this repository, within
+> [`/examples`](../../examples).
+
+## Releases
+
+See the [latest changes](https://github.com/liveblocks/liveblocks/releases) or
+learn more about
+[upcoming releases](https://github.com/liveblocks/liveblocks/milestones).
+
+## Community
+
+- [Discord](https://liveblocks.io/discord) - To get involved with the Liveblocks
+ community, ask questions and share tips.
+- [X](https://x.com/liveblocks) - To receive updates, announcements, blog posts,
+ and general Liveblocks tips.
+
+## License
+
+Licensed under the Apache License 2.0, Copyright © 2021-present
+[Liveblocks](https://liveblocks.io).
+
+See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information.
diff --git a/packages/liveblocks-lexical/eslint.config.mjs b/packages/liveblocks-lexical/eslint.config.mjs
new file mode 100644
index 00000000000..9ce94f64b0a
--- /dev/null
+++ b/packages/liveblocks-lexical/eslint.config.mjs
@@ -0,0 +1,74 @@
+import { makeConfig } from "@liveblocks/eslint-config";
+import commonRestrictedSyntax from "@liveblocks/eslint-config/restricted-syntax";
+import react from "eslint-plugin-react";
+import reactHooks from "eslint-plugin-react-hooks";
+
+export default [
+ ...makeConfig(),
+
+ {
+ plugins: {
+ react,
+ "react-hooks": reactHooks,
+ },
+
+ settings: {
+ react: {
+ version: "detect",
+ },
+ },
+
+ rules: {
+ // -------------------------------
+ // Custom syntax we want to forbid
+ // -------------------------------
+ "no-restricted-syntax": [
+ "error",
+ ...commonRestrictedSyntax,
+ {
+ selector:
+ "ImportDeclaration[source.value='react'] ImportSpecifier[imported.name='use']",
+ message: "use is only available on React >=19.",
+ },
+ ],
+
+ // ----------------------------------------------------------------------
+ // Overrides from default rule config used in all other projects!
+ // ----------------------------------------------------------------------
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/unbound-method": "off",
+
+ // ----------------------------------------------------------------------
+ // Extra rules for this project specifically
+ // ----------------------------------------------------------------------
+
+ // Enforce React best practices
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "error",
+ "react/jsx-key": ["error", { checkFragmentShorthand: true }],
+ "react/no-unescaped-entities": "error",
+ "react/no-unknown-property": "error",
+
+ // Relax promise rules given how we use them in this project
+ "@typescript-eslint/no-floating-promises": "off",
+ "@typescript-eslint/no-misused-promises": "off",
+ },
+ },
+
+ {
+ files: ["src/**/__tests__/**"],
+
+ rules: {
+ // Ideally, enable these lint rules again later, as they are useful to
+ // catch bugs
+ "@typescript-eslint/no-unsafe-argument": "off",
+ "@typescript-eslint/no-unsafe-assignment": "off",
+ "@typescript-eslint/no-unsafe-return": "off",
+ "@typescript-eslint/unbound-method": "off",
+ "@typescript-eslint/no-floating-promises": "off",
+ "@typescript-eslint/no-unnecessary-type-assertion": "off",
+ },
+ },
+];
diff --git a/packages/liveblocks-lexical/package.json b/packages/liveblocks-lexical/package.json
new file mode 100644
index 00000000000..ad433e6d5b4
--- /dev/null
+++ b/packages/liveblocks-lexical/package.json
@@ -0,0 +1,105 @@
+{
+ "name": "@liveblocks/lexical",
+ "version": "3.24.0",
+ "description": "Lexical collaboration plugins backed by Liveblocks.",
+ "license": "Apache-2.0",
+ "author": "Liveblocks Inc.",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "types": "./dist/index.d.cts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "module": "./dist/index.js",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "./styles.css": {
+ "types": "./styles.css.d.cts",
+ "default": "./styles.css"
+ }
+ },
+ "files": [
+ "dist/**",
+ "**/*.css",
+ "**/*.css.d.cts",
+ "**/*.css.d.ts",
+ "**/*.css.map",
+ "README.md"
+ ],
+ "scripts": {
+ "dev": "rollup --config rollup.config.js --watch",
+ "build": "rollup --config rollup.config.js",
+ "format": "eslint --fix src/; stylelint --fix src/styles/; prettier --write src/",
+ "lint": "eslint src/; stylelint src/styles/",
+ "lint:package": "publint --strict && attw --pack",
+ "start": "pnpm run dev",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:ci": "vitest run --coverage",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@liveblocks/client": "workspace:*",
+ "@liveblocks/core": "workspace:*"
+ },
+ "peerDependencies": {
+ "@lexical/react": "^0.45",
+ "@lexical/selection": "^0.45",
+ "@lexical/utils": "^0.45",
+ "@liveblocks/react": "workspace:*",
+ "@types/react": "^18 || ^19",
+ "@types/react-dom": "^18 || ^19",
+ "lexical": "^0.45",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ },
+ "devDependencies": {
+ "@lexical/react": "0.45.0",
+ "@lexical/rich-text": "0.45.0",
+ "@lexical/selection": "0.45.0",
+ "@lexical/utils": "0.45.0",
+ "@liveblocks/eslint-config": "workspace:*",
+ "@liveblocks/react": "workspace:*",
+ "@liveblocks/rollup-config": "workspace:*",
+ "@liveblocks/vitest-config": "workspace:*",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "lexical": "0.45.0",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "rollup": "3.28.1",
+ "stylelint": "^15.10.2",
+ "stylelint-config-standard": "^34.0.0",
+ "typescript": "^5.9.3",
+ "vitest": "^4.1.4"
+ },
+ "sideEffects": false,
+ "bugs": {
+ "url": "https://github.com/liveblocks/liveblocks/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/liveblocks/liveblocks.git",
+ "directory": "packages/liveblocks-lexical"
+ },
+ "homepage": "https://liveblocks.io",
+ "keywords": [
+ "lexical",
+ "liveblocks",
+ "real-time",
+ "collaboration",
+ "collaborative",
+ "presence",
+ "crdts",
+ "synchronize",
+ "rooms",
+ "documents"
+ ]
+}
diff --git a/packages/liveblocks-lexical/rollup.config.js b/packages/liveblocks-lexical/rollup.config.js
new file mode 100644
index 00000000000..f10ab31ea41
--- /dev/null
+++ b/packages/liveblocks-lexical/rollup.config.js
@@ -0,0 +1,17 @@
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+
+import { createConfig } from "@liveblocks/rollup-config";
+
+import pkg from "./package.json" with { type: "json" };
+
+export default createConfig({
+ pkg,
+ entries: ["src/index.ts"],
+ styles: [
+ {
+ entry: "src/styles/index.css",
+ destination: "styles.css",
+ },
+ ],
+});
diff --git a/packages/liveblocks-lexical/src/__tests__/history.test.ts b/packages/liveblocks-lexical/src/__tests__/history.test.ts
new file mode 100644
index 00000000000..e3e32a52e67
--- /dev/null
+++ b/packages/liveblocks-lexical/src/__tests__/history.test.ts
@@ -0,0 +1,3895 @@
+import { HeadingNode, QuoteNode } from "@lexical/rich-text";
+import {
+ LiveList,
+ LiveMap,
+ LiveObject,
+ LiveText,
+ type Room,
+} from "@liveblocks/client";
+import { kInternal } from "@liveblocks/core";
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isParagraphNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ CAN_REDO_COMMAND,
+ CAN_UNDO_COMMAND,
+ CLEAR_EDITOR_COMMAND,
+ CLEAR_HISTORY_COMMAND,
+ COLLABORATION_TAG,
+ COMMAND_PRIORITY_CRITICAL,
+ createEditor as createLexicalEditor,
+ DecoratorNode,
+ type EditorConfig,
+ ElementNode,
+ HISTORIC_TAG,
+ HISTORY_MERGE_TAG,
+ HISTORY_PUSH_TAG,
+ type Klass,
+ type LexicalEditor,
+ type LexicalNode,
+ type LexicalUpdateJSON,
+ type NodeKey,
+ ParagraphNode,
+ PASTE_TAG,
+ REDO_COMMAND,
+ type SerializedElementNode,
+ type SerializedLexicalNode,
+ type Spread,
+ TextNode,
+ UNDO_COMMAND,
+} from "lexical";
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+import {
+ createSerializedRoot,
+ prepareIsolatedStorageTest,
+} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup";
+import { LiveblocksCollaboration } from "../collaboration";
+import { LiveblocksHistory } from "../history";
+import {
+ $getLexicalNodeProps,
+ LiveblocksCollaborationManager,
+} from "../manager";
+import type {
+ LiveDecoratorNode,
+ LiveElementNode,
+ LiveRootNode,
+ LiveTextNode,
+} from "../types";
+
+describe("LiveblocksHistory", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe("commands", () => {
+ test("undo/redo restores Storage via room.history", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ // Capture uses setTimeout — fake only after room async setup finishes.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+
+ // History first, then Lexical → Storage (same order as the plugin).
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("Hello!");
+
+ // Flush the open capture via the idle timer.
+ vi.advanceTimersByTime(1000);
+ expect(room.history.canUndo()).toBe(true);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canRedo()).toBe(true);
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello!");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("undo/redo projects Storage back into Lexical", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+
+ // Constructor schedules a non-discrete binding update; flush it first.
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ // Storage → Lexical, including local via:"undo"/via:"redo".
+ const unsubscribeStorage = room.subscribe(
+ document,
+ (updates) => {
+ if (
+ updates.every((update) => {
+ const source = update.source;
+ return source.origin === "local" && source.via === "edit";
+ })
+ ) {
+ return;
+ }
+
+ const isFromHistory = updates.some((update) => {
+ const source = update.source;
+ return (
+ source.origin === "local" &&
+ (source.via === "undo" || source.via === "redo")
+ );
+ });
+
+ editor.update(
+ () => {
+ manager.$applyRemoteUpdates(updates);
+ },
+ {
+ skipTransforms: true,
+ tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG,
+ }
+ );
+ },
+ { isDeep: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("Hello!");
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent())
+ ).toBe("Hello!");
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ // Nested historic editor.update commits on a microtask.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent())
+ ).toBe("Hello");
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello!");
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent())
+ ).toBe("Hello!");
+
+ unsubscribeStorage();
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("UNDO_COMMAND returns false when the stack is empty", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+
+ expect(editor.dispatchCommand(UNDO_COMMAND, undefined)).toBe(false);
+ expect(content.toString()).toBe("Hello");
+
+ history.unregister();
+ });
+
+ test("REDO_COMMAND returns false when the redo stack is empty", async () => {
+ const { room, document } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+
+ expect(editor.dispatchCommand(REDO_COMMAND, undefined)).toBe(false);
+
+ history.unregister();
+ });
+
+ test("commits the open capture group before undo", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("Hello!");
+ // Still paused — nothing on the real undo stack yet.
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canUndo()).toBe(false);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("commits the open capture group before redo", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(room[kInternal].undoStack).toHaveLength(0);
+ expect(room.history.canRedo()).toBe(false);
+
+ // Redo with an empty redo stack must still flush the open capture
+ // (redo would otherwise discard pausedHistory).
+ expect(editor.dispatchCommand(REDO_COMMAND, undefined)).toBe(false);
+ expect(room[kInternal].undoStack).toHaveLength(1);
+ expect(content.toString()).toBe("Hello!");
+ expect(room.history.canUndo()).toBe(true);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("CLEAR_HISTORY_COMMAND empties the stacks without changing Storage", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+ expect(room.history.canUndo()).toBe(true);
+
+ expect(editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined)).toBe(
+ true
+ );
+ expect(room.history.canUndo()).toBe(false);
+ expect(room.history.canRedo()).toBe(false);
+ expect(content.toString()).toBe("Hello!");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("CLEAR_EDITOR_COMMAND clears history and returns false", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined)).toBe(
+ false
+ );
+ expect(room.history.canUndo()).toBe(false);
+ expect(content.toString()).toBe("Hello!");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("dispatches CAN_UNDO / CAN_REDO as the stack changes", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+
+ let canUndo = false;
+ let canRedo = false;
+ editor.registerCommand(
+ CAN_UNDO_COMMAND,
+ (payload) => {
+ canUndo = payload;
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL
+ );
+ editor.registerCommand(
+ CAN_REDO_COMMAND,
+ (payload) => {
+ canRedo = payload;
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL
+ );
+
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ expect(canUndo).toBe(false);
+ expect(canRedo).toBe(false);
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+ expect(canUndo).toBe(true);
+ expect(canRedo).toBe(false);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(canUndo).toBe(false);
+ expect(canRedo).toBe(true);
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ expect(canUndo).toBe(true);
+ expect(canRedo).toBe(false);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("a new edit after undo clears the redo stack", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+
+ // Need Storage → Lexical so undo leaves the editor matching Storage
+ // before the branching edit.
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+ const unsubscribeStorage = room.subscribe(
+ document,
+ (updates) => {
+ if (
+ updates.every((update) => {
+ const source = update.source;
+ return source.origin === "local" && source.via === "edit";
+ })
+ ) {
+ return;
+ }
+
+ const isFromHistory = updates.some((update) => {
+ const source = update.source;
+ return (
+ source.origin === "local" &&
+ (source.via === "undo" || source.via === "redo")
+ );
+ });
+
+ editor.update(
+ () => {
+ manager.$applyRemoteUpdates(updates);
+ },
+ {
+ skipTransforms: true,
+ tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG,
+ }
+ );
+ },
+ { isDeep: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canRedo()).toBe(true);
+
+ // Branching edit — redo of "!" must be discarded.
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ // Mutation-while-paused must clear redo immediately (before idle commit).
+ expect(content.toString()).toBe("Hello?");
+ expect(room.history.canRedo()).toBe(false);
+
+ vi.advanceTimersByTime(1000);
+ expect(room.history.canUndo()).toBe(true);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canRedo()).toBe(true);
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(content.toString()).toBe("Hello?");
+ expect(room.history.canRedo()).toBe(false);
+
+ unsubscribeStorage();
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("historic projection does not echo back into Storage", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ const unsubscribeStorage = room.subscribe(
+ document,
+ (updates) => {
+ if (
+ updates.every((update) => {
+ const source = update.source;
+ return source.origin === "local" && source.via === "edit";
+ })
+ ) {
+ return;
+ }
+
+ const isFromHistory = updates.some((update) => {
+ const source = update.source;
+ return (
+ source.origin === "local" &&
+ (source.via === "undo" || source.via === "redo")
+ );
+ });
+
+ editor.update(
+ () => {
+ manager.$applyRemoteUpdates(updates);
+ },
+ {
+ skipTransforms: true,
+ tag: isFromHistory ? HISTORIC_TAG : COLLABORATION_TAG,
+ }
+ );
+ },
+ { isDeep: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent())
+ ).toBe("Hello");
+
+ // If HISTORIC_TAG failed to skip Lexical → Storage, a new capture would
+ // open and the idle timer would push another undo item.
+ vi.advanceTimersByTime(1000);
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canUndo()).toBe(false);
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ unsubscribeStorage();
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("unregister commits any open capture group", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ unregisterSync();
+ history.unregister();
+ expect(room[kInternal].undoStack).toHaveLength(1);
+ expect(content.toString()).toBe("Hello!");
+ });
+ });
+
+ describe("grouping", () => {
+ test("merges consecutive dirty edits while the capture is open", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!?");
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canUndo()).toBe(false);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("starts a new undo item after the idle timer commits", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!?");
+ expect(room[kInternal].undoStack.length).toBeGreaterThanOrEqual(2);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello!");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("resets the idle timer when the capture is extended", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(800);
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ // Resets the idle window.
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(800);
+ // Still capturing — first timer was cleared; second has 200ms left.
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ vi.advanceTimersByTime(200);
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("does not close the capture on selection-only updates", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+
+ // Caret moves: dirtyLeaves/Elements empty — must not commit capture.
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(0, 0);
+ },
+ { discrete: true }
+ );
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(3, 3);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!?");
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("ignores collaboration-tagged updates for capture", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true, tag: COLLABORATION_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ // Sync also skips COLLABORATION_TAG — Storage unchanged, no capture.
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canUndo()).toBe(false);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("ignores historic-tagged updates for capture", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true, tag: HISTORIC_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello");
+ expect(room.history.canUndo()).toBe(false);
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("historic and collaboration updates do not disturb an open capture", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ // Mid-capture peer/undo projections must not commit the pause group.
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("X");
+ },
+ { discrete: true, tag: COLLABORATION_TAG }
+ );
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("Y");
+ },
+ { discrete: true, tag: HISTORIC_TAG }
+ );
+ expect(room[kInternal].undoStack).toHaveLength(0);
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true }
+ );
+ expect(room[kInternal].undoStack).toHaveLength(0);
+ vi.advanceTimersByTime(1000);
+
+ // One stack item for the whole capture (tagged updates never committed).
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("merges structural dirty edits within the idle window", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ // Sync full document text so paragraph splits are reflected in Storage.
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+
+ // Paragraph insert dirties elements — still one capture with the insert.
+ editor.update(
+ () => {
+ $getRoot().append(
+ $createParagraphNode().append($createTextNode("World"))
+ );
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ // Lexical joins blocks with "\n\n" in getTextContent().
+ expect(content.toString()).toBe("Hello!\n\nWorld");
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+ });
+
+ describe("boundaries", () => {
+ test("treats PASTE_TAG as a hard undo boundary", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.clear();
+ paragraph.append($createTextNode("Hello!PASTE"));
+ },
+ { discrete: true, tag: PASTE_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!PASTE");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello!");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("treats HISTORY_PUSH_TAG as a hard boundary within the idle window", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true, tag: HISTORY_PUSH_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!?");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello!");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("HISTORY_MERGE_TAG prevents PASTE_TAG from splitting the capture", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ // MERGE is checked before hard boundaries — paste must not commit.
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.clear();
+ paragraph.append($createTextNode("Hello!PASTE"));
+ },
+ { discrete: true, tag: [HISTORY_MERGE_TAG, PASTE_TAG] }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("Hello!PASTE");
+ expect(room[kInternal].undoStack).toHaveLength(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("HISTORY_MERGE_TAG extends an open capture like a normal dirty edit", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("!");
+ },
+ { discrete: true }
+ );
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.selectEnd().insertText("?");
+ },
+ { discrete: true, tag: HISTORY_MERGE_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(room[kInternal].undoStack).toHaveLength(1);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+
+ test("two PASTE_TAG updates within the idle window stay separate", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const manager = new LiveblocksCollaborationManager(document, editor);
+ editor.update(() => {}, { discrete: true });
+ const history = new LiveblocksHistory(editor, room, manager);
+ history.register();
+ const unregisterSync = editor.registerUpdateListener(
+ ({ tags, editorState }) => {
+ if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
+ return;
+ }
+ editorState.read(() => {
+ const plain = $getRoot().getTextContent();
+ room.batch(() => {
+ const current = content.toString();
+ if (current === plain) return;
+ content.delete(0, current.length);
+ if (plain.length > 0) content.insert(0, plain);
+ });
+ });
+ }
+ );
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.clear();
+ paragraph.append($createTextNode("HelloA"));
+ },
+ { discrete: true, tag: PASTE_TAG }
+ );
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.clear();
+ paragraph.append($createTextNode("HelloAB"));
+ },
+ { discrete: true, tag: PASTE_TAG }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(content.toString()).toBe("HelloAB");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("HelloA");
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ expect(content.toString()).toBe("Hello");
+
+ unregisterSync();
+ history.unregister();
+ });
+ });
+
+ describe("selection restore", () => {
+ test("undo restores the caret from before a local insert", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ // Capture uses setTimeout — fake only after room async setup finishes.
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ // Constructor schedules a non-discrete binding update; flush it first.
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ // Prefer $setSelection over TextNode.select() for selection-only updates —
+ // the latter can dirty the text node and open a capture with the wrong before.
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(textNode.getKey(), 5, "text");
+ selection.focus.set(textNode.getKey(), 5, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(5, 5).insertText("!");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("Hello!");
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ // Nested historic editor.update commits on a microtask.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return {
+ offset: selection.anchor.offset,
+ collapsed: selection.isCollapsed(),
+ type: selection.anchor.type,
+ };
+ })
+ ).toEqual({ offset: 5, collapsed: true, type: "text" });
+
+ collaboration.unregister();
+ });
+
+ test("redo restores the caret from after a local insert", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(textNode.getKey(), 5, "text");
+ selection.focus.set(textNode.getKey(), 5, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(5, 5).insertText("!");
+ },
+ { discrete: true }
+ );
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello!");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return {
+ offset: selection.anchor.offset,
+ collapsed: selection.isCollapsed(),
+ type: selection.anchor.type,
+ };
+ })
+ ).toEqual({ offset: 6, collapsed: true, type: "text" });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores a mid-text caret after insert", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(textNode.getKey(), 2, "text");
+ selection.focus.set(textNode.getKey(), 2, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(2, 2).insertText("X");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("HeXllo");
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return {
+ offset: selection.anchor.offset,
+ collapsed: selection.isCollapsed(),
+ type: selection.anchor.type,
+ };
+ })
+ ).toEqual({ offset: 2, collapsed: true, type: "text" });
+
+ collaboration.unregister();
+ });
+
+ test("continuing to type after undo does not use a stale before selection", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ // Cycle 1: He|llo → HeXllo → undo → He|llo
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(textNode.getKey(), 2, "text");
+ selection.focus.set(textNode.getKey(), 2, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(2, 2).insertText("X");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("HeXllo");
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return selection.anchor.offset;
+ })
+ ).toBe(2);
+
+ // Cycle 2: insert again from the restored caret — no fresh selection-only
+ // update. After undo, collaboration sets `history.pendingBefore` from a
+ // freshly encoded selection. Without that, `#pendingBefore` would still
+ // be the previous item's `after` (offset 3) and this undo would land on
+ // Hell|o.
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ textNode.select(2, 2).insertText("X");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("HeXllo");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return selection.anchor.offset;
+ })
+ ).toBe(3);
+
+ vi.advanceTimersByTime(1000);
+
+ let restoreOnSecondUndo: { offset: number } | null = null;
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const restore = collaboration.history.pendingRestore;
+ if (restore === null) return;
+ restoreOnSecondUndo = { offset: restore.storage.anchor.offset };
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello");
+ expect(restoreOnSecondUndo).toEqual({ offset: 2 });
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return selection.anchor.offset;
+ })
+ ).toBe(2);
+
+ collaboration.unregister();
+ });
+
+ test("DIAG dirty flags on first selection after collab init", async () => {
+ const { room, document } = await createRoomWithText("First");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("First");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ const observations: Array<{
+ dirtyLeaves: number;
+ dirtyElements: number;
+ tags: string[];
+ binding: number;
+ encoded: unknown;
+ }> = [];
+
+ const unsub = editor.registerUpdateListener(
+ ({ editorState, dirtyLeaves, dirtyElements, tags }) => {
+ observations.push(
+ editorState.read(() => ({
+ dirtyLeaves: dirtyLeaves.size,
+ dirtyElements: dirtyElements.size,
+ tags: [...tags],
+ binding: collaboration.manager.binding.reverse.size,
+ encoded: collaboration.manager.$encodeSelection(),
+ }))
+ );
+ }
+ );
+
+ // First selection after init
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 1, "text");
+ selection.focus.set(text.getKey(), 4, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ unsub();
+ // Soft assert — print via expect for visibility
+ expect(observations).toEqual([
+ expect.objectContaining({
+ dirtyLeaves: 0,
+ dirtyElements: 0,
+ }),
+ ]);
+
+ collaboration.unregister();
+ });
+
+ test("DIAG select then delete without discrete (raf-batched)", async () => {
+ const { room, document } = await createRoomWithText("First");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("First");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ // Non-discrete: Lexical may merge updates in the same flush window.
+ editor.update(() => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 1, "text");
+ selection.focus.set(text.getKey(), 4, "text");
+ $setSelection(selection);
+ });
+
+ editor.update(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ });
+
+ // Flush pending Lexical updates
+ editor.update(() => {}, { discrete: true });
+
+ const content = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+ expect(content.toString()).toBe("Ft");
+
+ vi.advanceTimersByTime(1000);
+
+ let restore: { a: number; f: number } | null = null;
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const r = collaboration.history.pendingRestore;
+ if (r === null) return;
+ restore = { a: r.lexical.anchor.offset, f: r.lexical.focus.offset };
+ });
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect({
+ restore,
+ selection: editor.read(() => {
+ const s = $getSelection();
+ if (!$isRangeSelection(s)) return null;
+ return {
+ a: s.anchor.offset,
+ f: s.focus.offset,
+ c: s.isCollapsed(),
+ };
+ }),
+ }).toEqual({
+ restore: { a: 1, f: 4 },
+ selection: { a: 1, f: 4, c: false },
+ });
+
+ collaboration.unregister();
+ });
+
+ test("DIAG first selection before binding flush then delete", async () => {
+ const { room, document } = await createRoomWithText("First");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("First");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ // No binding flush — simulate racing first interaction.
+ collaboration.register();
+
+ const encBeforeFlush = editor.read(() => ({
+ binding: collaboration.manager.binding.reverse.size,
+ encoded: collaboration.manager.$encodeSelection(),
+ }));
+
+ // Selection against pre-rebuild keys (createEditor tree), while manager
+ // rebuild may still be pending.
+ editor.update(() => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 1, "text");
+ selection.focus.set(text.getKey(), 4, "text");
+ $setSelection(selection);
+ });
+
+ editor.update(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ });
+
+ editor.update(() => {}, { discrete: true });
+
+ const content = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+
+ vi.advanceTimersByTime(1000);
+
+ let restore: unknown = "unset";
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ restore = collaboration.history.pendingRestore;
+ });
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect({
+ encBeforeFlush,
+ content: content.toString(),
+ restore,
+ selection: editor.read(() => {
+ const s = $getSelection();
+ if (!$isRangeSelection(s)) return null;
+ return {
+ a: s.anchor.offset,
+ f: s.focus.offset,
+ c: s.isCollapsed(),
+ };
+ }),
+ }).toEqual({
+ encBeforeFlush: expect.anything(),
+ content: "First",
+ restore: expect.objectContaining({
+ lexical: expect.objectContaining({
+ anchor: expect.objectContaining({ offset: 1 }),
+ }),
+ }),
+ selection: { a: 1, f: 4, c: false },
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores a partial text range selection after delete", async () => {
+ const { room, document } = await createRoomWithText("First");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("First");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 1, "text");
+ selection.focus.set(text.getKey(), 4, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ const content = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+ expect(content.toString()).toBe("Ft");
+
+ vi.advanceTimersByTime(1000);
+
+ let restoreAtUndo: {
+ storageAnchor: number;
+ storageFocus: number;
+ lexicalAnchor: number;
+ lexicalFocus: number;
+ decodeAnchor: number | null;
+ } | null = null;
+
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const restore = collaboration.history.pendingRestore;
+ if (restore === null) return;
+ restoreAtUndo = {
+ storageAnchor: restore.storage.anchor.offset,
+ storageFocus: restore.storage.focus.offset,
+ lexicalAnchor: restore.lexical.anchor.offset,
+ lexicalFocus: restore.lexical.focus.offset,
+ decodeAnchor: content[kInternal].decodeIndex(
+ restore.storage.anchor.offset,
+ restore.storage.anchor.version
+ ),
+ };
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("First");
+ // Storage decode still remaps the left edge (1 → 4), but Lexical
+ // snapshot keeps the pre-delete offsets and is preferred when the key
+ // is still bound.
+ expect(restoreAtUndo).toEqual({
+ storageAnchor: 1,
+ storageFocus: 4,
+ lexicalAnchor: 1,
+ lexicalFocus: 4,
+ decodeAnchor: 4,
+ });
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return {
+ anchor: {
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ },
+ focus: {
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ },
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ anchor: { offset: 1, type: "text" },
+ focus: { offset: 4, type: "text" },
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores a multi-paragraph range selection after delete", async () => {
+ const { room, document } = await createTwoParagraphRoom();
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration } = createCollaborationFromDocument(
+ room,
+ document
+ );
+
+ editor.update(
+ () => {
+ const paragraphs = $getRoot()
+ .getChildren()
+ .filter($isParagraphNode) as ParagraphNode[];
+ const first = paragraphs[0]!;
+ const secondText = paragraphs[1]!.getFirstChild();
+ if (secondText === null || !$isTextNode(secondText)) {
+ throw new Error("Expected text in second paragraph");
+ }
+
+ const selection = $createRangeSelection();
+ selection.anchor.set(first.getKey(), 0, "element");
+ selection.focus.set(
+ secondText.getKey(),
+ secondText.getTextContentSize(),
+ "text"
+ );
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ expect(document.get("children").length).toBe(1);
+
+ vi.advanceTimersByTime(1000);
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(document.get("children").length).toBe(2);
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const paragraphs = $getRoot()
+ .getChildren()
+ .filter($isParagraphNode) as ParagraphNode[];
+ const first = paragraphs[0]!;
+ const secondText = paragraphs[1]!.getFirstChild();
+ if (secondText === null || !$isTextNode(secondText)) {
+ return null;
+ }
+ return {
+ anchorMatchesFirst: selection.anchor.key === first.getKey(),
+ anchor: {
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ },
+ focusMatchesSecondText: selection.focus.key === secondText.getKey(),
+ focus: {
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ },
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ anchorMatchesFirst: true,
+ anchor: { offset: 0, type: "element" },
+ focusMatchesSecondText: true,
+ focus: { offset: 6, type: "text" },
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores text-to-text multi-paragraph selection offsets after delete", async () => {
+ const { room, document } = await createTwoParagraphRoom();
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration, manager } =
+ createCollaborationFromDocument(room, document);
+
+ editor.update(
+ () => {
+ const paragraphs = $getRoot()
+ .getChildren()
+ .filter($isParagraphNode) as ParagraphNode[];
+ const firstText = paragraphs[0]!.getFirstChild();
+ const secondText = paragraphs[1]!.getFirstChild();
+ if (
+ firstText === null ||
+ !$isTextNode(firstText) ||
+ secondText === null ||
+ !$isTextNode(secondText)
+ ) {
+ throw new Error("Expected text in both paragraphs");
+ }
+
+ const selection = $createRangeSelection();
+ selection.anchor.set(firstText.getKey(), 1, "text");
+ selection.focus.set(
+ secondText.getKey(),
+ secondText.getTextContentSize(),
+ "text"
+ );
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ const beforeSelection = editor.read(() => manager.$encodeSelection());
+ expect(beforeSelection).not.toBeNull();
+ expect(beforeSelection!.anchor.offset).toBe(1);
+ expect(beforeSelection!.focus.offset).toBe(6);
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ vi.advanceTimersByTime(1000);
+
+ let decodeAtUndo: {
+ storageAnchor: number;
+ lexicalAnchor: number;
+ decodeAnchor: number | null;
+ } | null = null;
+ const firstContent = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const restore = collaboration.history.pendingRestore;
+ if (restore === null) return;
+ decodeAtUndo = {
+ storageAnchor: restore.storage.anchor.offset,
+ lexicalAnchor: restore.lexical.anchor.offset,
+ decodeAnchor: firstContent[kInternal].decodeIndex(
+ restore.storage.anchor.offset,
+ restore.storage.anchor.version
+ ),
+ };
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // Storage decode remaps surviving first-paragraph endpoint (1 → 5);
+ // Lexical snapshot keeps offset 1 and is preferred when the key survives.
+ // Focus on the recreated second paragraph falls back to storage decode.
+ expect(decodeAtUndo).toEqual({
+ storageAnchor: 1,
+ lexicalAnchor: 1,
+ decodeAnchor: 5,
+ });
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const paragraphs = $getRoot()
+ .getChildren()
+ .filter($isParagraphNode) as ParagraphNode[];
+ const firstText = paragraphs[0]!.getFirstChild();
+ const secondText = paragraphs[1]!.getFirstChild();
+ if (
+ firstText === null ||
+ !$isTextNode(firstText) ||
+ secondText === null ||
+ !$isTextNode(secondText)
+ ) {
+ return null;
+ }
+ return {
+ anchorMatchesFirstText: selection.anchor.key === firstText.getKey(),
+ anchor: {
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ },
+ focusMatchesSecondText: selection.focus.key === secondText.getKey(),
+ focus: {
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ },
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ anchorMatchesFirstText: true,
+ anchor: { offset: 1, type: "text" },
+ focusMatchesSecondText: true,
+ focus: { offset: 6, type: "text" },
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("redo restores the post-delete collapsed caret after multi-paragraph delete", async () => {
+ const { room, document } = await createTwoParagraphRoom();
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration, manager } =
+ createCollaborationFromDocument(room, document);
+
+ editor.update(
+ () => {
+ const paragraphs = $getRoot()
+ .getChildren()
+ .filter($isParagraphNode) as ParagraphNode[];
+ const first = paragraphs[0]!;
+ const secondText = paragraphs[1]!.getFirstChild();
+ if (secondText === null || !$isTextNode(secondText)) {
+ throw new Error("Expected text in second paragraph");
+ }
+
+ const selection = $createRangeSelection();
+ selection.anchor.set(first.getKey(), 0, "element");
+ selection.focus.set(
+ secondText.getKey(),
+ secondText.getTextContentSize(),
+ "text"
+ );
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ vi.advanceTimersByTime(1000);
+
+ const afterDeleteSelection = editor.read(() =>
+ manager.$encodeSelection()
+ );
+ expect(afterDeleteSelection).not.toBeNull();
+ expect(afterDeleteSelection!.anchor).toEqual(afterDeleteSelection!.focus);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(document.get("children").length).toBe(1);
+ const restored = editor.read(() => manager.$encodeSelection());
+ expect(restored).not.toBeNull();
+ expect(restored!.anchor).toEqual(afterDeleteSelection!.anchor);
+ expect(restored!.focus).toEqual(afterDeleteSelection!.focus);
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return selection.isCollapsed();
+ })
+ ).toBe(true);
+
+ collaboration.unregister();
+ });
+
+ test("clearing history does not leave a pending selection restore", async () => {
+ const { room, document, content } = await createRoomWithText("Hello");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hello");
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const textNode = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (textNode === null || !$isTextNode(textNode)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(textNode.getKey(), 2, "text");
+ selection.focus.set(textNode.getKey(), 4, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.insertText("X");
+ },
+ { discrete: true }
+ );
+ expect(content.toString()).toBe("HeXo");
+
+ vi.advanceTimersByTime(1000);
+ room.history.clear();
+
+ expect(collaboration.history.pendingRestore).toBeNull();
+ expect(room.history.canUndo()).toBe(false);
+
+ collaboration.unregister();
+ });
+
+ test("undo restores a range selection after deleting across mixed formatting", async () => {
+ // "Hello " (plain) + "world" (bold). Select from offset 3 in plain
+ // through the bold span, delete, undo — selection should cover
+ // "lo world" again (flat 3–11), even if TextNode keys are recreated.
+ const { room, document, content } = await createRoomWithFormattedText([
+ ["Hello "],
+ ["world", { bold: true }],
+ ]);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration, manager } =
+ createCollaborationFromDocument(room, document);
+
+ expect(content.toString()).toBe("Hello world");
+ expect(
+ editor.read(() => {
+ const texts = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getChildren();
+ return texts.map((node) => {
+ if (!$isTextNode(node)) return null;
+ return {
+ text: node.getTextContent(),
+ bold: node.hasFormat("bold"),
+ };
+ });
+ })
+ ).toEqual([
+ { text: "Hello ", bold: false },
+ { text: "world", bold: true },
+ ]);
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const plain = paragraph.getFirstChild();
+ const bold = paragraph.getLastChild();
+ if (
+ plain === null ||
+ !$isTextNode(plain) ||
+ bold === null ||
+ !$isTextNode(bold)
+ ) {
+ throw new Error("Expected plain + bold text nodes");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(plain.getKey(), 3, "text");
+ selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ const beforeSelection = editor.read(() => manager.$encodeSelection());
+ expect(beforeSelection).not.toBeNull();
+ expect(beforeSelection!.anchor.offset).toBe(3);
+ expect(beforeSelection!.focus.offset).toBe(11);
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ expect(content.toString()).toBe("Hel");
+ vi.advanceTimersByTime(1000);
+
+ let restoreAtUndo: {
+ storageAnchor: number;
+ storageFocus: number;
+ localAnchor: number;
+ localFocus: number;
+ lexicalAnchorOffset: number;
+ lexicalFocusOffset: number;
+ decodeAnchor: number | null;
+ } | null = null;
+
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const restore = collaboration.history.pendingRestore;
+ if (restore === null) return;
+ restoreAtUndo = {
+ storageAnchor: restore.storage.anchor.offset,
+ storageFocus: restore.storage.focus.offset,
+ localAnchor: restore.local.anchor.offset,
+ localFocus: restore.local.focus.offset,
+ lexicalAnchorOffset: restore.lexical.anchor.offset,
+ lexicalFocusOffset: restore.lexical.focus.offset,
+ decodeAnchor: content[kInternal].decodeIndex(
+ restore.storage.anchor.offset,
+ restore.storage.anchor.version
+ ),
+ };
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello world");
+ // Storage decode remaps the left edge (3 → 11). Local flat offsets keep
+ // the pre-delete range and are used when Lexical keys were recreated.
+ expect(restoreAtUndo).toEqual({
+ storageAnchor: 3,
+ storageFocus: 11,
+ localAnchor: 3,
+ localFocus: 11,
+ lexicalAnchorOffset: 3,
+ lexicalFocusOffset: 5,
+ decodeAnchor: 11,
+ });
+
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const plain = paragraph.getFirstChild();
+ const bold = paragraph.getLastChild();
+ if (
+ plain === null ||
+ !$isTextNode(plain) ||
+ bold === null ||
+ !$isTextNode(bold)
+ ) {
+ return null;
+ }
+ return {
+ text: $getRoot().getTextContent(),
+ segments: paragraph.getChildren().map((node) => {
+ if (!$isTextNode(node)) return null;
+ return {
+ text: node.getTextContent(),
+ bold: node.hasFormat("bold"),
+ };
+ }),
+ anchorMatchesPlain: selection.anchor.key === plain.getKey(),
+ anchor: {
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ },
+ focusMatchesBold: selection.focus.key === bold.getKey(),
+ focus: {
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ },
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ text: "Hello world",
+ segments: [
+ { text: "Hello ", bold: false },
+ { text: "world", bold: true },
+ ],
+ anchorMatchesPlain: true,
+ anchor: { offset: 3, type: "text" },
+ focusMatchesBold: true,
+ focus: { offset: 5, type: "text" },
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo selection restore ignores stale reverse bindings for detached keys", async () => {
+ // Repro: binding.reverse.has(key) can stay true for a key that
+ // $getNodeByKey returns null for. Preferring that lexical snapshot
+ // used to throw PointType.set: node with key X is [not found].
+ const { room, document, content } = await createRoomWithFormattedText([
+ ["Hello "],
+ ["world", { bold: true }],
+ ]);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration, manager } =
+ createCollaborationFromDocument(room, document);
+
+ const liveText = (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode;
+
+ let staleAnchorKey: string | null = null;
+ let staleFocusKey: string | null = null;
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const plain = paragraph.getFirstChild();
+ const bold = paragraph.getLastChild();
+ if (
+ plain === null ||
+ !$isTextNode(plain) ||
+ bold === null ||
+ !$isTextNode(bold)
+ ) {
+ throw new Error("Expected plain + bold text nodes");
+ }
+ staleAnchorKey = plain.getKey();
+ staleFocusKey = bold.getKey();
+ const selection = $createRangeSelection();
+ selection.anchor.set(plain.getKey(), 3, "text");
+ selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ expect(content.toString()).toBe("Hel");
+ expect(staleAnchorKey).not.toBeNull();
+ expect(staleFocusKey).not.toBeNull();
+
+ // Detached keys that createBinding will not scrub (not in forward[]).
+ (manager.binding.reverse as Map).set(
+ staleAnchorKey!,
+ liveText
+ );
+ (manager.binding.reverse as Map).set(
+ staleFocusKey!,
+ liveText
+ );
+
+ vi.advanceTimersByTime(1000);
+
+ expect(() => {
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ }).not.toThrow();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello world");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ return {
+ text: $getRoot().getTextContent(),
+ anchor: {
+ offset: selection.anchor.offset,
+ type: selection.anchor.type,
+ },
+ focus: {
+ offset: selection.focus.offset,
+ type: selection.focus.type,
+ },
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ text: "Hello world",
+ anchor: { offset: 3, type: "text" },
+ focus: { offset: 5, type: "text" },
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores a range selection after deleting inside a bold span", async () => {
+ // Uniform bold LiveText — same left-edge decode remap as plain text,
+ // but the TextNode carries formatting. Select "orl" in "world", delete,
+ // undo → selection should land back on offsets 1–4 of the bold node.
+ const { room, document, content } = await createRoomWithFormattedText([
+ ["world", { bold: true }],
+ ]);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration } = createCollaborationFromDocument(
+ room,
+ document
+ );
+
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected bold text node");
+ }
+ expect(text.hasFormat("bold")).toBe(true);
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 1, "text");
+ selection.focus.set(text.getKey(), 4, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ expect(content.toString()).toBe("wd");
+ vi.advanceTimersByTime(1000);
+
+ let decodeAtUndo: number | null = null;
+ const unsub = room[kInternal].history.subscribe((event) => {
+ if (event.action !== "undo") return;
+ const restore = collaboration.history.pendingRestore;
+ if (restore === null) return;
+ decodeAtUndo = content[kInternal].decodeIndex(
+ restore.storage.anchor.offset,
+ restore.storage.anchor.version
+ );
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ unsub();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("world");
+ // Storage decode still remaps the left edge (1 → 4); Lexical snapshot
+ // must win when the bold TextNode key survives (single-segment path).
+ expect(decodeAtUndo).toBe(4);
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ return null;
+ }
+ return {
+ bold: text.hasFormat("bold"),
+ anchorMatches: selection.anchor.key === text.getKey(),
+ focusMatches: selection.focus.key === text.getKey(),
+ anchor: selection.anchor.offset,
+ focus: selection.focus.offset,
+ isCollapsed: selection.isCollapsed(),
+ };
+ })
+ ).toEqual({
+ bold: true,
+ anchorMatches: true,
+ focusMatches: true,
+ anchor: 1,
+ focus: 4,
+ isCollapsed: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores selection after deleting only the bold sibling", async () => {
+ // Delete the entire bold sibling while leaving plain text. Undo must
+ // re-select "world". Flat offset 6 is the plain|bold boundary — decode
+ // may land at end of plain or start of bold; both select the same text.
+ const { room, document, content } = await createRoomWithFormattedText([
+ ["Hello "],
+ ["world", { bold: true }],
+ ]);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const { editor, collaboration, manager } =
+ createCollaborationFromDocument(room, document);
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const bold = paragraph.getLastChild();
+ if (bold === null || !$isTextNode(bold)) {
+ throw new Error("Expected bold text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(bold.getKey(), 0, "text");
+ selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ throw new Error("Expected range selection");
+ }
+ selection.removeText();
+ },
+ { discrete: true }
+ );
+
+ expect(content.toString()).toBe("Hello ");
+ vi.advanceTimersByTime(1000);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(content.toString()).toBe("Hello world");
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ return {
+ segments: paragraph.getChildren().map((node) => {
+ if (!$isTextNode(node)) return null;
+ return {
+ text: node.getTextContent(),
+ bold: node.hasFormat("bold"),
+ };
+ }),
+ selectedText: selection.getTextContent(),
+ isCollapsed: selection.isCollapsed(),
+ local: {
+ anchor: manager.$encodeLocalPoint(selection.anchor)?.offset,
+ focus: manager.$encodeLocalPoint(selection.focus)?.offset,
+ },
+ };
+ })
+ ).toEqual({
+ segments: [
+ { text: "Hello ", bold: false },
+ { text: "world", bold: true },
+ ],
+ selectedText: "world",
+ isCollapsed: false,
+ local: { anchor: 6, focus: 11 },
+ });
+
+ collaboration.unregister();
+ });
+ });
+
+ describe("decorator nodes", () => {
+ test("undo/redo insert and remove of a decorator child", async () => {
+ const { room, document } = await createRoomWithText("Hi");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hi", [CustomDecoratorNode]);
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.append(
+ $createCustomDecoratorNode({
+ src: "https://example.com/a.png",
+ altText: "A",
+ })
+ );
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(paragraph_liveblocks.get("children").length).toBe(2);
+ expect(paragraph_liveblocks.get("children").get(1)!.get("kind")).toBe(
+ "decorator"
+ );
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(paragraph_liveblocks.get("children").length).toBe(1);
+ expect(
+ editor.read(() =>
+ ($getRoot().getFirstChild() as ParagraphNode).getChildrenSize()
+ )
+ ).toBe(1);
+
+ editor.dispatchCommand(REDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(paragraph_liveblocks.get("children").length).toBe(2);
+ expect(paragraph_liveblocks.get("children").get(1)!.get("kind")).toBe(
+ "decorator"
+ );
+ expect(
+ (paragraph_liveblocks.get("children").get(1)! as LiveDecoratorNode)
+ .get("props")
+ ?.toJSON()
+ ).toEqual({
+ src: "https://example.com/a.png",
+ altText: "A",
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo restores the caret from before a decorator insert", async () => {
+ const { room, document } = await createRoomWithText("Hi");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("Hi", [CustomDecoratorNode]);
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 2, "text");
+ selection.focus.set(text.getKey(), 2, "text");
+ $setSelection(selection);
+ },
+ { discrete: true }
+ );
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.append(
+ $createCustomDecoratorNode({
+ src: "https://example.com/a.png",
+ altText: "A",
+ })
+ );
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(
+ (document.get("children").get(0) as LiveElementNode).get("children")
+ .length
+ ).toBe(2);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(
+ (document.get("children").get(0) as LiveElementNode).get("children")
+ .length
+ ).toBe(1);
+ expect(
+ editor.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return null;
+ }
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ return {
+ offset: selection.anchor.offset,
+ collapsed: selection.isCollapsed(),
+ type: selection.anchor.type,
+ onText: text !== null && selection.anchor.key === text.getKey(),
+ };
+ })
+ ).toEqual({
+ offset: 2,
+ collapsed: true,
+ type: "text",
+ onText: true,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo/redo decorator prop changes", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.history.disable(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "decorator",
+ type: "custom-decorator",
+ version: 1,
+ props: new LiveMap([
+ ["src", "https://example.com/a.png"],
+ ["altText", "A"],
+ ]),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("", [CustomDecoratorNode]);
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ const decorator_liveblocks = (
+ document.get("children").get(0) as LiveElementNode
+ )
+ .get("children")
+ .get(0)! as LiveDecoratorNode;
+
+ editor.update(
+ () => {
+ const decorator = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild() as CustomDecoratorNode;
+ const writable = decorator.getWritable();
+ writable.__src = "https://example.com/b.png";
+ writable.__altText = "B";
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({
+ src: "https://example.com/b.png",
+ altText: "B",
+ });
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(decorator_liveblocks.get("props")?.toJSON()).toEqual({
+ src: "https://example.com/a.png",
+ altText: "A",
+ });
+ expect(
+ editor.read(() => {
+ const decorator = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild() as CustomDecoratorNode;
+ return $getLexicalNodeProps(decorator);
+ })
+ ).toEqual({
+ src: "https://example.com/a.png",
+ altText: "A",
+ });
+
+ collaboration.unregister();
+ });
+ });
+
+ describe("inline element undo with formatted text", () => {
+ test("undo mark next to bold does not duplicate trailing text in Lexical", async () => {
+ const { room, document } = await createRoomWithText("How are you?");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("How are you?", [InlineMarkNode]);
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ // Bold "are" → multi-segment LiveText under one storage child.
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 4, "text");
+ selection.focus.set(text.getKey(), 7, "text");
+ $setSelection(selection);
+ selection.formatText("bold");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ // Mark plain suffix " you" next to the bold span (leaves trailing "?").
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild();
+ if (!$isParagraphNode(paragraph)) {
+ throw new Error("Expected paragraph");
+ }
+ const suffix = paragraph
+ .getChildren()
+ .find(
+ (child) =>
+ $isTextNode(child) && child.getTextContent() === " you?"
+ );
+ if (suffix === undefined || !$isTextNode(suffix)) {
+ throw new Error("Expected suffix text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(suffix.getKey(), 0, "text");
+ selection.focus.set(suffix.getKey(), 4, "text");
+ $setSelection(selection);
+ $wrapSelectionInInlineMark();
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+ expect(paragraph_liveblocks.get("children").length).toBeGreaterThan(1);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(
+ paragraph_liveblocks.get("children").map((child) => ({
+ kind: child.get("kind"),
+ content:
+ child.get("kind") === "text"
+ ? (child as LiveTextNode).get("content").toJSON()
+ : undefined,
+ }))
+ ).toEqual([
+ {
+ kind: "text",
+ content: [["How "], ["are", { bold: true }], [" you?"]],
+ },
+ ]);
+
+ expect(
+ editor.read(() => {
+ const paragraph = $getRoot().getFirstChild();
+ if (!$isParagraphNode(paragraph)) {
+ throw new Error("Expected paragraph");
+ }
+ return {
+ text: paragraph.getTextContent(),
+ hasMark: paragraph
+ .getChildren()
+ .some((child) => $isInlineMarkNode(child)),
+ };
+ })
+ ).toEqual({
+ text: "How are you?",
+ hasMark: false,
+ });
+
+ collaboration.unregister();
+ });
+
+ test("undo mark wrapping the bold span restores formatted text", async () => {
+ const { room, document } = await createRoomWithText("How are you?");
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+
+ const editor = createEditor("How are you?", [InlineMarkNode]);
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ editor.update(
+ () => {
+ const text = (
+ $getRoot().getFirstChild() as ParagraphNode
+ ).getFirstChild();
+ if (text === null || !$isTextNode(text)) {
+ throw new Error("Expected text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(text.getKey(), 4, "text");
+ selection.focus.set(text.getKey(), 7, "text");
+ $setSelection(selection);
+ selection.formatText("bold");
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ editor.update(
+ () => {
+ const paragraph = $getRoot().getFirstChild();
+ if (!$isParagraphNode(paragraph)) {
+ throw new Error("Expected paragraph");
+ }
+ const bold = paragraph
+ .getChildren()
+ .find((child) => $isTextNode(child) && child.hasFormat("bold"));
+ if (bold === undefined || !$isTextNode(bold)) {
+ throw new Error("Expected bold text node");
+ }
+ const selection = $createRangeSelection();
+ selection.anchor.set(bold.getKey(), 0, "text");
+ selection.focus.set(bold.getKey(), bold.getTextContentSize(), "text");
+ $setSelection(selection);
+ $wrapSelectionInInlineMark();
+ },
+ { discrete: true }
+ );
+ vi.advanceTimersByTime(1000);
+
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(
+ editor.read(() => {
+ const paragraph = $getRoot().getFirstChild();
+ if (!$isParagraphNode(paragraph)) {
+ throw new Error("Expected paragraph");
+ }
+ return {
+ text: paragraph.getTextContent(),
+ hasMark: paragraph
+ .getChildren()
+ .some((child) => $isInlineMarkNode(child)),
+ spans: paragraph.getChildren().map((child) => ({
+ type: child.getType(),
+ text: child.getTextContent(),
+ bold: $isTextNode(child) ? child.hasFormat("bold") : false,
+ })),
+ };
+ })
+ ).toEqual({
+ text: "How are you?",
+ hasMark: false,
+ spans: [
+ { type: "text", text: "How ", bold: false },
+ { type: "text", text: "are", bold: true },
+ { type: "text", text: " you?", bold: false },
+ ],
+ });
+
+ collaboration.unregister();
+ });
+ });
+});
+
+type SerializedInlineMarkNode = Spread<
+ { type: "inline-mark"; ids: string[] },
+ SerializedElementNode
+>;
+
+/** Minimal MarkNode stand-in — inline element that splits paragraph children. */
+class InlineMarkNode extends ElementNode {
+ __ids: string[];
+
+ static getType(): string {
+ return "inline-mark";
+ }
+
+ static clone(node: InlineMarkNode): InlineMarkNode {
+ return new InlineMarkNode(node.__ids, node.__key);
+ }
+
+ constructor(ids: string[] = ["mark"], key?: NodeKey) {
+ super(key);
+ this.__ids = ids;
+ }
+
+ createDOM(_config: EditorConfig): HTMLElement {
+ return document.createElement("mark");
+ }
+
+ updateDOM(): boolean {
+ return false;
+ }
+
+ isInline(): true {
+ return true;
+ }
+
+ exportJSON(): SerializedInlineMarkNode {
+ return {
+ ...super.exportJSON(),
+ type: "inline-mark",
+ ids: this.__ids,
+ };
+ }
+
+ static importJSON(serialized: SerializedInlineMarkNode): InlineMarkNode {
+ return $createInlineMarkNode(serialized.ids);
+ }
+}
+
+function $createInlineMarkNode(ids: string[] = ["mark"]): InlineMarkNode {
+ return $applyNodeReplacement(new InlineMarkNode(ids));
+}
+
+function $isInlineMarkNode(
+ node: LexicalNode | null | undefined
+): node is InlineMarkNode {
+ return node instanceof InlineMarkNode;
+}
+
+function $wrapSelectionInInlineMark(): void {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
+ throw new Error("Expected non-collapsed range selection");
+ }
+ const nodes = selection.extract();
+ if (nodes.length === 0) {
+ throw new Error("Expected extracted nodes");
+ }
+ const mark = $createInlineMarkNode();
+ nodes[0]!.insertBefore(mark);
+ for (const node of nodes) {
+ mark.append(node);
+ }
+}
+
+type SerializedCustomDecoratorNode = Spread<
+ {
+ src: string;
+ altText: string;
+ },
+ SerializedLexicalNode
+>;
+
+class CustomDecoratorNode extends DecoratorNode {
+ __src: string;
+ __altText: string;
+
+ static getType(): string {
+ return "custom-decorator";
+ }
+
+ static clone(node: CustomDecoratorNode): CustomDecoratorNode {
+ return new CustomDecoratorNode(node.__src, node.__altText, node.__key);
+ }
+
+ static importJSON(
+ serializedNode: SerializedCustomDecoratorNode
+ ): CustomDecoratorNode {
+ return $createCustomDecoratorNode().updateFromJSON(serializedNode);
+ }
+
+ constructor(src = "", altText = "", key?: NodeKey) {
+ super(key);
+ this.__src = src;
+ this.__altText = altText;
+ }
+
+ exportJSON(): SerializedCustomDecoratorNode {
+ return {
+ ...super.exportJSON(),
+ src: this.__src,
+ altText: this.__altText,
+ };
+ }
+
+ updateFromJSON(
+ serializedNode: LexicalUpdateJSON
+ ): this {
+ const node = super.updateFromJSON(serializedNode);
+ const writable = node.getWritable();
+ if (serializedNode.src !== undefined) {
+ writable.__src = serializedNode.src;
+ }
+ if (serializedNode.altText !== undefined) {
+ writable.__altText = serializedNode.altText;
+ }
+ return writable;
+ }
+
+ createDOM(_config: EditorConfig): HTMLElement {
+ return document.createElement("span");
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ decorate(): null {
+ return null;
+ }
+}
+
+function $createCustomDecoratorNode({
+ src = "",
+ altText = "",
+}: {
+ src?: string;
+ altText?: string;
+} = {}): CustomDecoratorNode {
+ return $applyNodeReplacement(new CustomDecoratorNode(src, altText));
+}
+
+async function createTwoParagraphRoom() {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.history.disable(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("First"),
+ }),
+ ]),
+ }),
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Second"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ return { room, document };
+}
+
+function createCollaborationFromDocument(
+ room: Room,
+ document: LiveRootNode
+): {
+ editor: LexicalEditor;
+ collaboration: LiveblocksCollaboration;
+ manager: LiveblocksCollaborationManager;
+} {
+ const editor = createLexicalEditor({
+ namespace: "history-selection-test",
+ nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode],
+ });
+
+ // Mirror storage into Lexical, including LiveText format segments so mixed
+ // bold/plain spans bind as sibling TextNodes under one LiveText child.
+ editor.update(
+ () => {
+ for (const child of document.get("children")) {
+ const paragraph = $createParagraphNode();
+ for (const grandchild of (child as LiveElementNode).get("children")) {
+ if (grandchild.get("kind") === "text") {
+ paragraph.append(
+ ...$createTextNodesFromLiveText(
+ (grandchild as LiveTextNode).get("content")
+ )
+ );
+ }
+ }
+ $getRoot().append(paragraph);
+ }
+ },
+ { discrete: true }
+ );
+
+ const collaboration = new LiveblocksCollaboration(editor, room, document);
+ editor.update(() => {}, { discrete: true });
+ collaboration.register();
+
+ return {
+ editor,
+ collaboration,
+ manager: collaboration.manager,
+ };
+}
+
+function $createTextNodesFromLiveText(content: LiveText): TextNode[] {
+ return content.toJSON().map((segment) => {
+ const node = $createTextNode(segment[0]);
+ const attributes = segment.length > 1 ? segment[1] : undefined;
+ if (attributes?.bold === true) {
+ node.toggleFormat("bold");
+ }
+ if (attributes?.italic === true) {
+ node.toggleFormat("italic");
+ }
+ if (attributes?.underline === true) {
+ node.toggleFormat("underline");
+ }
+ if (attributes?.strikethrough === true) {
+ node.toggleFormat("strikethrough");
+ }
+ if (attributes?.code === true) {
+ node.toggleFormat("code");
+ }
+ return node;
+ });
+}
+
+async function createRoomWithFormattedText(
+ segments: ConstructorParameters[0]
+) {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.history.disable(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText(segments),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const content = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+
+ return { room, document, content };
+}
+
+async function createRoomWithText(text: string = "Hello") {
+ // Room setup uses real async I/O — must run before fake timers.
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.history.disable(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText(text),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const content = (
+ (document.get("children").get(0) as LiveElementNode)
+ .get("children")
+ .get(0)! as LiveTextNode
+ ).get("content");
+
+ return { room, document, content };
+}
+
+function createEditor(
+ text: string = "Hello",
+ extraNodes: Array> = []
+): LexicalEditor {
+ const editor = createLexicalEditor({
+ namespace: "history-test",
+ nodes: [ParagraphNode, TextNode, HeadingNode, QuoteNode, ...extraNodes],
+ });
+ editor.update(
+ () => {
+ $getRoot().append($createParagraphNode().append($createTextNode(text)));
+ },
+ { discrete: true }
+ );
+ return editor;
+}
diff --git a/packages/liveblocks-lexical/src/__tests__/manager.test.ts b/packages/liveblocks-lexical/src/__tests__/manager.test.ts
new file mode 100644
index 00000000000..8f9a559e3ea
--- /dev/null
+++ b/packages/liveblocks-lexical/src/__tests__/manager.test.ts
@@ -0,0 +1,8815 @@
+import { $createHeadingNode, HeadingNode, QuoteNode } from "@lexical/rich-text";
+import { $dfs } from "@lexical/utils";
+import {
+ LiveList,
+ LiveMap,
+ LiveObject,
+ LiveText,
+ type Room,
+} from "@liveblocks/client";
+import type { Json, TextAttributes } from "@liveblocks/core";
+import { kInternal } from "@liveblocks/core";
+import {
+ $applyNodeReplacement,
+ $createParagraphNode,
+ $createRangeSelection,
+ $createTextNode,
+ $getNodeByKey,
+ $getRoot,
+ $isParagraphNode,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ COLLABORATION_TAG,
+ createEditor as createLexicalEditor,
+ DecoratorNode,
+ type EditorConfig,
+ type ElementNode,
+ HISTORIC_TAG,
+ type LexicalEditor,
+ type LexicalNode,
+ type LexicalUpdateJSON,
+ type NodeKey,
+ ParagraphNode,
+ type SerializedLexicalNode,
+ type SerializedTextNode,
+ type Spread,
+ type TextModeType,
+ TextNode,
+} from "lexical";
+import { describe, expect, test, vi } from "vitest";
+
+import {
+ createSerializedRoot,
+ prepareIsolatedStorageTest,
+} from "../../../liveblocks-core/src/__tests__/_MockWebSocketServer.setup";
+import {
+ $getLexicalNodeProps,
+ $setLexicalNodeProps,
+ areTextNodesStructurallyEqual,
+ createStorageNodeFromLexicalNode,
+ find_liveblocksNode,
+ LiveblocksCollaborationManager,
+} from "../manager";
+import type {
+ LiveDecoratorNode,
+ LiveElementNode,
+ LiveLineBreakNode,
+ LiveRootNode,
+ LiveStorageNode,
+ LiveTextNode,
+} from "../types";
+
+describe("LiveblocksCollaborationManager", () => {
+ describe("$encodeSelection", () => {
+ test("encodes a collapsed text caret", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Hello world"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+
+ editor.update(() => {
+ const text_lexical = $dfs().find(({ node }) => $isTextNode(node))!
+ .node as TextNode;
+ text_lexical.select(3, 3);
+ });
+
+ editor.read(() => {
+ const textNodeId = text_liveblocks[kInternal].getId();
+ expect(textNodeId).toBeDefined();
+ const version = text_liveblocks.get("content").version;
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 3,
+ version,
+ },
+ focus: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 3,
+ version,
+ },
+ });
+ });
+ });
+
+ test("encodes a non-collapsed text range within a single TextNode", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Hello world"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+
+ editor.update(() => {
+ const text_lexical = $dfs().find(({ node }) => $isTextNode(node))!
+ .node as TextNode;
+ text_lexical.select(1, 5);
+ });
+
+ editor.read(() => {
+ const textNodeId = text_liveblocks[kInternal].getId();
+ const version = text_liveblocks.get("content").version;
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 1,
+ version,
+ },
+ focus: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 5,
+ version,
+ },
+ });
+ });
+ });
+
+ test("flattens offsets across coalesced TextNodes that share one LiveText", async () => {
+ // One LiveText with two segments → two Lexical TextNodes, one binding.
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText([
+ ["Hello ", { bold: true }],
+ ["world"],
+ ]),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ const textNodes = paragraph
+ .getChildren()
+ .filter($isTextNode) as TextNode[];
+ expect(textNodes).toHaveLength(2);
+ // Caret in "world" at local offset 1 → flat LiveText offset 7.
+ textNodes[1]!.select(1, 1);
+ });
+
+ editor.read(() => {
+ const textNodeId = text_liveblocks[kInternal].getId();
+ const version = text_liveblocks.get("content").version;
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 7,
+ version,
+ },
+ focus: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 7,
+ version,
+ },
+ });
+ });
+ });
+
+ test("encodes a range spanning coalesced TextNodes into flat LiveText offsets", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText([
+ ["Hello ", { bold: true }],
+ ["world"],
+ ]),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ const textNodes = paragraph
+ .getChildren()
+ .filter($isTextNode) as TextNode[];
+ const selection = $createRangeSelection();
+ // "Hell|o " … "wo|rld" → flat [4, 8]
+ selection.anchor.set(textNodes[0]!.getKey(), 4, "text");
+ selection.focus.set(textNodes[1]!.getKey(), 2, "text");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ const textNodeId = text_liveblocks[kInternal].getId();
+ const version = text_liveblocks.get("content").version;
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 4,
+ version,
+ },
+ focus: {
+ nodeId: textNodeId,
+ type: "text",
+ offset: 8,
+ version,
+ },
+ });
+ });
+ });
+
+ test("does not accumulate offsets across adjacent distinct LiveText children", async () => {
+ // Concurrent remote inserts can leave two separate LiveText children
+ // whose Lexical TextNodes sit next to each other. Formats differ so
+ // Lexical does not merge them.
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText([["foo", { bold: true }]]),
+ }),
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("bar"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+ const second_liveblocks = (paragraph_liveblocks as LiveElementNode)
+ .get("children")
+ .get(1)! as LiveTextNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ const textNodes = paragraph
+ .getChildren()
+ .filter($isTextNode) as TextNode[];
+ expect(textNodes).toHaveLength(2);
+ // Caret inside "bar" at offset 1 — must NOT include "foo"'s length.
+ textNodes[1]!.select(1, 1);
+ });
+
+ editor.read(() => {
+ const encoded = manager.$encodeSelection();
+ expect(encoded).not.toBeNull();
+ expect(encoded!.anchor).toEqual({
+ nodeId: second_liveblocks[kInternal].getId(),
+ type: "text",
+ offset: 1,
+ version: second_liveblocks.get("content").version,
+ });
+ expect(encoded!.focus).toEqual(encoded!.anchor);
+ });
+ });
+
+ test("encodes an element point, coalescing TextNodes that share one LiveText", async () => {
+ // Lexical: [Text "Hi" bold, Text "there", LineBreak]
+ // Storage: [text (coalesced), linebreak]
+ // Element caret after both text nodes (Lexical index 2) → storage offset 1.
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText([["Hi", { bold: true }], ["there"]]),
+ }),
+ new LiveObject({
+ kind: "linebreak",
+ type: "linebreak",
+ version: 1,
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ expect(paragraph.getChildrenSize()).toBe(3);
+ const selection = $createRangeSelection();
+ selection.anchor.set(paragraph.getKey(), 2, "element");
+ selection.focus.set(paragraph.getKey(), 2, "element");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 1,
+ version: 0,
+ },
+ focus: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 1,
+ version: 0,
+ },
+ });
+ });
+ });
+
+ test("encodes an element point between adjacent distinct LiveText children", async () => {
+ // Lexical: [Text "foo" bold, Text "bar"] — two storage text children.
+ // Element caret between them (Lexical index 1) → storage offset 1,
+ // not 0 (would happen if all adjacent text were blindly coalesced).
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText([["foo", { bold: true }]]),
+ }),
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("bar"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ expect(paragraph.getChildrenSize()).toBe(2);
+ const selection = $createRangeSelection();
+ selection.anchor.set(paragraph.getKey(), 1, "element");
+ selection.focus.set(paragraph.getKey(), 1, "element");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 1,
+ version: 0,
+ },
+ focus: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 1,
+ version: 0,
+ },
+ });
+ });
+ });
+
+ test("encodes an element point at the end of a paragraph", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Hi"),
+ }),
+ new LiveObject({
+ kind: "linebreak",
+ type: "linebreak",
+ version: 1,
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const paragraph_liveblocks = document
+ .get("children")
+ .get(0) as LiveElementNode;
+
+ editor.update(() => {
+ const paragraph = $dfs().find(({ node }) => $isParagraphNode(node))!
+ .node as ParagraphNode;
+ // Children: [Text, LineBreak] → end is Lexical index 2 → storage 2.
+ const selection = $createRangeSelection();
+ selection.anchor.set(paragraph.getKey(), 2, "element");
+ selection.focus.set(paragraph.getKey(), 2, "element");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 2,
+ version: 0,
+ },
+ focus: {
+ nodeId: paragraph_liveblocks[kInternal].getId(),
+ type: "element",
+ offset: 2,
+ version: 0,
+ },
+ });
+ });
+ });
+
+ test("returns null when the selected text node is unbound", () => {
+ const document = createParagraphDocument("Hello");
+ const { editor, manager } = createEditor(document);
+
+ editor.update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ const extra = $createTextNode("extra");
+ paragraph.append(extra);
+ extra.select(0, 0);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toBeNull();
+ });
+ });
+
+ test("returns null when an element point crosses an unbound text child", () => {
+ const document = createParagraphDocument("Hello");
+ const { editor, manager } = createEditor(document);
+
+ editor.update(() => {
+ const paragraph = $getRoot().getFirstChild() as ParagraphNode;
+ paragraph.append($createTextNode("extra"));
+ // Element caret after the unbound text (Lexical index 2).
+ const selection = $createRangeSelection();
+ selection.anchor.set(paragraph.getKey(), 2, "element");
+ selection.focus.set(paragraph.getKey(), 2, "element");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toBeNull();
+ });
+ });
+
+ test("returns null when there is no range selection", () => {
+ const document = createParagraphDocument("Hello");
+ const { editor, manager } = createEditor(document);
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toBeNull();
+ });
+ });
+
+ test("encodes through LiveText.encodeIndex after a local pending insert", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Hello"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+ const liveText = text_liveblocks.get("content");
+
+ // Local pending insert at index 0 shifts local "H|ello" caret without
+ // bumping the confirmed version. encodeIndex must report confirmed coords.
+ liveText.insert(0, "X");
+ const version = liveText.version;
+
+ editor.update(() => {
+ const text_lexical = $dfs().find(({ node }) => $isTextNode(node))!
+ .node as TextNode;
+ // Mirror the local LiveText content in Lexical and place caret after "X".
+ text_lexical.setTextContent(liveText.toString());
+ text_lexical.select(1, 1);
+ });
+
+ editor.read(() => {
+ const encoded = manager.$encodeSelection();
+ expect(encoded).not.toBeNull();
+ // Local caret at 1 (after pending "X") → confirmed offset 0.
+ expect(encoded!.anchor.offset).toBe(liveText[kInternal].encodeIndex(1));
+ expect(encoded!.anchor.offset).toBe(0);
+ expect(encoded!.anchor.version).toBe(version);
+ expect(encoded!.anchor.nodeId).toBe(text_liveblocks[kInternal].getId());
+ });
+ });
+
+ test("returns null when storage nodes are detached (no node id)", () => {
+ // createParagraphDocument builds LiveObjects that never enter a room
+ // pool, so getId() is undefined and presence cannot be published.
+ const document = createParagraphDocument("Hello");
+ const { editor, manager } = createEditor(document);
+ const text_liveblocks = find_liveblocksNode(
+ document,
+ (node) => node.get("kind") === "text"
+ ) as LiveTextNode;
+
+ editor.update(() => {
+ const text_lexical = $dfs().find(({ node }) => $isTextNode(node))!
+ .node as TextNode;
+ text_lexical.select(2, 2);
+ });
+
+ editor.read(() => {
+ expect(text_liveblocks[kInternal].getId()).toBeUndefined();
+ expect(manager.$encodeSelection()).toBeNull();
+ });
+ });
+
+ test("encodes a range spanning two root paragraphs", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("One"),
+ }),
+ ]),
+ }),
+ new LiveObject({
+ kind: "element",
+ type: "paragraph",
+ version: 1,
+ children: new LiveList([
+ new LiveObject({
+ kind: "text",
+ type: "text",
+ version: 1,
+ content: new LiveText("Two"),
+ }),
+ ]),
+ }),
+ ]),
+ })
+ );
+ });
+
+ const document = root.get("document") as LiveRootNode;
+ const { editor, manager } = createEditor(document);
+ const first_liveblocks = (
+ document.get("children").get(0) as LiveElementNode
+ )
+ .get("children")
+ .get(0)! as LiveTextNode;
+ const second_liveblocks = (
+ document.get("children").get(1) as LiveElementNode
+ )
+ .get("children")
+ .get(0)! as LiveTextNode;
+
+ editor.update(() => {
+ const paragraphs = $dfs()
+ .filter(({ node }) => $isParagraphNode(node))
+ .map(({ node }) => node as ParagraphNode);
+ expect(paragraphs).toHaveLength(2);
+ const firstText = paragraphs[0]!
+ .getChildren()
+ .filter($isTextNode)[0] as TextNode;
+ const secondText = paragraphs[1]!
+ .getChildren()
+ .filter($isTextNode)[0] as TextNode;
+ const selection = $createRangeSelection();
+ selection.anchor.set(firstText.getKey(), 1, "text");
+ selection.focus.set(secondText.getKey(), 2, "text");
+ $setSelection(selection);
+ });
+
+ editor.read(() => {
+ expect(manager.$encodeSelection()).toEqual({
+ anchor: {
+ nodeId: first_liveblocks[kInternal].getId(),
+ type: "text",
+ offset: 1,
+ version: first_liveblocks.get("content").version,
+ },
+ focus: {
+ nodeId: second_liveblocks[kInternal].getId(),
+ type: "text",
+ offset: 2,
+ version: second_liveblocks.get("content").version,
+ },
+ });
+ });
+ });
+ });
+
+ describe("$decodeSelection", () => {
+ test("round-trips a collapsed text caret", async () => {
+ const { room, root } = (await prepareIsolatedStorageTest(
+ [createSerializedRoot()],
+ 0
+ )) as unknown as {
+ room: Room;
+ root: LiveObject<{ document?: LiveRootNode }>;
+ };
+
+ room.batch(() => {
+ root.set(
+ "document",
+ new LiveObject({
+ kind: "root",
+ type: "root",
+ version: 1,
+ children: new LiveList