bevy_react_core 0.7.0

The core bridge of bevy-react (drive bevy_ui from React over an embedded V8 runtime). Apps depend on the `bevy-react` crate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
//! The Bevy-side endpoint of the Rust<->JS boundary: channel handles plus the
//! id<->entity bookkeeping the reconciler ops are applied against.

use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::text::{LetterSpacing, LineHeight};
use crossbeam_channel::Receiver;

use crate::protocol::{NodeId, op::Op, outbound::Outbound};
use crate::style::{Style, StyleDirty};

/// The text appearance a `<text>` element/span carries, kept so inheriting child
/// runs (bare strings) can copy it on append without an ECS query (Bevy commands
/// are deferred within an op batch, so the parent's components aren't visible yet).
pub type ResolvedTextStyle = (TextColor, TextFont, LineHeight, LetterSpacing);

/// Carries batches of reconciler ops from the JS thread to Bevy.
pub type OpReceiver = Receiver<Vec<Op>>;
/// Carries everything Bevy sends to the JS side — UI events, app events, and
/// request responses — over one channel (sync `send`, no runtime needed).
///
/// The transport differs by target. On native, the JS thread parks on an async
/// recv, so this is a tokio `UnboundedSender`. On web there is no separate thread
/// (React runs in the page's own engine), so a crossbeam `Sender` drained per
/// frame is enough. Both expose the same `send(msg) -> Result<…>`, so every
/// producer ([`event`](crate::event), [`request`](crate::request)) is target-agnostic.
#[cfg(not(target_arch = "wasm32"))]
pub type OutboundSender = tokio::sync::mpsc::UnboundedSender<Outbound>;
#[cfg(target_arch = "wasm32")]
pub type OutboundSender = crossbeam_channel::Sender<Outbound>;

/// Marks an entity the React reconciler created (every host element: nodes,
/// text roots and spans, SVG shapes, portals, surfaces, roots), carrying its
/// reconciler node id — the id a JS `ref` resolves to (`{ id, type }`).
///
/// This is the app-side filter for reaching React-created entities from Bevy:
/// `Query<(Entity, &Name), With<ReactNode>>` finds nodes by their `name` prop
/// (see [`ReactNodes`](crate::ReactNodes) for the hash lookup), and
/// `Added<ReactNode>` / `RemovedComponents<ReactNode>` are the mount/unmount
/// signals. Order such systems `.after(ReactApplySet)` to see the current
/// frame's ops.
///
/// # What the app may touch
///
/// The bridge re-applies the props it owns on every delta, so writes to
/// **bridge-owned components** are clobbered on the next re-render (only the
/// writers reading a changed style property re-run, so a stray write may even
/// survive for a while — don't rely on it). Read them freely; own everything else:
///
/// - Bridge-owned: `Node`, `BackgroundColor`, `BorderColor`, `BorderRadius`,
///   `Outline`, `BoxShadow`, `ZIndex`/`GlobalZIndex`, `LayoutConfig`, `Visibility`,
///   `UiTransform`, `ImageNode`, `Text`/`TextSpan`/`TextFont`/`TextColor`/
///   `TextLayout`, `ScrollPosition`, `Interaction`/`FocusPolicy`/`Pickable`
///   (the `focusPolicy` mirror; SVG shapes get a pass-through one), `Name`,
///   `Children`/`ChildOf`, plus this crate's own markers.
/// - Yours: any component the bridge never inserts (`MaterialNode<M>`,
///   `TabIndex`, audio, your own markers and data).
///
/// Child entities you spawn under a React node die with it (`despawn` is
/// recursive) but are **orphaned** whenever the bridge rebuilds that node's
/// child list (a sibling mounting, unmounting, or reordering): the sync
/// replaces `Children` from the React tree, dropping their `ChildOf` — an
/// orphaned `Node` then renders as a root UI node. Parent Bevy children under a
/// node whose React children are static, or keep them world-space and follow
/// the node's layout instead.
#[derive(Component, Debug, Clone, Copy)]
pub struct ReactNode(pub NodeId);

/// Marks a `<root>` host element: the screen-space twin of `<surface>` — a
/// detached top-level UI tree rendered on the default UI camera, used for
/// overlays that must float above (and stay out of) the app's own tree, like
/// the devtools panel. Tracked in [`JsBridge::detached`].
#[derive(Component, Debug, Clone, Copy)]
pub struct RRoot;

/// Base + hover + press styles kept on an element that declares `hoverStyle`
/// and/or `pressStyle`. The interaction system re-applies the merged style as
/// the node's `Interaction` changes, entirely on the Bevy side (no round-trip
/// to JS). Absent on elements without variants — they style as before.
#[derive(Component, Debug, Clone, Default)]
pub struct StyleVariants {
    pub base: Option<Style>,
    pub hover: Option<Style>,
    pub press: Option<Style>,
    pub focus: Option<Style>,
    /// The properties any variant sets — what a hover/press/focus edge
    /// re-applies (the merged value of anything else is the base's, already
    /// applied).
    pub keys: StyleDirty,
    /// Why the next interaction restyle runs — what a base-only delta has
    /// dirtied since the last one consumed it. Written by the op-apply path
    /// (a queued in-place `base` update ORs its `StyleDirty` mask in), read
    /// and reset to [`Restyle::Idle`] by `apply_interaction_styles`.
    pub restyle: Restyle,
}

/// The pending work behind a `Changed<StyleVariants>` tick (see
/// [`StyleVariants::restyle`]). A change with no recorded reason — a full
/// (re)stamp, or a poke from the layer evaluator — re-merges and re-runs
/// every writer. An `Interaction`/`FocusState` flip re-runs the writers of
/// the properties any variant sets ([`StyleVariants::keys`]); a base-only
/// delta re-runs just the writers of the properties it touched (the merged
/// style can't differ anywhere else), and when the node is idle (not
/// hovered, pressed, or focused) the merged style IS the base the op path
/// already applied, so the restyle is skipped outright.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Restyle {
    /// Re-apply everything (the value a full stamp inserts with).
    #[default]
    Full,
    /// Nothing recorded — the resting value; a change tick without a reason
    /// (a promotion-flip poke) is treated as [`Restyle::Full`].
    Idle,
    /// Only a base-style delta happened; re-run the writers of these
    /// properties.
    Base(StyleDirty),
}

impl Restyle {
    /// Fold a base-only delta's touched properties in: accumulates across several
    /// deltas in one frame, never narrows a pending full restyle.
    pub fn note_base_delta(&mut self, mask: StyleDirty) {
        *self = match *self {
            Restyle::Idle => Restyle::Base(mask),
            Restyle::Base(prev) => Restyle::Base(prev.union(mask)),
            Restyle::Full => Restyle::Full,
        };
    }
}

/// Whether a node with a `focusStyle` [`StyleVariants::focus`] is currently
/// focused. A *mutated* bool (never inserted/removed while the node lives) so the
/// interaction-style system re-merges on `Changed<FocusState>` when focus toggles.
/// Set by the focus observers; read by `apply_interaction_styles`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct FocusState(pub bool);

/// Records which pointer handlers a node declared in JS, so the drag-capture
/// system knows whether to emit `pointerDown` / `pointerMove` / `pointerUp` for
/// it, and the hover system whether to emit `pointerEnter` / `pointerLeave`.
/// Stamped (or removed) alongside the node's `Interaction` +
/// `RelativeCursorPosition` whenever any `onPointer*` handler is present.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct PointerHandlers {
    pub down: bool,
    pub moved: bool,
    pub up: bool,
    pub enter: bool,
    pub leave: bool,
}

/// Marks an element that **owns** clicks: the click collectors
/// ([`collect_ui_events`](crate::reconcile::collect_ui_events) /
/// `collect_virtual_clicks`) climb a picked leaf to the nearest click owner,
/// and only owners are reported to JS. Stamped by `apply_pointer_handlers`
/// when `onClick` or any `onPointer*` handler is declared; native `<button>`s
/// and `editableText` inputs own clicks by element type (the collectors match
/// them directly). Deliberately **not** `Interaction`: hover/press styling
/// inserts an `Interaction` too, and a style-only interactive element (a
/// hover-styled `<text>` label inside a `<button>`) must never steal the
/// click from the ancestor that declared the handler.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ClickOwner;

/// Whether a node with an `onPointerEnter`/`onPointerLeave` handler currently has
/// the pointer inside it (its `Interaction` is not `None`). Kept so the hover
/// system can emit `pointerEnter`/`pointerLeave` only on the boundary crossing —
/// not on the `Hovered`↔`Pressed` transition of a click. A *component* (rather
/// than a side-table) so it despawns with the node. Stamped alongside
/// `PointerHandlers` when either handler is present; absent otherwise.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct HoverState(pub bool);

/// Marks a node that declared an `onScroll` handler, so the read-back system
/// (`collect_scroll_events`) reports its `ScrollPosition` changes. The marker is
/// what keeps that query cheap: `ScrollPosition` is a required component of every
/// `Node`, so a bare `Changed<ScrollPosition>` query would fire for every node on
/// its mount frame — scoping to `With<ScrollListener>` walks only onScroll nodes.
/// Inserted/removed alongside the node as its `onScroll` handler comes and goes,
/// mirroring how `Interaction` gates `onClick`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct ScrollListener;

/// Marks a node that declared an `onWheel` handler, so
/// [`crate::scroll::collect_wheel_events`] reports raw wheel deltas over it. The
/// marker scopes the wheel hit-test to opted-in nodes; unlike [`ScrollListener`]
/// it works on *any* node (no `overflow: scroll` needed). Inserted/removed as the
/// handler comes and goes, mirroring `ScrollListener`.
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct WheelListener;

/// Per-node wheel step: logical pixels scrolled per mouse-wheel "line", overriding
/// the default. Read by `scroll::apply_scroll`; absent → the default `LINE_HEIGHT`.
/// Stamped from the `scrollStep` prop; only affects `MouseScrollUnit::Line` wheels
/// (trackpads report `Pixel` deltas, which are used raw).
#[derive(Component, Debug, Clone, Copy)]
pub struct ScrollStep(pub f32);

/// A standalone clone of the outbound sender, inserted in [`Plugin::build`] so
/// the request dispatcher and the [`ReactEvents`](crate::ReactEvents) system
/// param can push to JS without depending on [`JsBridge`], which only exists
/// after `Startup`.
#[derive(Resource, Clone)]
pub struct OutboundResource(pub OutboundSender);

/// What kind of `TextSpan`-backed run a node inside a `<text>` element is.
/// Tracked in [`JsBridge::spans`]; a node not in that map isn't a span at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpanKind {
    /// A bare-string run with no style of its own: it inherits (and must be
    /// re-sent) its parent `<text>`'s resolved style.
    RawInherited,
    /// A nested/inline `<text>` span carrying its own style, which must NOT
    /// inherit its parent's.
    InlineStyled,
}

/// The Bevy resource holding the live boundary state.
#[derive(Resource)]
pub struct JsBridge {
    /// The app's feature registrations (element kinds, prop keys — see
    /// [`crate::ext`]), snapshotted at startup. On the bridge rather than a
    /// system param because `apply_js_ops` is at Bevy's 16-param cap.
    pub ext: std::sync::Arc<crate::ext::ExtRegistry>,
    /// Incoming op batches from the reconciler.
    pub ops_rx: OpReceiver,
    /// Outgoing UI events to the reconciler (wrapped in [`Outbound::UiEvent`]).
    pub outbound_tx: OutboundSender,
    /// Maps reconciler node ids to their spawned entities.
    pub nodes: HashMap<NodeId, Entity>,
    /// The `name` prop → entities index behind [`crate::ReactNodes`]. Kept in
    /// step with the `Name` components by the op-apply path (create/update/
    /// remove/reset); see [`crate::names`].
    pub names: crate::names::NameIndex,
    /// The `sharedTag` index (+ every node's element kind) behind the
    /// shared-element pairing pre-pass; see `crate::shared_tags`.
    pub shared_tags: crate::shared_tags::SharedTags,
    /// The last applied props per node (event-like fields stripped — see
    /// [`crate::protocol::props::Props::split_events`]). Every [`crate::protocol::op::Op::Update`]
    /// merges its delta into this retained state, so the apply path always works
    /// from the full merged props even though only the changed fields crossed
    /// the boundary. Seeded on create.
    /// Boxed: `Props` is several KB by value (four inline `Style`s), and the
    /// update path moves entries out of and back into the map per op.
    pub props_cache: HashMap<NodeId, Box<crate::protocol::props::Props>>,
    /// Nodes whose layer-promotion state may have changed this batch (a delta
    /// touched a trigger field, or their child count crossed 0↔1+). Marked by
    /// the op-apply arms, drained by
    /// [`crate::layer::evaluate_layer_promotions`]. Lives on the bridge (not a
    /// resource) because `apply_js_ops` is at Bevy's 16-system-param cap.
    pub layer_dirty: HashSet<NodeId>,
    /// Nodes currently promoted to composited layers (mirror of the
    /// [`crate::layer::PromotedLayer`] markers, maintained by the evaluator).
    /// The op-apply and interaction-restyle paths consult it to suppress the
    /// per-node opacity fold; rich metadata lives in
    /// [`crate::layer::LayersRegistry`].
    pub promoted_layers: HashSet<NodeId>,
    /// Resolved text style of each `<text>` element/span, for span inheritance.
    pub text_styles: HashMap<NodeId, ResolvedTextStyle>,
    /// Node ids whose text content lives in a `TextSpan` component (vs a `Text`),
    /// so `Op::UpdateText` updates the right component, and what [`SpanKind`]
    /// each one is. Nodes absent from the map hold a plain `Text` (or no text).
    pub spans: HashMap<NodeId, SpanKind>,
    /// Detached nodes — elements whose `ElementFlags::detached` is set
    /// (`<surface>`/`<root>` UI roots, `<anchor>` overlays): never attached in
    /// the Bevy hierarchy by the op path; see [`Self::is_detached`].
    pub detached: HashSet<NodeId>,
    /// Nodes currently carrying an [`AnimatedNode`](crate::animations::AnimatedNode)
    /// (a mirror of the component's presence, maintained by the stamp helpers),
    /// so a style delta on a binding-less node — the common case — queues no
    /// `remove::<AnimatedNode>()` no-op command.
    pub animated: HashSet<NodeId>,
    /// The last `ScrollPosition` emitted to JS (or written by a controlled
    /// `scrollTop`/`scrollLeft`) per node. Dedups `"scroll"` events and breaks the
    /// controlled-component echo loop: a programmatic write-back equal to this is
    /// not re-emitted. Only nodes with an `onScroll` handler appear here.
    pub scroll_positions: HashMap<NodeId, Vec2>,
    /// Authoritative ordered children per parent (incl. `ROOT_ID`), stored as a
    /// doubly-linked sibling list (per-child links here, per-parent ends in
    /// [`Self::child_list`]) so detach/append/insert are O(1) regardless of sibling
    /// count. Bevy's `Children` component can't be read mid-batch — `Commands`
    /// hierarchy ops are deferred to the next sync point — so this mirror is the
    /// source of truth for child order; `apply_js_ops` syncs it into the ECS with one
    /// `replace_children` per structurally-changed parent per batch.
    pub siblings: HashMap<NodeId, SiblingLinks>,
    /// Ends of each parent's ordered child list (entry present iff non-empty).
    pub child_list: HashMap<NodeId, ChildList>,
    /// Reverse lookup (child → its current parent) so a re-parent or reorder can detach
    /// the child from its old parent's ordered list before re-inserting it.
    pub parent_of: HashMap<NodeId, NodeId>,
    /// React-tree parentage of detached nodes: detached id → its React parent
    /// id, plus the reverse (parent → its detached children). A detached node is
    /// kept OUT of `siblings`/`parent_of` (it's not a Bevy child of its React
    /// parent, and counting it would skew sibling ordering), so its structural
    /// position lives here instead. This lets `Op::Remove` of an *ancestor*
    /// despawn the detached node — which Bevy's recursive despawn can't reach.
    pub detached_parent: HashMap<NodeId, NodeId>,
    pub child_detached: HashMap<NodeId, Vec<NodeId>>,
    /// Reusable DFS stack for the subtree walks (`detached_under`,
    /// `forget_subtree`) — always empty between calls.
    walk_stack: Vec<NodeId>,
}

/// Doubly-linked sibling entry (present iff the node is attached to a parent).
#[derive(Clone, Copy, Default)]
pub struct SiblingLinks {
    prev: Option<NodeId>,
    next: Option<NodeId>,
}

/// Ends of a parent's ordered child list.
#[derive(Clone, Copy)]
pub struct ChildList {
    head: NodeId,
    tail: NodeId,
}

impl JsBridge {
    pub fn new(ops_rx: OpReceiver, outbound_tx: OutboundSender, root: Entity) -> Self {
        let mut nodes = HashMap::new();
        // ROOT_ID (0) always resolves to the UI root entity.
        nodes.insert(crate::protocol::ROOT_ID, root);
        Self {
            ext: std::sync::Arc::new(crate::ext::builtin_registry()),
            ops_rx,
            outbound_tx,
            nodes,
            names: Default::default(),
            shared_tags: crate::shared_tags::SharedTags::default(),
            props_cache: HashMap::new(),
            layer_dirty: HashSet::new(),
            promoted_layers: HashSet::new(),
            text_styles: HashMap::new(),
            spans: HashMap::new(),
            detached: HashSet::new(),
            animated: HashSet::new(),
            scroll_positions: HashMap::new(),
            siblings: HashMap::new(),
            child_list: HashMap::new(),
            parent_of: HashMap::new(),
            detached_parent: HashMap::new(),
            child_detached: HashMap::new(),
            walk_stack: Vec::new(),
        }
    }

    /// Whether `id` is a detached node (see [`Self::detached`]): the
    /// child-attach ops record its React parentage via
    /// [`Self::attach_detached`] instead of attaching it.
    pub fn is_detached(&self, id: NodeId) -> bool {
        !self.detached.is_empty() && self.detached.contains(&id)
    }

    /// Record a detached node's React parent (detaching it from any previous
    /// one first), so a later removal of an ancestor can find and despawn it.
    pub fn attach_detached(&mut self, node: NodeId, parent: NodeId) {
        self.detach_detached(node);
        self.detached_parent.insert(node, parent);
        self.child_detached.entry(parent).or_default().push(node);
    }

    /// Unlink a detached `node` from its current React parent's list (if
    /// any). Called before a re-`Append`/`Insert` (a reorder/re-parent) and on
    /// removal.
    pub fn detach_detached(&mut self, node: NodeId) {
        if let Some(parent) = self.detached_parent.remove(&node)
            && let Some(list) = self.child_detached.get_mut(&parent)
        {
            list.retain(|&id| id != node);
        }
    }

    /// Every detached node structurally **under** `node` — its detached
    /// children, recursively through normal descendants (the sibling lists)
    /// and nested detached nodes — removing their parentage bookkeeping as it
    /// goes. Does NOT include `node` itself (a detached node removed directly
    /// is handled by its own `Remove`). Used so `Op::Remove` despawns detached
    /// nodes that Bevy's recursive despawn of `node` can't reach.
    pub fn detached_under(&mut self, node: NodeId) -> Vec<NodeId> {
        let mut out = Vec::new();
        // No detached node anywhere (the common case): nothing to walk for.
        if self.child_detached.is_empty() {
            return out;
        }
        let mut stack = std::mem::take(&mut self.walk_stack);
        stack.push(node);
        while let Some(n) = stack.pop() {
            if let Some(detached) = self.child_detached.remove(&n) {
                for d in detached {
                    self.detached_parent.remove(&d);
                    out.push(d);
                    // A detached node can itself host nested ones.
                    stack.push(d);
                }
                // The last detached node has been found: the rest of the
                // walk can't find another.
                if self.child_detached.is_empty() {
                    break;
                }
            }
            stack.extend(self.children_of(n));
        }
        stack.clear();
        self.walk_stack = stack;
        out
    }

    /// Unlink `child` from its current parent's ordered children list (if any). Called
    /// before an `Append`/`Insert` so a reorder or re-parent doesn't leave a stale
    /// duplicate in the shadow tree, and on removal. O(1): patches the doubly-linked
    /// neighbors and the parent's list ends.
    pub fn detach(&mut self, child: NodeId) {
        let Some(parent) = self.parent_of.remove(&child) else {
            return;
        };
        let Some(links) = self.siblings.remove(&child) else {
            return;
        };
        if let Some(prev) = links.prev
            && let Some(l) = self.siblings.get_mut(&prev)
        {
            l.next = links.next;
        }
        if let Some(next) = links.next
            && let Some(l) = self.siblings.get_mut(&next)
        {
            l.prev = links.prev;
        }
        if let Some(list) = self.child_list.get_mut(&parent) {
            match (links.prev, links.next) {
                // Only child: the parent's list is now empty.
                (None, None) => {
                    self.child_list.remove(&parent);
                }
                (None, Some(next)) => list.head = next,
                (Some(prev), None) => list.tail = prev,
                (Some(_), Some(_)) => {}
            }
        }
    }

    /// Attach `child` as `parent`'s last child (detaching it from any previous
    /// position first). O(1).
    pub fn append_child(&mut self, parent: NodeId, child: NodeId) {
        self.detach(child);
        self.parent_of.insert(child, parent);
        match self.child_list.get_mut(&parent) {
            Some(list) => {
                let old_tail = list.tail;
                if let Some(l) = self.siblings.get_mut(&old_tail) {
                    l.next = Some(child);
                }
                self.siblings.insert(
                    child,
                    SiblingLinks {
                        prev: Some(old_tail),
                        next: None,
                    },
                );
                list.tail = child;
            }
            None => {
                self.siblings.insert(child, SiblingLinks::default());
                self.child_list.insert(
                    parent,
                    ChildList {
                        head: child,
                        tail: child,
                    },
                );
            }
        }
    }

    /// Attach `child` immediately before `before` under `parent` (detaching `child`
    /// from any previous position first). Falls back to appending when `before` is not
    /// currently a child of `parent` — the same fallback the old index-based path had
    /// (`position(..).unwrap_or(len)`), and the path taken when `before` is a
    /// `<surface>` (which never enters the sibling list). O(1).
    pub fn insert_before(&mut self, parent: NodeId, child: NodeId, before: NodeId) {
        self.detach(child);
        if self.parent_of.get(&before) != Some(&parent) {
            self.append_child(parent, child);
            return;
        }
        self.parent_of.insert(child, parent);
        let before_links = self
            .siblings
            .get_mut(&before)
            .expect("attached child has sibling links");
        let prev = before_links.prev;
        before_links.prev = Some(child);
        self.siblings.insert(
            child,
            SiblingLinks {
                prev,
                next: Some(before),
            },
        );
        match prev {
            Some(p) => {
                if let Some(l) = self.siblings.get_mut(&p) {
                    l.next = Some(child);
                }
            }
            None => {
                if let Some(list) = self.child_list.get_mut(&parent) {
                    list.head = child;
                }
            }
        }
    }

    /// Iterate `parent`'s children in order (walks the sibling links).
    pub fn children_of(&self, parent: NodeId) -> impl Iterator<Item = NodeId> + '_ {
        let mut cursor = self.child_list.get(&parent).map(|l| l.head);
        std::iter::from_fn(move || {
            let id = cursor?;
            cursor = self.siblings.get(&id).and_then(|l| l.next);
            Some(id)
        })
    }

    /// Drop all per-node side-table data for a single node id. Covers the `NodeId`-keyed
    /// data tables only — NOT the structural `siblings`/`child_list`/`parent_of` maps
    /// (handled by `forget_subtree`/`detach`) nor the detached parentage maps
    /// (handled by `attach_detached`/`detach_detached`/`detached_under`).
    fn forget_node_data(&mut self, id: NodeId) {
        let entity = self.nodes.remove(&id);
        let props = self.props_cache.remove(&id);
        // Drop the node from the by-name index (its `Name` dies with the entity).
        if let (Some(entity), Some(name)) = (entity, props.as_ref().and_then(|p| p.name.as_deref()))
        {
            self.names.remove(name, entity);
        }
        self.shared_tags
            .forget(id, props.as_ref().and_then(|p| p.shared_tag.as_deref()));
        take_if_any(&mut self.layer_dirty, id);
        take_if_any(&mut self.promoted_layers, id);
        remove_if_any(&mut self.text_styles, id);
        remove_if_any(&mut self.spans, id);
        take_if_any(&mut self.detached, id);
        take_if_any(&mut self.animated, id);
        remove_if_any(&mut self.scroll_positions, id);
    }

    /// Drop `child` and its whole subtree from the shadow tree. React emits a `Remove`
    /// only for the root of a removed subtree (Bevy despawns the descendants
    /// recursively), so we recurse to keep the structural maps bounded and to prune
    /// every node's per-node side-table data (via `forget_node_data`) — otherwise
    /// descendant ids would linger as stale entity handles until the next `Op::Reset`.
    /// Does not unlink the root from its parent's ordered list; call `detach` for that.
    pub fn forget_subtree(&mut self, child: NodeId) {
        let mut stack = std::mem::take(&mut self.walk_stack);
        stack.push(child);
        while let Some(id) = stack.pop() {
            self.forget_node_data(id);
            // Unlink every child as the list is walked (the links are consumed
            // in the same step that yields the next sibling).
            if let Some(list) = self.child_list.remove(&id) {
                let mut cursor = Some(list.head);
                while let Some(kid) = cursor {
                    self.parent_of.remove(&kid);
                    cursor = self.siblings.remove(&kid).and_then(|l| l.next);
                    stack.push(kid);
                }
            }
        }
        self.walk_stack = stack;
    }
}

/// `map.remove(&id)`, skipping the hash when the table is empty — most of the
/// per-node side-tables are empty in most apps, and every removed node pays
/// for all of them.
fn remove_if_any<V>(map: &mut HashMap<NodeId, V>, id: NodeId) {
    if !map.is_empty() {
        map.remove(&id);
    }
}

/// [`remove_if_any`] for the set-typed side-tables.
fn take_if_any(set: &mut HashSet<NodeId>, id: NodeId) {
    if !set.is_empty() {
        set.remove(&id);
    }
}