Skip to content

fix: resolve ensured-but-empty mergeable containers by id#1010

Merged
lodystage[bot] merged 2 commits into
mainfrom
fix/mergeable-get-container-by-id
Jun 11, 2026
Merged

fix: resolve ensured-but-empty mergeable containers by id#1010
lodystage[bot] merged 2 commits into
mainfrom
fix/mergeable-get-container-by-id

Conversation

@lodystage

@lodystage lodystage Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem

After ensureMergeableMap (and the other ensure_mergeable_* APIs), the child container is attached, map.get(key) returns a handle, and doc.toJSON() shows it — but doc.getContainerById(child.id) / has_container returned undefined/false until the first op was written into the child (commit or not). The document claimed the container existed while refusing to resolve its own id.

This silently breaks any "hold a cid → look the container back up" pattern, and the inconsistency propagates across the network: peer A ensures an empty mergeable child and syncs; peer B sees the empty map in toJSON() but cannot resolve the same cid either. loro-mirror was the first downstream victim (all three criticals in loro-mirror PR #85 trace back to this single behavior).

Fix

Mergeable cids are self-describing, and the parent map already stores a binary child ref that map.get(key) resolves into a handle. does_container_exist now reuses the existing logical alive-walk (get_reachable) for mergeable cids after a store miss: parse the cid, register the arena edge (no state creation), and verify the child refs along the parent chain are alive. The cost lands only on the path that previously returned false; hot paths are untouched.

ensure_root_container now skips mergeable roots so that id lookup does not eagerly materialize container state — they stay lazy until their first op (or export, via the alive-container walk), as before.

Semantics (aligned with setContainer)

  • Ensured (ref alive) → the id resolves: before any op, before commit, and on remote peers after sync.
  • Never-ensured mergeable cid → still returns undefined; ids are not materialized out of thin air.
  • Deleted: a mergeable child with its own state stays resolvable by id, matching ordinary deleted-container semantics; an ensured-but-empty child stops resolving once its ref is removed (it has no state anywhere).

Tests

  • Rewrote loro_has_container_for_mergeable_cid_through_public_api, which had encoded the old behavior as a contract.
  • New Rust regression tests: resolve-by-id for an ensured-but-empty child, the same on a remote peer after sync, and deletion semantics for both empty and written children.
  • New JS test in mergeable.test.ts covering getContainerById / hasContainer for the original repro shape ({"records":{"note":{}}}), including the never-ensured-cid and remote-peer cases.
  • Documented the contract on LoroDoc::has_container and the wasm getContainerById.

Verified: cargo test -p loro, cargo test -p loro-internal --features=test_utils,jsonpath,counter, and the wasm vitest suite all pass (the only failures are the pre-existing base64.test.ts artifact-loading failures in dev builds, unrelated to this change).

🤖 Generated with Claude Code

After ensure_mergeable_*, the child is attached, map.get(key) returns a
handle, and toJSON() shows it, but has_container / getContainerById
returned false/undefined until the first op was written into the child.
Any "hold a cid, look the container back up" pattern silently failed on
this state, locally and on remote peers after sync.

does_container_exist now falls back to the logical alive-walk
(get_reachable) for mergeable cids after a store miss: parse the cid,
register the arena edge, and verify the parent chain's child refs are
alive. The cost lands only on the path that previously returned false.

Semantics now align with set_container:
- ensured (ref alive) -> id resolves, even before any op or commit,
  and on remote peers after sync
- never-ensured mergeable cid -> still does not resolve (no
  materialization out of thin air)
- deleted: a child with its own state stays resolvable like ordinary
  deleted containers; an ensured-but-empty child stops resolving once
  its ref is removed

ensure_root_container now skips mergeable roots so id lookup does not
eagerly materialize container state; they stay lazy until first op or
export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3031.03 KB
  • Gzipped size: 1000.08 KB
  • Brotli size: 702.02 KB

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lodystage lodystage Bot merged commit 9d7d5c8 into main Jun 11, 2026
1 check passed
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