Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 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
9ef23c7
fix(loro.js): drop containers deleted before the shallow root from sh…
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
534b7f9
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
zxch3n Sep 27, 2026
e80c443
fix(loro.js): keep hidden mergeable children in shallow snapshots
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
d8a81f6
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
c61bf19
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
25a4d3e
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
0d358cb
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
007f01f
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
38dac5e
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
e712190
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
c12ccc9
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
b716216
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
e63e8fa
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
fd38db5
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
13d497c
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
64d1226
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
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
e4b0193
Merge branch 'fix/loro-js-shallow-root-map-winner' into fix/loro-js-s…
zxch3n Sep 28, 2026
0db90b4
Merge main (#1127 squash) into #1128
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-deleted-containers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

Shallow snapshots exported by `loro.js` no longer contain containers deleted before the shallow root, such as a map child removed before the cut. Before, every container ever created was written to both the root state and the latest state. Tree node metadata is still kept for all nodes, including deleted ones, because a later move can revive such a node, and every mergeable child is kept, because re-ensuring it after its marker was deleted or replaced shows its state again. Re-exporting an older blob at its own root also drops these containers.
12 changes: 12 additions & 0 deletions context/internal-encoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,18 @@ regressions are `cached_root_*` unit tests in `shallow_snapshot.rs`.
`crates/loro/tests/perf_shallow_reexport.rs` is the ignored release benchmark
for first and repeated re-export and import.

The pure TypeScript runtime (`loro-js`) uses the same retention rule when it
rebuilds both states in `LoroDoc.#encodeShallowSnapshot`. The root state keeps
`#retainedContainerKeys()` at the root: root containers (every mergeable
container is one, as in Rust's `existing_retention_roots`, so a child hidden by
a deleted or different-kind marker keeps its state), visible Map/List children,
and every tree node's meta, including deleted nodes. The latest state
additionally keeps containers alive at the latest version and containers whose
creation id the root version does not include. `loro-js` always rebuilds the
root state by replay instead of reusing its cached root store, so re-exporting
an older blob at the same root also prunes it (the #1123 case). Tests are in
`loro-js/tests/shallow-snapshot-deleted-containers.test.ts`.

Two import-side pieces support revived tree nodes. `TreeOpGroup::record_shallow_root_state`
seeds the tree diff cache with deleted nodes as well (directly deleted as
`Delete`, their descendants as `Create` under their real parent); with only
Expand Down
3 changes: 2 additions & 1 deletion context/mergeable-containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ flushed KV entry; the shallow alive walk retains them explicitly). An
ensured-but-empty mergeable child has no KV entry of its own: full export omits
it and importers resolve it from the parent map's marker. This is covered by
`tests/mergeable_container/snapshot.rs`, including shallow snapshot tests for
losing-kind state.
losing-kind state. loro.js follows the same rule in `#retainedContainerKeys`
(`loro-js/tests/shallow-snapshot-deleted-containers.test.ts`).

Raw marker bytes are the wire/storage representation. Public read and diff
surfaces should translate an active marker to a container value. APIs that expose
Expand Down
66 changes: 63 additions & 3 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6302,13 +6302,25 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
const latestFrontiers = this.#frontiersForVersion(latestVersion);
const restoreVersion = this.version();

// Only containers a retained op can still reach are exported; content
// deleted before the root must not survive in either state section.
// See context/internal-encoding.md (shallow snapshot retention set).
// The root state of a container that loro.js cannot replay is its replay
// (as without snapshot states): its snapshot state is at a later version
// and cannot restore what was deleted before it.
this.#rebuildFromHistory(rootVersion, restoreVersion, true);
const rootStore = this.#buildStateStore(startFrontiers);
const rootRetained = this.#retainedContainerKeys();
const rootStore = this.#buildStateStore(startFrontiers, rootRetained);
this.#rebuildFromHistory(latestVersion, rootVersion);
const latestStore = this.#buildStateStore();
const latestRetained = this.#retainedContainerKeys();
for (const key of rootRetained) latestRetained.add(key);
for (const [key, container] of this.#containers) {
const id = container._codecId;
if (id?.kind === "normal" && id.counter >= (rootVersion.get(id.peer) ?? 0)) {
latestRetained.add(key);
}
}
const latestStore = this.#buildStateStore(undefined, latestRetained);
if (restoreVersion.compare(latestVersion) !== 0) {
this.#rebuildFromHistory(restoreVersion, latestVersion);
}
Expand Down Expand Up @@ -6420,11 +6432,59 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
: undefined;
}

#buildStateStore(frontiers?: readonly CodecId[]): StateSnapshotStore {
/**
* Containers reachable from the root containers in the current state. As in
* Rust, every mergeable container is a root too: its deterministic ID
* survives a deleted or replaced marker, and re-ensuring the same kind shows
* its state again. Tree node metadata is followed for every node, including
* deleted ones: a retained move can revive a deleted node together with its
* old metadata. Deleted Map and List children are not followed; re-inserting
* one creates a new container ID.
*/
#retainedContainerKeys(): Set<string> {
const retained = new Set<string>();
const pending: LoroContainer[] = [];
const visit = (container: LoroContainer): void => {
const id = container._codecId;
if (id === undefined) return;
const key = this.#containerKey(id);
if (retained.has(key)) return;
retained.add(key);
pending.push(container);
};
for (const container of this.#containers.values()) {
if (container._codecId?.kind === "root") visit(container);
}
for (
let container = pending.pop();
container !== undefined;
container = pending.pop()
) {
if (container instanceof LoroMap) {
for (const record of container._entries.values()) {
if (!record.deleted && record.value instanceof LoroContainer)
visit(record.value);
}
} else if (container instanceof LoroList) {
for (const element of container._visibleElements()) {
if (element.value instanceof LoroContainer) visit(element.value);
}
} else if (container instanceof LoroTree) {
for (const record of container._nodes.values()) visit(record.data);
}
}
return retained;
}

#buildStateStore(
frontiers?: readonly CodecId[],
retained?: ReadonlySet<string>,
): StateSnapshotStore {
const containers: StateSnapshotContainerEntry[] = [];
for (const container of this.#containers.values()) {
const id = container._codecId;
if (id === undefined) continue;
if (retained !== undefined && !retained.has(this.#containerKey(id))) continue;
const parent = container.parent()?._codecId;
containers.push({
id,
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading