diff --git a/docs/design/scene_node_identity.md b/docs/design/scene_node_identity.md new file mode 100644 index 000000000..9b2e93f70 --- /dev/null +++ b/docs/design/scene_node_identity.md @@ -0,0 +1,180 @@ +# Scene node identity: per-name variant slots with client-wins display + +Status: **implemented** (same branch, after an interim rejection-based +design; the "where we are" section below describes the state this replaced). +Code map: owner/virtual fields in `_messages.py`; owner stamping via +`SceneApi._queue_scene_message` and virtual anchors via +`_ensure_ancestors_exist` in `_scene_api.py` / `_scene_handles.py`; variant +slots, display rule, and frozen-pose inheritance in the frontend's +`SceneTreeState.ts` with owner routing in `MessageHandler.tsx`. Tests: +`tests/test_scene_scopes.py`, `SceneTreeState.test.ts`, +`tests/e2e/test_cross_scope_handles.py`. Not yet implemented: a scene-tree +panel badge for shadowing/local variants (cosmetic follow-up). + +## Where this started + +Scene nodes were identified by name alone, everywhere: the frontend scene +tree was a single store keyed by node name with no record of which scope +created an entry, and every name-keyed message (updates, removes, clicks, +drags) resolved against that one namespace. Because `server.scene` +(broadcast) and each `client.scene` (per-client) both feed the same tree, a +name claimed by two scopes visible to the same viewer silently corrupted +state. + +An interim fix (a `SceneNameIndex`, since removed) kept name-only identity +and **rejected** overlapping-scope claims at the add site (`ValueError`), +with an audience-subset rule for cross-scope parenting and cross-scope +cascade on broadcast removals. That was sound, but it made the collision +class *forbidden* rather than *unrepresentable*, and it forced server and +client code to coordinate names. + +## The model + +Each scene-tree **name** becomes a slot holding up to two **variants**: a +broadcast variant and a client variant. Both variants keep independent state +(props, pose, visibility, interaction bindings), fed independently by their +scopes' messages. Exactly one variant is **effective** (rendered, +interactive) per name, chosen by a local display rule: + +> Pick the variant maximizing ``(is_real, is_client)``: +> real client > real broadcast > virtual client > virtual broadcast. + +"Virtual" marks auto-created intermediate ancestors (see below). The rule +gives client-over-server supersede semantics -- a client-scoped add of a name +the server owns *shadows* the broadcast node for that one viewer -- without +any of the machinery that made shadowing expensive under name-only identity: + +- **No per-client filtering of broadcast sends.** Updates to a shadowed + broadcast node land in the broadcast variant's state; they're simply not + displayed while shadowed. Nothing clobbers the client variant. +- **No resurrection machinery.** Removing the client variant un-hides the + broadcast variant, which has been accumulating state all along -- the + display rule is recomputed locally from data already in the store. +- **Deterministic removal in both directions.** Server removes its `/x`: + the broadcast variant leaves the slot; a client variant is unaffected. + Client removes its `/x`: the broadcast variant (if any) shows again. +- **Late joiners are trivially correct**: broadcast replay populates only + broadcast variants. + +**Hierarchy stays name-based.** One tree edge structure per name; children +attach to their parent *name*, not to a specific variant, and pose composes +through whichever variant is effective. This is what keeps the frontend +change small: no per-variant tree, no parent-edge resolution rules. + +### Virtual intermediates + +Ancestor auto-creation (`_ensure_ancestors_exist`) becomes **unconditional +per scope**: every add creates anchors for all missing *same-scope* +ancestors, even when another scope's variant of that name exists. These +anchors are flagged **virtual** -- a field on the create message -- and: + +- Virtual variants yield to real ones in the display rule, so a client + auto-ancestor for `/a` never shadows the server's real `/a` (its axes, its + pose visuals). A later explicit add of the same name from the same scope + supersedes the virtual variant with a real one (ordinary within-scope + supersede). +- Virtual variants render nothing and are never interactive; while a real + variant of the name exists, the anchor is pure lifecycle bookkeeping. + +Unconditional anchors give every node a complete same-scope ancestor chain, +which is what makes scope-local cascade (below) orphan-free: the two scopes +are two complete overlaid trees, merged per name by the display rule. The +cost is a handful of tiny anchor messages per deep add. + +Virtual intermediates also dissolve the audience-subset rule: a broadcast add +of `/a/b` where `/a` exists only in some client's scope auto-creates a +*virtual broadcast* `/a`. Other clients see the child under an invisible +anchor; the owning client's real `/a` shadows the anchor. No error needed in +either direction, so **both `ValueError`s from the name index are relaxed** +(non-breaking: code that raised starts working, with defined semantics). + +## Wire protocol + +`owner` is an **opaque string** stamped on scene-node messages, not a +boolean: today it takes two values ("broadcast" and a per-connection +identifier), but under the audience-set endgame (elements carry an audience; +`client.scene.add_*` becomes sugar) a client may see nodes from several +owners, and an opaque id avoids a second identity migration. + +**Per-message owner field, not per-batch origin tagging.** Batch tagging +(each of the two producer tasks stamping the windows it drains) is cheaper +today, but it identifies owners with *buffers* -- and the endgame is a single +persistent buffer whose per-client window generator filters messages by +audience (precedent: `excluded_self_client` is already filtered per-client in +`AsyncMessageBuffer.window_generator`). In that world one batch carries mixed +owners. Pay the schema sweep once: + +- Server→client: scene-node messages (`_CreateSceneNodeMessage` subclasses, + `SceneNodeUpdateMessage`, `Set{Orientation,Position,...}`, + `SetSceneNodeVisibilityMessage`, `RemoveSceneNodeMessage`, binding + messages) gain `owner: str`, stamped by the queueing `SceneApi`. Create + messages additionally gain the `virtual` flag. Changes go through + `_messages.py` + `sync_client_server.py --sync-messages`. +- Client→server: interaction messages (`SceneNodeClickMessage`, + `SceneNodeDragMessage`, transform-controls updates and drag start/end) + echo the effective variant's owner, so dispatch resolves to exactly one + scope's registry. Only the effective variant is interactive; a shadowed + broadcast node's bindings lie dormant until it is unshadowed. +- Entity identity for redundancy keys and GC becomes (owner, name) -- a + no-op while buffers are split per owner, load-bearing once merged. + +## Frontend + +- Store: per-name slot with `broadcast?` / `client?` variant entries; pose + data and bindings move into the variant. Effective-variant selection is + one pure function; only the effective variant is mounted (a shadow toggle + remounts, which is acceptable churn -- same cost as today's same-name + re-add). +- `nodeRefFromName` stays name-keyed (only the effective variant mounts). +- Scene-tree panel: one row per name (the effective variant), with a badge + for local/shadowing variants. +- `.viser` serialization: unchanged (recordings already filter to the + broadcast scope, which is collision-free on its own). + +## Python side + +- The `SceneNameIndex` keeps its bookkeeping roles (ancestor-existence + checks, disconnect cleanup) and loses both claim-time rejections -- and, + with scope-local cascade, its cross-scope cascade lookup. +- **Cascade is scope-local**: a server remove cascades through broadcast + descendants only; a client remove cascades through that client's + descendants only. Neither scope can destroy the other's state -- both + scopes are driven by the same Python program, so when coupled teardown is + wanted (a per-client annotation that should die with the mesh it + annotates), the author removes it explicitly rather than the design doing + it behind their back. Unconditional same-scope virtual anchors (above) + guarantee no orphans: the surviving scope's subtree keeps a complete + ancestor chain. This also deletes the cross-scope handle-invalidation + machinery from the current branch -- the zombie problem is solved from + the other side, by keeping the frontend node alive so handle and frontend + agree by construction. +- **Frozen-pose inheritance**: children compose pose through the parent + name's *effective* variant; virtual anchors contribute nothing while a + real variant exists. When the effective variant is removed and a virtual + anchor becomes effective, the anchor inherits the departing variant's + last pose (a frontend-local copy) -- surviving children stay where they + were instead of teleporting to identity. Accepted caveat: a client child + can outlive the broadcast object it annotated, frozen in place, until its + author removes it. +- `client.scene.add_frame("/WorldAxes")` becomes the sanctioned per-client + world-axes override (shadowing the server's node), replacing the removed + `client.scene.world_axes` handle. + +## Migration and sequencing + +1. (Done, current branch) Name-only identity + `SceneNameIndex` rejection. + Errors are forward-compatible: relaxing them later breaks nobody. +2. Merged single producer per connection (planned): one ordered stream, + cross-scope `atomic()`. Independent of this design but shares the + filtered-window machinery. +3. This design: owner + virtual fields, variant slots + display rule on the + frontend, both `ValueError`s relaxed, cascade rewired to scope-local + semantics (the interim cross-scope cascade + handle invalidation from + step 1 is deleted; handles that used to be invalidated stay valid, so the + change is again a relaxation). The branch's rejection tests flip to + coexistence/shadowing assertions. Client/server version gating already + forces matched deploys; no wire compatibility shims needed. +4. Audience sets: `audience=` on add, per-client filtering in the window + generator, audience mutation on live elements. Owner ids from step 3 are + the identity substrate; the display rule generalizes by ranking owner + specificity (more-specific audience wins). diff --git a/docs/source/conventions.rst b/docs/source/conventions.rst index 8dfb93f93..c77840562 100644 --- a/docs/source/conventions.rst +++ b/docs/source/conventions.rst @@ -75,6 +75,52 @@ In ``viser``, all camera parameters use the **COLMAP/OpenCV convention**: **Conversion**: A simple **180° rotation around the local X-axis** converts between the two conventions. +Server and Client Scopes +------------------------ + +Scene and GUI elements can be created through two kinds of handles: + +- ``server.scene`` / ``server.gui``: **shared** elements, visible to every + connected client and replayed to clients that connect later. +- ``client.scene`` / ``client.gui`` (via :class:`~viser.ClientHandle`): + **per-client** elements, visible to one client only. Client state is + ephemeral -- it disappears when the connection closes, and a reconnecting + browser is a new client -- so per-client state should be (re)built in + :meth:`~viser.ViserServer.on_client_connect`. + +Each scene-tree name can hold one node from each scope. When both exist, +the client-scoped node **shadows** the shared one for that client: it is +the one rendered and the one that receives clicks and drags, while other +clients keep seeing the shared node. Updates to a shadowed shared node keep +accumulating invisibly; removing the client-scoped node reveals the shared +node again with its latest state. This makes per-client overrides of shared +elements a one-liner:: + + # Everyone sees this... + server.scene.add_box("/box", color=(255, 0, 0)) + # ...except this client, who now sees their own version instead: + client.scene.add_box("/box", color=(0, 255, 0)) + +Removal is **scope-local**: removing a node (or a whole subtree) through +one scope's handle never touches the other scope's nodes, even per-client +children named under a shared parent -- those stay, anchored at the +parent's last pose, until their own scope removes them. In the scene-tree +panel, per-client nodes are marked with a ``local`` badge. + +GUI container nesting across scopes is **directional**: a ``client.gui`` +element may be added inside a ``server.gui`` container context (its +audience is a subset of the container's), rendering inside the shared +folder for that client only:: + + with server.gui.add_folder("Shared folder"): + client.gui.add_button("Only I see this") + +The reverse -- a ``server.gui`` element inside a ``client.gui`` container +-- raises, since no other client could see the container. Cross-nested +elements are the one exception to scope-local removal: removing the +server container also removes the client elements nested inside it (an +orphaned widget, unlike a scene node, has nowhere coherent to go). + ---- .. seealso:: @@ -82,5 +128,6 @@ In ``viser``, all camera parameters use the **COLMAP/OpenCV convention**: **Related Documentation** - :class:`~viser.ViserServer` for scene management + - :class:`~viser.ClientHandle` for per-client state - :func:`~viser.SceneApi.set_up_direction` for coordinate system configuration - :mod:`~viser.transforms` for transformation utilities diff --git a/examples/03_interaction/08_per_client_scenes.py b/examples/03_interaction/08_per_client_scenes.py new file mode 100644 index 000000000..973a8ad40 --- /dev/null +++ b/examples/03_interaction/08_per_client_scenes.py @@ -0,0 +1,77 @@ +"""Per-client scene state + +Mix shared (broadcast) scene elements with per-client ones, including +per-client overrides of shared nodes. + +**Features:** + +* :attr:`viser.ClientHandle.scene` for elements only one client sees +* Shadowing: a client-scoped node with a shared node's name replaces it for + that client only, and the shared node (with its latest state) returns when + the client-scoped variant is removed +* Scope-local removal: per-client annotations survive shared-node removal + until their own scope removes them +""" + +from __future__ import annotations + +import time + +import viser + +server = viser.ViserServer() + +# A shared box that every client sees, animated by the server. +shared_box = server.scene.add_box( + "/box", dimensions=(0.5, 0.5, 0.5), color=(200, 60, 60) +) + + +@server.on_client_connect +def _(client: viser.ClientHandle) -> None: + # Per-client GUI + scene state. Everything created through `client.` is + # visible to this client alone and is rebuilt here on reconnect (client + # state is ephemeral -- see the ClientHandle docs). + highlight = client.gui.add_checkbox("Highlight box (only me)", False) + annotate = client.gui.add_checkbox("Annotate box (only me)", False) + + highlight_handle: viser.BoxHandle | None = None + annotation_handle = None + + @highlight.on_update + def _(_) -> None: + nonlocal highlight_handle + if highlight.value: + # Same name as the shared box: this client-scoped variant + # SHADOWS the shared one for this client only. Other clients + # keep seeing the server's red box, and server updates keep + # accumulating in the hidden variant. + highlight_handle = client.scene.add_box( + "/box", dimensions=(0.55, 0.55, 0.55), color=(60, 200, 80) + ) + elif highlight_handle is not None: + # Un-shadow: the shared box reappears with its LATEST state. + highlight_handle.remove() + highlight_handle = None + + @annotate.on_update + def _(_) -> None: + nonlocal annotation_handle + if annotate.value: + # A per-client child under the shared node. If the server ever + # removes /box, this annotation survives (anchored where the box + # was) until this client removes it -- removal never reaches + # across scopes. + annotation_handle = client.scene.add_label( + "/box/note", text=f"client {client.client_id}'s note" + ) + elif annotation_handle is not None: + annotation_handle.remove() + annotation_handle = None + + +while True: + # Server-side animation of the shared box; shadowing clients don't see + # these updates until they un-shadow. + shared_box.position = (0.0, 0.0, 0.4 + 0.2 * (time.time() % 1.0)) + time.sleep(0.05) diff --git a/src/viser/_backwards_compat_shims.py b/src/viser/_backwards_compat_shims.py index 8547005f8..4d2542d19 100644 --- a/src/viser/_backwards_compat_shims.py +++ b/src/viser/_backwards_compat_shims.py @@ -103,6 +103,19 @@ class DeprecatedAttributeShim: `<=0.1.30`.""" def __getattr__(self, name: str) -> Any: + # During partial construction (or an attribute miss on a + # partially-torn-down object), `self.scene` / `self.gui` don't exist + # yet -- and looking them up would land back in THIS __getattr__, + # recursing until RecursionError. Bail out to a plain AttributeError + # instead, so init-ordering mistakes fail legibly. (Both attributes + # are plain instance attributes on ViserServer and ClientHandle, so + # __dict__ is the right place to check.) + if "scene" not in self.__dict__ or "gui" not in self.__dict__: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}' " + "(object is partially constructed: scene/gui APIs not set up " + "yet)" + ) fixed_name = { # Map from old method names (viser v0.1.*) to new methods names. "reset_scene": "reset", diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index 3af75eac9..1301addc2 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -12,6 +12,7 @@ from asyncio import AbstractEventLoop from collections.abc import Mapping from concurrent.futures import ThreadPoolExecutor +from contextvars import ContextVar from pathlib import Path from typing import ( TYPE_CHECKING, @@ -76,6 +77,7 @@ _colors_to_int_tuple, _CommandHandleState, _GuiButtonHandleState, + _GuiHandle, _GuiHandleState, _GuiInputHandle, _make_uuid, @@ -195,6 +197,20 @@ class _RootGuiContainer: _children: dict[str, SupportsRemoveProtocol] +_context_owner_by_server: ContextVar[dict[int, GuiApi]] = ContextVar( + "viser_gui_context_owner_by_server", default={} +) +"""Which GuiApi owns the active (non-root) container context, keyed by +``id()`` of the owning server's GuiApi. A ContextVar rather than +thread-keyed state: asyncio callbacks interleave on one event-loop thread, +and a ``with`` block suspended at an ``await`` must not leak its container +context into unrelated callbacks -- each asyncio task runs in a copied +Context, so only code inside the block sees the marker. Sync code keeps +working like before (a thread has its own implicit Context). Values are +treated as IMMUTABLE -- every update installs a fresh dict -- because +copied Contexts share the mapping object itself.""" + + _global_order_counter = 0 @@ -236,9 +252,11 @@ class GuiApi: _target_container_from_thread_id: dict[int, str] """ID of container to put GUI elements into. Per-instance (NOT a shared - class attribute) -- otherwise a thread inside a ``with some_gui.add_folder()`` - block would leak that container target into a *different* GuiApi instance - (e.g. server.gui vs a client.gui) and raise KeyError on the foreign uuid.""" + class attribute). Cross-instance nesting is DIRECTIONAL, resolved by + _get_container_uuid's thread-context check: a ``client.gui`` add inside + a ``with server.gui.add_folder()`` block nests in the server container + (the element's audience is a subset of the container's), while the + reverse raises instead of silently landing at the other scope's root.""" def __init__( self, @@ -266,6 +284,12 @@ def __init__( "root": _RootGuiContainer({}) } self._modal_handle_from_uuid: dict[str, GuiModalHandle] = {} + # Elements of THIS scope that were nested inside another scope's + # container (client elements inside server containers -- the only + # allowed direction). They are parented in the server GuiApi's + # container tree, so this scope's reset() and disconnect teardown + # can't reach them through the root container walk; track them here. + self._handles_in_foreign_containers: dict[str, _GuiHandle[Any]] = {} self._panel_handle_from_uuid: dict[str, PanelHandle] = {} # Layout-update counter, bumped on every placement command (any panel) # and stamped onto the placement message (see @@ -648,12 +672,169 @@ async def _handle_command_trigger( ).add_done_callback(print_threadpool_errors) def _get_container_uuid(self) -> str: - """Get container ID associated with the current thread.""" + """Get container ID associated with the current thread. + + When a container context from a DIFFERENT GuiApi of the same server + is active in the current Context, nesting is directional: a + client-scope add inside a server-scope container targets the + server's container, while the reverse raises -- silently placing the + element at this scope's root (the historical behavior) hid the + mistake.""" + owner_api = self._context_owner() + if owner_api is not None and owner_api is not self: + from ._viser import ViserServer + + if isinstance(owner_api._owner, ViserServer) and not isinstance( + self._owner, ViserServer + ): + # Client element into a server container: allowed (the + # element's audience is a subset of the container's). The + # element nests under the SERVER scope's active container. + # This is the one deliberate exception to scope-local + # removal: the server container's teardown cascades into + # cross-nested client elements, because an orphaned widget + # -- unlike a scene node, which keeps its pose -- has + # nowhere coherent to go. + return owner_api._target_container_from_thread_id.get( + threading.get_ident(), "root" + ) + # Server (or other-client) element into a client container: + # every viewer outside that client scope couldn't see the + # container, so the element would dangle. Fail loudly. + raise RuntimeError( + "A GUI container context from a client scope is active on " + "this thread, and elements with a broader audience cannot " + "nest inside it (they would dangle for every other client). " + "Client elements may nest inside server containers, but not " + "vice versa." + ) return self._target_container_from_thread_id.get(threading.get_ident(), "root") + def _resolve_container_handle(self, container_uuid: str) -> GuiContainerProtocol: + """Resolve a container uuid to its handle, tolerating client + elements nested inside SERVER containers: the parent then lives in + the server GuiApi's registry rather than this one's.""" + handle = self._container_handle_from_uuid.get(container_uuid) + if handle is not None: + return handle + server_gui = self._root_server().gui + if server_gui is not self: + handle = server_gui._container_handle_from_uuid.get(container_uuid) + if handle is not None: + return handle + raise KeyError(container_uuid) + + def _release_cross_scope_nesting(self) -> None: + """Bookkeeping-only detach of this (client-scoped) GuiApi's elements + from the server containers they were nested in. Called on + disconnect: the connection's buffer is already closed, so no removal + messages are sent -- we just unhook the handles from the server's + container tree so a later server-side container removal doesn't + cascade a remove into a dead connection.""" + while self._handles_in_foreign_containers: + uuid, handle = self._handles_in_foreign_containers.popitem() + # Tombstone BEFORE detaching from the server parent: a user + # thread racing us with handle.remove() then bails at the + # already-removed check instead of finding a half-detached + # tree (its registry pops are tolerant of ours regardless). + self._tombstone_subtree(handle) + parent = self._root_server().gui._container_handle_from_uuid.get( + handle._impl.parent_container_id + ) + if parent is not None: + parent._children.pop(uuid, None) + + def _tombstone_subtree(self, handle: Any) -> None: + """Recursively mark a cross-nested subtree removed and purge it from + this scope's registries WITHOUT queuing messages (the connection is + closed). Descendants must be tombstoned too: a surviving user + reference calling ``.remove()`` on one should get the ordinary + already-removed warning, not a KeyError from a parent that no + longer resolves. Mirrors the per-type drains in the remove() + implementations.""" + from ._gui_handles import GuiTabHandle + + # Tabs keep their tombstone/uuid on the handle itself; everything + # else keeps them on `_impl`. + is_tab = isinstance(handle, GuiTabHandle) + state = handle if is_tab else handle._impl + if state.removed: + return + state.removed = True + uuid = handle._id if is_tab else handle._impl.uuid + self._gui_input_handle_from_uuid.pop(uuid, None) + self._container_handle_from_uuid.pop(uuid, None) + for child in ( + *getattr(handle, "_tab_handles", ()), + *tuple(getattr(handle, "_children", {}).values()), + ): + self._tombstone_subtree(child) + + def _root_server(self): + """The ViserServer this GuiApi ultimately belongs to (itself for the + broadcast scope, the owning server for a client scope).""" + from ._viser import ViserServer + + return ( + self._owner + if isinstance(self._owner, ViserServer) + else self._owner._viser_server + ) + + def _context_owner(self) -> GuiApi | None: + """The GuiApi owning the current Context's active (non-root) + container context on this API's server, if any.""" + return _context_owner_by_server.get().get(id(self._root_server().gui)) + + def _set_context_owner(self, owner: GuiApi | None) -> None: + """Install (or clear, with None) this server's context-owner marker + in the current Context. Copy-on-write: copied Contexts (sibling + asyncio tasks) share the mapping object, so it is never mutated.""" + key = id(self._root_server().gui) + current = _context_owner_by_server.get() + if owner is None: + if key in current: + updated = dict(current) + del updated[key] + _context_owner_by_server.set(updated) + else: + _context_owner_by_server.set({**current, key: owner}) + def _set_container_uuid(self, container_uuid: str) -> None: - """Set container ID associated with the current thread.""" - self._target_container_from_thread_id[threading.get_ident()] = container_uuid + """Set container ID associated with the current thread, tracking + which GuiApi currently owns an active (non-root) container context + so cross-scope nesting stays directional (see _get_container_uuid).""" + thread_id = threading.get_ident() + self._target_container_from_thread_id[thread_id] = container_uuid + if container_uuid == "root": + if self._context_owner() is self: + self._set_context_owner(None) + else: + self._set_context_owner(self) + + def _snapshot_container_context(self) -> tuple[GuiApi, str]: + """Snapshot the active container context for a `with` block to + restore on exit: the OWNING GuiApi and its current target uuid. + Carrying the owner explicitly -- instead of re-deriving it from a + bare uuid at exit time -- keeps the restore correct even when the + snapshot container has been removed while the block was open (a + dangling uuid restores into the scope that owned it, exactly like a + removed-while-open container always has within a single scope). + Raises for disallowed nesting directions, via _get_container_uuid.""" + container_uuid = self._get_container_uuid() + owner_api = self._context_owner() + return (owner_api if owner_api is not None else self, container_uuid) + + def _restore_container_context(self, snapshot: tuple[GuiApi, str]) -> None: + """Restore an `__enter__`-time snapshot on `__exit__`. When the + snapshot belongs to another scope (a client container context nested + inside a server one), our own thread target -- set for the block + that is now exiting -- is dropped so later adds can't resolve + against it once the owning scope's context also exits.""" + owner_api, container_uuid = snapshot + if owner_api is not self: + self._target_container_from_thread_id.pop(threading.get_ident(), None) + owner_api._set_container_uuid(container_uuid) def _next_layout_counter(self) -> int: """Bump and return the layout-update counter. THE single home of the @@ -672,6 +853,11 @@ def reset(self) -> None: root_container = self._container_handle_from_uuid["root"] while root_container._children: next(iter(root_container._children.values())).remove() + # This scope's elements nested inside ANOTHER scope's containers + # (client elements in server containers) aren't reachable from this + # root; drain them explicitly. + while self._handles_in_foreign_containers: + next(iter(self._handles_in_foreign_containers.values())).remove() while self._modal_handle_from_uuid: next(iter(self._modal_handle_from_uuid.values())).close() # Panels are top-level entities (not under `root`), so drain them @@ -1003,7 +1189,12 @@ def add_form( #