Skip to content

fix(loro.js): keep root-time map values and tree nodes when a shallow doc retreats - #1127

Merged
zxch3n merged 39 commits into
mainfrom
fix/loro-js-shallow-root-map-winner
Sep 28, 2026
Merged

zxch3n merged 39 commits into
mainfrom
fix/loro-js-shallow-root-map-winner

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

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.js counterpart 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, and getLastEditor returns undefined.

Minimal repro on main: map.set("a",1); map.set("z",0); commit (root), then map.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.js rebuilds the shallow root state by replaying, so its exported winner metadata is already correct. The broken part is retreat: #applyVersionTransition looks 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 (#shallowRootMapRecord and #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:

  • Retained delete with a trimmed placement: when a retained delete wins but the node's placement op was trimmed, the node falls back to its root placement and stays deleted. Before, it stayed alive.
  • Rust shallow snapshots without a latest state: a Rust shallow export with a short retained tail encodes 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.md documents the design.

Rust parity table (#1124 scenarios)

Scenario loro.js before after
checkout(root) after an equal-value rewrite / revertTo (Map, child Map, mergeable Map) key dropped fixed
same for Tree node placement node dropped fixed
re-export at the same root (2 hops) and at a later root lost after retreat fixed
getLastEditor at the root undefined root-time writer
exported shallow root metadata already correct (replayed) unchanged
MovableList value_id / getLastEditorAt not fixed here: getLastEditorAt/getLastMoverAt are 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

  • New loro-js/tests/shallow-root-map-winner.test.ts ports shallow_checkout_equal_value.rs without MovableList, with 0 and 300 padding ops and with/without revertTo. It compares a shallow replica with an imported full-history replica for checkout at every version, retreat in reverse order, diff+applyDiff for 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.ts also covers the retained-delete case above and a Rust-written state = E fixture (shallow-empty-state.blob): root-time values and getLastEditor across checkouts.
  • bench:complexity 1000,2000,4000,8000 against origin/main: no systematic regression. The only entry flagged in one run, text-tail-checkout-subscribed at 8000, measured 0.15–0.35 ms in the other 5 runs, so it was a GC outlier.
  • First fallback retreat, 32k child Maps (median of 5 alternating runs, load1 about 5), round 3 79170146 vs now efb4e90e:
    • Rust writer (state = E): 0.603 vs 0.604 ms.
    • loro.js writer: 0.596 vs 0.585 ms.
    • Later retreats: 0.019 ms.
  • CI (build and loro-js jobs) 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

zxch3n and others added 2 commits September 28, 2026 00:47
…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>
zxch3n and others added 14 commits September 28, 2026 02:14
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>

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the review of 8efeeb15 → now 54125375. The updated #1126 and main are merged in.

  • F4: the retained-delete fix moved here from fix(loro.js): drop containers deleted before the shallow root, keep tree node metadata #1128 (commit 1bb76a83), so this PR is correct on its own. After a shallow import, checkout(root) gives ["0@1","1@1"] and checkout(deleted) gives ["1@1"], matching Rust for Rust→loro.js, loro.js→loro.js, and loro.js→Rust. Before this change the result was ["0@1","1@1"] both times. A regression test is in shallow-root-map-winner.test.ts.
  • First fallback: it no longer formats and indexes every root container. It reuses the root entry index that the import already builds to merge the root and latest states (#shallowRootEntryIndex, from fix(loro.js): correct checkout after a snapshot import #1126). These direct fallbacks also replace fix(loro.js): correct checkout after a snapshot import #1126's temporary rebuild guard for trimmed winners.
  • Fallback cost. Setup: a shallow doc with N child Maps, all written in the root commit. After the one-time replay, one child is edited, and then the doc retreats to the root. "Cold" is the first such retreat; "warm" is the median of 100.
N 8efeeb1 cold now cold warm (both)
1,024 0.40–0.46 ms 0.33–0.41 ms ~0.017 ms
8,192 1.8–2.1 ms 0.34–0.37 ms ~0.017 ms
32,768 8.1–11.1 ms 0.41–0.48 ms ~0.017 ms

zxch3n and others added 7 commits September 28, 2026 04:26
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>
zxch3n and others added 3 commits September 28, 2026 04:55
…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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3272.76 KB
  • Gzipped size: 1087.19 KB
  • Brotli size: 760.88 KB

zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Round-2 follow-up: 54125375 → 880bd5f6. The updated #1126 (B1/B2 fixes, CI) is merged in.

  • N1: first fallback for Rust shallow snapshots without a latest state. A Rust shallow export with a short retained tail encodes state = E, so the import replays from the root store and never built the root-entry index. The first fallback then formatted every root container id. The index is now filled from the keys that the root-store hydration already computes (aead1634). Adds a Rust-written E-state fixture test (shallow-empty-state.blob): root-time values and getLastEditor across checkouts.
first fallback retreat, 32k child Maps (5 alternating runs) 5412537 now
Rust-written (state = E) 10.1 ms 0.81 ms
loro.js-written (latest state present) 0.67 ms 0.69 ms
later retreats, both writers 0.019 ms 0.019 ms

zxch3n and others added 2 commits September 28, 2026 09:08
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>

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-3 update: 880bd5f6 → 79170146. This only merges #1126 1c5ff489, which brings in main 27982a55. No loro.js code changed.

  • Rust recheck:
    • The Rust-written E-state fixture (shallow-empty-state.blob) regenerates byte-identical with Rust at 27982a55.
    • The F4 repro (Rust and loro.js, as writer and reader in all four combinations) is unchanged.
  • First fallback retreat (ms, median of 5 alternating runs, load1 median 10.0):
round 2 880bd5f6 now 79170146
Rust-written (state = E), 32k child Maps 0.587 0.589
loro.js-written, 32k child Maps 0.584 0.602
later retreats, both writers 0.019–0.021 0.019–0.021

zxch3n and others added 2 commits September 28, 2026 22:18
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>
zxch3n and others added 6 commits September 28, 2026 22:33
- 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>

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-4 follow-up: 79170146 → efb4e90e. This merges main 29553d5b through #1126, plus #1126's NEW-B1/NEW-B2/P2 fixes (see #1126's comment). This PR's own code is unchanged.

  • Description: updated with the merge order (fix(loro.js): correct checkout after a snapshot import #1126 must not ship without this PR), the retained-delete and state = E fallbacks, and current numbers.
  • Checks: pnpm -C loro-js check passes with 289 tests.
  • First fallback retreat, 32k child Maps (median of 5 alternating runs, load1 about 5):
round 3 79170146 now
Rust writer (state = E) 0.603 ms 0.604 ms
loro.js writer 0.596 ms 0.585 ms
later retreats 0.019 ms 0.019 ms
  • bench:complexity: 0 of 852 entries flagged against main and against the round-3 heads.
  • CI on efb4e90e: build and loro-js pass.

zxch3n and others added 2 commits September 29, 2026 00:43
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>

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-5 follow-up: efb4e90e → ea561f8f. This merges #1126's R4-B1 fix (see #1126's comment); this PR's own code is unchanged.

  • Checks: pnpm -C loro-js check passes with 290 tests.
  • Rust check: the deleted-mark paths give hlXo, as in Rust.

CI on ea561f8f: build and loro-js pass.

@zxch3n
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
zxch3n merged commit 1c75b3f into main Sep 28, 2026
2 checks passed
zxch3n added a commit that referenced this pull request Sep 28, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant