Repository navigation
fix(loro.js): keep root-time map values and tree nodes when a shallow doc retreats - #1127
Merged
Merged
Conversation
…mport
Snapshot-hydrated state has no tombstones or op history, so incremental
transitions returned wrong historical state, and a replay could hydrate
lazy latest-state entries on top of replayed ops ("duplicate sequence id").
Rationale: context/loro-js-performance.md (snapshot-hydrated state).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ow retreat A shallow history trims the ops that wrote root-time values, so retreating a later write dropped the key or node. Fall back to the shallow root state, indexed lazily per container. Rationale: context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 27, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first checkout after a snapshot import replayed the whole document, up to three times for the first `diff`, and replaced correct snapshot state with loro.js replays. Replaying Rust-created rich text lost styles and moved text, because loro.js positions do not count Rust style anchors. A transition now hydrates and rebuilds only the Text/List containers it touches, once, and keeps a snapshot state that the replay does not reproduce for every version with the same operations on that container. Checkout events of unread nested containers are correct, and a full snapshot export keeps unread containers after history is loaded. See context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Resolution: the shallow root fallbacks here supersede the base branch's rebuild when a shallow Map or Tree winner was trimmed, so that guard is dropped. The root-state lookup reuses the base branch's root entry index (`#shallowRootEntryIndex`) instead of formatting every root container id on the first fallback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rimmed placement When a retained delete wins but its node's placement op was trimmed from a shallow history, the retreat left the node alive. It now takes the root placement from the shallow root state and stays deleted. This moves the fix from the next PR in the stack so this one is correct on its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shallow export rebuilds the root and latest states by full replay, which replaced snapshot state that the replay does not reproduce (Rust rich text) in both the export and the live document. The rebuilds now pass the version they leave, so snapshot states are stashed and reinstated, and unread lazy sequences are hydrated first. The import builds the root entry index only when it merges a latest state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The check for crossing snapshot operations ran before lazily encoded containers were hydrated, so an unread nested Text or List skipped its history rebuild and the checkout fell back to replaying the whole document. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolution: a detached snapshot export (#1130) builds its latest state with the same prepared transition as checkout (#prepareSnapshotTransition, #settleSnapshotSequences) and encodes it with the lazy-aware state encoder. Lazily imported documents therefore also use the O(delta) transition instead of a staging replay, and snapshot states that a replay does not reproduce stay exact in the export. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… checkout Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Resolution: `#canTransitionRecords` takes no target here, since the shallow root fallbacks replace the base branch's rebuild guard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
|
Follow-up on the review of
|
This was referenced Sep 27, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t state A Text or List whose replay does not reproduce its snapshot state (Rust rich text, since loro.js does not count style anchors in positions) no longer installs the replay and pins the snapshot state back. Operations applied while the replay was installed, such as an update imported while detached, changed the latest state and exports. Such a container now keeps its snapshot state as the authority: every transition that touches it rebuilds it from that state plus later operations, so older versions are approximate but the latest state and exports never come from a replay. Full rebuilds and forkAt do the same. Transitions also deduplicate sequence elements by id. A packed Text span returns a new wrapper per lookup, so two concurrent deletes of one character deleted it twice and a subscribed checkout threw after half-updating the document. A checkout that throws now restores its previous version and state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Resolution: the base branch's checkout helper and detached export, calling `#canTransitionRecords` without the target it needs only for its shallow rebuild guard, which the root fallbacks here replace. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Rust shallow snapshot with a short retained tail has no latest state, so the import replays from the root store and never built the key index that the root fallbacks use. The first fallback then formatted every root container id (about 8.5 ms for 32k Maps). The index is now filled from the keys the root hydration already computes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`pull_request` ran only for PRs into main, dev, or feat-gc, so PRs stacked on another branch had no checks, and no job ran `pnpm -C loro-js check`. Drop the base-branch filter and add a loro-js job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sed operations Rebuilding an unreplayable container from its snapshot state on every transition cost O(container) per checkout (about 25 ms for a 64k-character Rust rich text). When the installed state already has every forward operation, a Text is now toggled by id and style version, as a state without history is, and only rebuilt otherwise (for example after an update imported while detached). Its events come from the transition's recording unless style operations are crossed. Adds a regression test over Rust rich-text histories: random checkouts, detaches, and imports must keep the latest state of a document that only imports. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comparing a styled text's replay with its snapshot state read both deltas even when the visible ids already differed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
WASM Size Report
|
Member
Author
|
Round-2 follow-up:
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
|
Round-3 update:
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- forkAt/fork keeps an unreplayable snapshot state only when the fork's version includes it; an older fork replays its own operations, as on main. - A MovableList is never unreplayable: its hydrated state carries Rust position ids, so its replay replaces the state, as on main. - A completion that throws reinstalls the snapshot state; #transitionTo prepares inside its try, and diff restores with a full rebuild when it or its move back throws. Rationale and limits: context/loro-js-performance.md (issue #1163 tracks the concurrent-delete placement bug that main already has). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replaying a hydrated MovableList to the current version and retreating from there carried loro.js's MovableList non-convergence at the latest version into older versions (random Rust histories: 581 mismatching versions vs 91 on main). A MovableList is no longer a snapshot sequence: its hydrated state has no move or value history, so a transition that crosses its snapshot operations replays to the target, as on main. See context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
|
Round-4 follow-up:
|
A full rebuild only checked Text whose snapshot state still had styles. When every marked character was deleted, the mark's anchors still shift later Rust positions, so the replay (hlXo -> hloX) replaced the snapshot state and was exported. The check now also covers Text with a mark operation in its history (#markedTexts) or styles in its shallow root state; plain text still skips it. See context/loro-js-performance.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allow-root-map-winner Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
zxch3n
changed the base branch from
fix/loro-js-lazy-snapshot-checkout
to
main
September 28, 2026 17:07
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n
added a commit
that referenced
this pull request
Sep 28, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1126; #1128 builds on this. Merge #1126 → #1127 → #1128 back to back. Without this PR, #1126 guards root-time Map/Tree winners with a full rebuild, which costs 83–88 ms for every return to the root of a 32k-Map shallow document; this PR makes it 0.014 ms.
Summary
This is the
loro.jscounterpart of Rust #1124. Same symptom: when the root commit holds more than one op,checkout(root)on a shallow replica drops a map value that was rewritten after the root. It also affects tree nodes, andgetLastEditorreturnsundefined.Minimal repro on
main:map.set("a",1); map.set("z",0); commit(root), thenmap.set("a",2); commit. After a shallow import,checkout(root)returns{"z":0}instead of{"a":1,"z":0}.Root cause
The cause is different from Rust.
loro.jsrebuilds the shallow root state by replaying, so its exported winner metadata is already correct. The broken part is retreat:#applyVersionTransitionlooks up the winner of a map key or tree node in the retained op history (latestIncludedOperation). The op that wrote the root-time value was trimmed, so nothing was found, and the key or node was removed.Fix
When no retained op wins at the target, Map and Tree retreats fall back to the shallow root state (
#shallowRootMapRecordand#shallowRootTreeNode). The root store container index, and each map's keys or tree's nodes, are indexed on first lookup and cached per root store. A retreat only touches the containers it changes, which matches the per-map lazy seeding of Rust #1120.Two more retreat cases are fixed here:
state = E. The root-store index is now built from the root hydration, so the first fallback does not format every root container id: 0.6 ms at 32k Maps, down from 10.1 ms.context/loro-js-performance.mddocuments the design.Rust parity table (#1124 scenarios)
revertTo(Map, child Map, mergeable Map)getLastEditorat the rootundefinedvalue_id/getLastEditorAtgetLastEditorAt/getLastMoverAtare stubs that return the creator, and MovableList snapshots drop position/last-set ids. This needs a separate MovableList position-model change (see the audit report)Validation
loro-js/tests/shallow-root-map-winner.test.tsportsshallow_checkout_equal_value.rswithout MovableList, with 0 and 300 padding ops and with/withoutrevertTo. It compares a shallow replica with an imported full-history replica for checkout at every version, retreat in reverse order,diff+applyDifffor every version pair, re-export, undo/redo, and last editor. On the base branch 9 of 11 tests fail; with this PR all pass.pnpm -C loro-js check(format, lint, typecheck, tests, build) passes with 290 tests.shallow-root-map-winner.test.tsalso covers the retained-delete case above and a Rust-writtenstate = Efixture (shallow-empty-state.blob): root-time values andgetLastEditoracross checkouts.bench:complexity 1000,2000,4000,8000againstorigin/main: no systematic regression. The only entry flagged in one run,text-tail-checkout-subscribedat 8000, measured 0.15–0.35 ms in the other 5 runs, so it was a GC outlier.79170146vs nowefb4e90e:state = E): 0.603 vs 0.604 ms.buildandloro-jsjobs) runs on this stacked PR.Inherited from #1126
This PR includes #1126's snapshot-checkout changes, including the known limitations and the #1136/#1137 integration notes in #1126's description.
Rust: #1124 (equal-value winner metadata), #1120 (per-map lazy root seeding).
🤖 Generated with Claude Code