Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions crates/loro-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ pub fn check_root_container_name(name: &str) -> bool {
!name.is_empty() && name.char_indices().all(|(_, x)| x != '/' && x != '\0')
}

/// Return whether the given name indicates a mergeable container.
///
/// Mergeable containers are special containers that use a Root Container ID format
/// but have a parent. They are identified by having a `/` in their name, which is
/// forbidden for user-created root containers.
///
/// The format is: `parent_container_id/key`
#[inline]
pub fn is_mergeable_container_name(name: &str) -> bool {
name.contains('/')
}

impl CompactId {
pub fn new(peer: PeerID, counter: Counter) -> Self {
Self {
Expand Down Expand Up @@ -584,6 +596,19 @@ mod container {
pub fn is_unknown(&self) -> bool {
matches!(self.container_type(), ContainerType::Unknown(_))
}

/// Returns true if this is a mergeable container.
///
/// Mergeable containers are special containers that use a Root Container ID format
/// but have a parent. They are identified by having a `/` in their name, which is
/// forbidden for user-created root containers.
#[inline]
pub fn is_mergeable(&self) -> bool {
match self {
ContainerID::Root { name, .. } => crate::is_mergeable_container_name(name),
ContainerID::Normal { .. } => false,
}
}
}

impl TryFrom<&str> for ContainerType {
Expand Down
3 changes: 2 additions & 1 deletion crates/loro-internal/src/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,8 @@ impl SharedArena {
}

pub fn get_parent(&self, child: ContainerIdx) -> Option<ContainerIdx> {
if self.get_container_id(child).unwrap().is_root() {
let id = self.get_container_id(child).unwrap();
if id.is_root() && !id.is_mergeable() {
// TODO: PERF: we can speed this up by use a special bit in ContainerIdx to indicate
// whether the target is a root container
return None;
Expand Down
124 changes: 124 additions & 0 deletions crates/loro-internal/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4151,6 +4151,130 @@ impl MapHandler {
}),
}
}

pub fn get_mergeable_list(&self, key: &str) -> LoroResult<ListHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::List)
.into_list()
.unwrap(),
)
}

pub fn get_mergeable_map(&self, key: &str) -> LoroResult<MapHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::Map)
.into_map()
.unwrap(),
)
}

pub fn get_mergeable_movable_list(&self, key: &str) -> LoroResult<MovableListHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::MovableList)
.into_movable_list()
.unwrap(),
)
}

pub fn get_mergeable_text(&self, key: &str) -> LoroResult<TextHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::Text)
.into_text()
.unwrap(),
)
}

pub fn get_mergeable_tree(&self, key: &str) -> LoroResult<TreeHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::Tree)
.into_tree()
.unwrap(),
)
}

#[cfg(feature = "counter")]
pub fn get_mergeable_counter(&self, key: &str) -> LoroResult<counter::CounterHandler> {
self.get_or_create_mergeable_container(
key,
Handler::new_unattached(ContainerType::Counter)
.into_counter()
.unwrap(),
)
}

pub fn get_or_create_mergeable_container<C: HandlerTrait>(
&self,
key: &str,
child: C,
) -> LoroResult<C> {
let name = format!("{}/{}", self.id(), key);
let expected_id = ContainerID::Root {
name: name.into(),
container_type: child.kind(),
};

// Check if exists
if let Some(ValueOrHandler::Handler(h)) = self.get_(key) {
if h.id() == expected_id {
if let Some(c) = C::from_handler(h) {
return Ok(c);
} else {
unreachable!("Container type mismatch for same ID");
}
}
}

// Create
match &self.inner {
MaybeDetached::Detached(_) => Err(LoroError::MisuseDetachedContainer {
method: "get_or_create_mergeable_container",
}),
MaybeDetached::Attached(a) => a.with_txn(|txn| {
self.insert_mergeable_container_with_txn(txn, key, child, expected_id)
}),
}
}

pub fn insert_mergeable_container_with_txn<H: HandlerTrait>(
&self,
txn: &mut Transaction,
key: &str,
child: H,
container_id: ContainerID,
) -> LoroResult<H> {
let inner = self.inner.try_attached_state()?;

// Insert into Map
txn.apply_local_op(
inner.container_idx,
crate::op::RawOpContent::Map(crate::container::map::MapSet {
key: key.into(),
value: Some(LoroValue::Container(container_id.clone())),
}),
EventHint::Map {
key: key.into(),
value: Some(LoroValue::Container(container_id.clone())),
},
&inner.doc,
)?;

// Attach
let ans = child.attach(txn, inner, container_id)?;

// Set Parent in Arena
let child_idx = ans.idx();
inner
.doc
.arena
.set_parent(child_idx, Some(inner.container_idx));
Comment on lines +4269 to +4274

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat mergeable Root IDs as parentless

This explicit parent assignment won’t be observable because mergeable containers use ContainerID::Root and SharedArena::get_parent short‑circuits all root IDs to None. As a result, path/ancestor logic (e.g. DocState::get_path used by getPathToContainer and event path) will still treat mergeable containers as top‑level roots even after this call, so a getMergeableList created under a map will report a root path like cid:root-…/key instead of the map key path. That’s a functional regression for path‑based lookups/subscriptions; consider updating get_parent (and depth/root handling) to honor is_mergeable() root IDs.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in latest push.


Ok(ans)
}
}

fn with_txn<R>(doc: &LoroDoc, f: impl FnOnce(&mut Transaction) -> LoroResult<R>) -> LoroResult<R> {
Expand Down
16 changes: 14 additions & 2 deletions crates/loro-internal/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,7 @@ impl DocState {
let roots = self.arena.root_containers(flag);
let ans: loro_common::LoroMapValue = roots
.into_iter()
.map(|idx| {
.filter_map(|idx| {
let id = self.arena.idx_to_id(idx).unwrap();
let ContainerID::Root {
name,
Expand All @@ -885,7 +885,11 @@ impl DocState {
else {
unreachable!()
};
(name.to_string(), LoroValue::Container(id))
// Skip mergeable containers - they should not appear at the root level
if id.is_mergeable() {
return None;
}
Some((name.to_string(), LoroValue::Container(id)))
})
.collect();
LoroValue::Map(ans)
Expand All @@ -905,6 +909,10 @@ impl DocState {
let id = self.arena.idx_to_id(root_idx).unwrap();
match &id {
loro_common::ContainerID::Root { name, .. } => {
// Skip mergeable containers - they should not appear at the root level
if id.is_mergeable() {
continue;
}
let v = self.get_container_deep_value(root_idx);
if (should_hide_empty_root_container || deleted_root_container.contains(&id))
&& v.is_empty_collection()
Expand All @@ -931,6 +939,10 @@ impl DocState {
let id = self.arena.idx_to_id(root_idx).unwrap();
match id.clone() {
loro_common::ContainerID::Root { name, .. } => {
// Skip mergeable containers - they should not appear at the root level
if id.is_mergeable() {
continue;
}
ans.insert(
name.to_string(),
self.get_container_deep_value_with_id(root_idx, Some(id)),
Expand Down
Loading
Loading