Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0b63820
fix(loro.js): replay history on the first checkout after a snapshot i…
zxch3n Sep 27, 2026
8efeeb1
fix(loro.js): restore trimmed root-time map and tree winners on shall…
zxch3n Sep 27, 2026
c36f66c
Merge branch 'main' into fix/loro-js-lazy-snapshot-checkout
zxch3n Sep 27, 2026
0f0bad0
fix(loro.js): rebuild only the snapshot containers a checkout touches
zxch3n Sep 27, 2026
5f91946
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
1bb76a8
fix(loro.js): keep a tree node deleted by a retained delete after a t…
zxch3n Sep 27, 2026
f302370
fix(loro.js): keep snapshot state through a shallow export
zxch3n Sep 27, 2026
e617fa6
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
5c18305
fix(loro.js): rebuild an unread nested sequence that a checkout crosses
zxch3n Sep 27, 2026
1aac304
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
a9c329c
docs(loro.js): record final snapshot checkout measurements
zxch3n Sep 27, 2026
38c8a81
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
42ce31c
Merge branch 'main' into fix/loro-js-lazy-snapshot-checkout
zxch3n Sep 27, 2026
7f66407
test(loro.js): export the latest Rust rich-text state from a detached…
zxch3n Sep 27, 2026
e56d37a
docs(loro.js): note the detached snapshot export transition
zxch3n Sep 27, 2026
5412537
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
97f57cf
Merge branch 'main' into fix/loro-js-lazy-snapshot-checkout
zxch3n Sep 27, 2026
5e3244b
fix(loro.js): never transition a replay that differs from the snapsho…
zxch3n Sep 27, 2026
326816f
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
aead163
perf(loro.js): index shallow root entries while hydrating the root store
zxch3n Sep 27, 2026
0c8c0cb
ci: test stacked PRs and run the loro.js checks
zxch3n Sep 27, 2026
667aaef
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
e1aeec0
perf(loro.js): toggle an unreplayable text by id when it has the cros…
zxch3n Sep 27, 2026
be54ce6
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
134c7c7
perf(loro.js): compare replayed values only when the visible ids match
zxch3n Sep 27, 2026
880bd5f
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 27, 2026
1c5ff48
Merge branch 'main' into fix/loro-js-lazy-snapshot-checkout
zxch3n Sep 28, 2026
7917014
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
ef4c3c3
Merge branch 'main' into fix/loro-js-lazy-snapshot-checkout
zxch3n Sep 28, 2026
6b90f95
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
e8dca05
fix(loro.js): keep old forks and Rust MovableLists on their own history
zxch3n Sep 28, 2026
d95cbc2
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
32fa42e
fix(loro.js): leave snapshot MovableLists on main's replay path
zxch3n Sep 28, 2026
db097df
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
8ef6818
docs(loro.js): record why snapshot MovableLists stay on main's path
zxch3n Sep 28, 2026
efb4e90
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
8bbcd7b
fix(loro.js): check Rust text that had styles before a full rebuild
zxch3n Sep 28, 2026
ea561f8
Merge branch 'fix/loro-js-lazy-snapshot-checkout' into fix/loro-js-sh…
zxch3n Sep 28, 2026
86663dc
Merge main (#1126 squash) into #1127
zxch3n Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loro-js-shallow-root-winner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

Fix `checkout`, `diff`, and undo on a `loro.js` shallow document when a map value or tree node placement was written in the shallow root commit and changed afterwards. When the root commit held more than one op, retreating to the root dropped the map key or tree node instead of restoring its root-time value, and `getLastEditor` returned `undefined`. A tree node deleted by a retained delete whose placement was trimmed from the shallow history now stays deleted.
14 changes: 14 additions & 0 deletions context/loro-js-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,20 @@ JavaScript constant factor.
needs no replay: 57–61 ms versus 152 ms, with the same 234 MiB peak RSS as
before the fix (earlier 284 MiB). The coalesced inserts also make importing
the B4 trace as one update about 30% faster (about 195 versus 275 ms).
- A shallow history trims the ops that wrote root-time Map values and Tree
placements (the root commit's other ops). When a Map or Tree retreat finds no
retained winner at or below the target, it uses the shallow root state entry
(`#shallowRootMapRecord`, `#shallowRootTreeNode`) instead of dropping the key
or node; a retained Tree delete whose placement was trimmed takes the root
placement and stays deleted. The root store entry for a container comes from
`#shallowRootEntryIndex`: the key index the import builds to merge the root
and latest states, or while hydrating the root store for a replay (Rust omits
the latest state for a short retained tail). Each container's key/node index
is built on first lookup. A retreat therefore touches only the maps and trees
it changes, as in Rust's per-map checkout index seeding (loro-dev/loro#1120,
#1124): the first such retreat in a shallow doc with 32,768 child Maps takes
under 1 ms for both loro.js- and Rust-written snapshots, flat from 1,024
Maps, and later ones about 0.02–0.03 ms.

When an element's deleted flag, tree parent/position, or map visibility changes,
mutate it through its owning index helper. Direct mutation leaves subtree or
Expand Down
238 changes: 188 additions & 50 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
type Container,
type CausalVersion,
type LastWriter,
type MapRecord,
type RuntimeValue,
type SequenceElement,
type SequenceMoveMeta,
Expand Down Expand Up @@ -339,6 +340,9 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
// Root store entries by container key, built by the import merge or on first
// lookup.
#shallowRootEntries: Map<string, StateSnapshotContainerEntry> | undefined;
// Root-time map entries and tree nodes of a shallow doc, indexed per
// container on first lookup.
#shallowRootIndexes = new WeakMap<StateSnapshotStore, ShallowRootIndex>();
#textStyles = new Map<string, TextStyleExpand>([
["bold", "after"],
["italic", "after"],
Expand Down Expand Up @@ -1164,24 +1168,11 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
this.#canTransitionRecords(
[...currentToFromRetreat, ...currentToFromForward],
currentToFromMoveMode,
fromVersion,
) &&
this.#canTransitionRecords(
[...retreatRecords, ...forwardRecords],
fromToToMoveMode,
toVersion,
) &&
(this.#shallowRootStore === undefined ||
(this.#canTransitionRecords(
[...retreatRecords, ...forwardRecords],
fromToToMoveMode,
fromVersion,
) &&
this.#canTransitionRecords(
[...currentToFromRetreat, ...currentToFromForward],
currentToFromMoveMode,
restoreVersion,
)));
);
let materializedVersion = restoreVersion;
let failed = false;
try {
Expand Down Expand Up @@ -2000,7 +1991,7 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
const recording: EventRecording | undefined = subscribed
? { beforeValues: new Map(), eventStates: new Map() }
: undefined;
if (this.#canTransitionRecords([...forward, ...retreat], movableMoveMode, target)) {
if (this.#canTransitionRecords([...forward, ...retreat], movableMoveMode)) {
this.#applyVersionTransition(
retreat,
forward,
Expand Down Expand Up @@ -2486,6 +2477,106 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
}
}

#shallowRootIndex(): ShallowRootIndex | undefined {
const store = this.#shallowRootStore;
if (store === undefined || store.kind !== "sstable") return undefined;
let index = this.#shallowRootIndexes.get(store);
if (index === undefined) {
index = { maps: new Map(), trees: new Map() };
this.#shallowRootIndexes.set(store, index);
}
return index;
}

#shallowRootContainerState(containerKey: string): ContainerStateSnapshot | undefined {
return this.#shallowRootEntryIndex().get(containerKey)?.wrapper.state;
}

#shallowRootTreeNode(
tree: LoroTree,
nodeKey: string,
): Omit<TreeNodeRecord, "data"> | undefined {
const index = this.#shallowRootIndex();
const id = tree._codecId;
if (index === undefined || id === undefined) return undefined;
const containerKey = this.#containerKey(id);
let nodes = index.trees.get(containerKey);
if (nodes === undefined) {
nodes = new Map();
const state = this.#shallowRootContainerState(containerKey);
if (state?.kind === CodecContainerType.Tree) {
const ids = state.nodes.map((node) => ({
peer: state.peers[Number(node.peerIndex)]!,
counter: node.counter,
}));
for (const [position, node] of state.nodes.entries()) {
const lastMoveId = {
peer: state.peers[Number(node.lastSetPeerIndex)]!,
counter: node.lastSetCounter,
};
nodes.set(formatTreeId(ids[position]!), {
id: ids[position]!,
parent:
node.parentIndexPlusTwo >= 2n
? ids[Number(node.parentIndexPlusTwo - 2n)]
: undefined,
position: state.positions[node.fractionalIndexIndex]!,
deleted: node.parentIndexPlusTwo === 1n,
writer: {
peer: lastMoveId.peer,
lamport: node.lastSetCounter + node.lastSetLamportSub,
},
lastMoveId,
});
}
}
index.trees.set(containerKey, nodes);
}
return nodes.get(nodeKey);
}

#shallowRootMapRecord(map: LoroMap, key: string): MapRecord | undefined {
const index = this.#shallowRootIndex();
const id = map._codecId;
if (index === undefined || id === undefined) return undefined;
const containerKey = this.#containerKey(id);
let entries = index.maps.get(containerKey);
if (entries === undefined) {
const state = this.#shallowRootContainerState(containerKey);
entries = new Map();
if (state?.kind === CodecContainerType.Map) {
const values = new Map(state.values);
for (const item of state.metadata) {
entries.set(item.key, {
value: values.get(item.key),
writer: {
peer: state.peers[Number(item.peerIndex)]!,
lamport: Number(item.lamport),
},
});
}
}
index.maps.set(containerKey, entries);
}
const entry = entries.get(key);
if (entry === undefined) return undefined;
if (entry.value === undefined) {
return {
value: undefined,
rawValue: undefined,
deleted: true,
writer: entry.writer,
};
}
const rawValue = this.#decodeSnapshotValue(entry.value, map);
return {
value: this.#materializeMapValue(map, key, rawValue),
rawValue,
deleted: false,
writer: entry.writer,
};
}

#previousMapOperation(
containerId: CodecContainerId,
key: string,
Expand Down Expand Up @@ -3928,13 +4019,8 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai

#canTransitionRecords(
records: readonly HistoryRecord[],
movableMoveMode: MovableMoveTransitionMode,
target: VersionVector,
movableMoveMode: MovableMoveTransitionMode = "anchors",
): boolean {
// A shallow history trims the root-time writes of Map keys and Tree nodes.
// When the winner at `target` is such a write, only a replay from the
// shallow root state restores it.
const shallowTarget = this.#shallowRootStore === undefined ? undefined : target;
const replayedMoveContainers = new Set<string>();
if (movableMoveMode === "replay") {
for (const { change } of records) {
Expand Down Expand Up @@ -4081,35 +4167,12 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
if (recorded < operation.length) return false;
} else if (content.type === "map-insert" || content.type === "map-delete") {
if (!(container instanceof LoroMap)) return false;
if (
shallowTarget !== undefined &&
latestIncludedOperation(
this.#mapOperationHistory.get(container.id)?.get(content.key),
shallowTarget,
) === undefined
) {
return false;
}
} else if (
content.type === "tree-create" ||
content.type === "tree-move" ||
content.type === "tree-delete"
) {
if (!(container instanceof LoroTree)) return false;
if (shallowTarget !== undefined) {
const operations = this.#treeOperationHistory
.get(container.id)
?.get(idKey(content.subject));
const winner = latestIncludedOperation(operations, shallowTarget);
if (
winner === undefined ||
(winner.operation.content.type === "tree-delete" &&
latestIncludedTreePlacement(operations, shallowTarget, winner.writer) ===
undefined)
) {
return false;
}
}
} else if (content.type === "future") {
if (
!(container instanceof LoroCounter) ||
Expand Down Expand Up @@ -4543,7 +4606,9 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
}
const indexed = latestIncludedOperation(history?.get(key), target);
if (indexed === undefined) {
map._replaceRecord(key, undefined);
// The winner at `target` may be a root-time op trimmed from a shallow
// history; the shallow root state still records its value and writer.
map._replaceRecord(key, this.#shallowRootMapRecord(map, key));
continue;
}
const content = indexed.operation.content;
Expand Down Expand Up @@ -4595,15 +4660,70 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
const winner = latestIncludedOperation(operations, target);
const existing = tree._nodes.get(nodeKey);
if (winner === undefined) {
if (existing !== undefined) tree._removeRecord(existing);
// As for maps, a shallow root state keeps the placement whose op was
// trimmed from the retained history.
const rootNode = this.#shallowRootTreeNode(tree, nodeKey);
if (rootNode === undefined) {
if (existing !== undefined) tree._removeRecord(existing);
} else if (existing === undefined) {
tree._setRecord({
...rootNode,
position: rootNode.position.slice(),
data: this.#getOrCreateContainer(
{ kind: "normal", ...rootNode.id, containerType: CodecContainerType.Map },
tree,
) as LoroMap,
});
} else if (rootNode.deleted) {
tree._deleteRecord(existing, rootNode.writer, rootNode.lastMoveId);
} else {
tree._updateRecord(
existing,
rootNode.parent,
rootNode.position.slice(),
rootNode.writer,
rootNode.lastMoveId,
);
}
continue;
}
const winnerContent = winner.operation.content;
const placement =
winnerContent.type === "tree-create" || winnerContent.type === "tree-move"
? winner
: latestIncludedTreePlacement(operations, target, winner.writer);
if (placement === undefined) continue;
if (placement === undefined) {
// A retained delete whose placement was trimmed from a shallow history.
const rootNode = this.#shallowRootTreeNode(tree, nodeKey);
if (rootNode === undefined || winnerContent.type !== "tree-delete") continue;
const deleteId = {
peer: winner.record.change.id.peer,
counter: winner.operation.counter,
};
if (existing === undefined) {
tree._setRecord({
...rootNode,
position: rootNode.position.slice(),
deleted: true,
writer: winner.writer,
lastMoveId: deleteId,
data: this.#getOrCreateContainer(
{ kind: "normal", ...rootNode.id, containerType: CodecContainerType.Map },
tree,
) as LoroMap,
});
} else {
tree._updateRecord(
existing,
rootNode.parent,
rootNode.position.slice(),
rootNode.writer,
rootNode.lastMoveId,
);
tree._deleteRecord(existing, winner.writer, deleteId);
}
continue;
}
const placementContent = placement.operation.content;
if (
placementContent.type !== "tree-create" &&
Expand Down Expand Up @@ -6107,7 +6227,7 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
const forward = this.#withoutContainers(allForward, rebuilds);
const retreat = this.#withoutContainers(allRetreat, rebuilds);
const mode = movableMoveTransitionMode(retreat, forward, this.#movableMovePeers);
if (!this.#canTransitionRecords([...retreat, ...forward], mode, latest)) {
if (!this.#canTransitionRecords([...retreat, ...forward], mode)) {
return encodeStateSnapshotStore(
this.forkAt(this.oplogFrontiers()).#buildStateStore(),
{ compression: "auto" },
Expand Down Expand Up @@ -6612,9 +6732,16 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
snapshotVersion: VersionVector | undefined,
): void {
if (store.kind !== "sstable") return;
for (const { id } of store.containers) {
this.#getOrCreateContainer(id, undefined, false);
// The root store keys are formatted here anyway; keep them for the shallow
// root fallbacks (#shallowRootEntryIndex).
const rootEntries =
store === this.#shallowRootStore && this.#shallowRootEntries === undefined
? new Map<string, StateSnapshotContainerEntry>()
: undefined;
for (const entry of store.containers) {
rootEntries?.set(this.#getOrCreateContainer(entry.id, undefined, false).id, entry);
}
if (rootEntries !== undefined) this.#shallowRootEntries = rootEntries;
for (const { id, wrapper } of store.containers) {
const container = this.#getOrCreateContainer(id, undefined, false);
const parent =
Expand Down Expand Up @@ -7806,6 +7933,17 @@ function lowerBoundWriter(
return low;
}

interface ShallowRootIndex {
readonly maps: Map<
string,
Map<
string,
{ readonly value: EncodedLoroValue | undefined; readonly writer: LastWriter }
>
>;
readonly trees: Map<string, Map<string, Omit<TreeNodeRecord, "data">>>;
}

function latestIncludedOperation(
history: IndexedSubjectHistory | undefined,
version: VersionVector,
Expand Down
Binary file added loro-js/tests/fixtures/rust/shallow-empty-state.blob
Binary file not shown.
Loading
Loading