Skip to main content

dioxus_dnd/core/components/
drop_zone.rs

1//! Drop targets: [`DropZone`], the two-world [`BridgeDropZone`], and the
2//! N-world [`crate::bridge_drop_zone!`] macro, plus the [`ParentZone`]
3//! context marker nested zones discover their parent through.
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use std::rc::Rc;
9
10use crate::core::hooks::{
11    use_bridge_world, use_dnd, use_zone_id, use_zone_registry, BridgeGeometry,
12};
13use crate::core::registry::{ZonePolicy, ZoneRecord};
14use crate::core::types::{edge_of, DragMode, DropOutcome, EdgeSet, Rect, ZoneId};
15use crate::core::world::use_joined_window;
16use crate::core::{DropEffects, DropQuery};
17
18/// Context marker a `DropZone` provides so zones nested inside it can
19/// discover their parent - powering hierarchical keyboard traversal with no
20/// configuration.
21#[derive(Clone, Copy, PartialEq)]
22pub struct ParentZone(pub ZoneId);
23
24#[derive(Clone, Copy, PartialEq)]
25struct LiveParentZone(Signal<ZoneId>);
26
27/// Read the nearest parent zone, including a bridge whose identity can change.
28///
29/// Public only for `bridge_drop_zone!` expansions in downstream crates.
30#[doc(hidden)]
31pub fn use_parent_zone() -> Option<ZoneId> {
32    let live = try_use_context::<LiveParentZone>();
33    let fixed = try_use_context::<ParentZone>();
34    live.map(|parent| *parent.0.read())
35        .or_else(|| fixed.map(|parent| parent.0))
36}
37
38/// Keyed context boundary used by the exported bridge macro.
39///
40/// This is public only because macro expansion happens in downstream crates.
41#[doc(hidden)]
42#[component]
43pub fn BridgeParentZoneBoundary(zone_id: ZoneId, children: Element) -> Element {
44    let mut live = use_signal(|| zone_id);
45    use_effect(use_reactive!(|(zone_id)| {
46        if *live.peek() != zone_id {
47            live.set(zone_id);
48        }
49    }));
50    provide_context(LiveParentZone(live));
51    provide_context(ParentZone(zone_id));
52    rsx! { {children} }
53}
54
55#[component]
56fn FixedParentZoneBoundary(zone_id: ZoneId, children: Element) -> Element {
57    provide_context(ParentZone(zone_id));
58    rsx! { {children} }
59}
60
61/// A region that accepts drags carrying `T`.
62///
63/// Handles the HTML5 boilerplate for you: `preventDefault` on dragover,
64/// enter/leave depth counting (so child elements don't cause hover flicker),
65/// and acceptance filtering.
66///
67/// Styling hooks: while an acceptable drag is in flight anywhere, the div
68/// carries `data-active="true"` (reveal your drop targets); while that drag
69/// hovers *this* zone it also carries `data-over="true"` (highlight it).
70/// Both are absent otherwise, so presence-based selectors (CSS
71/// `[data-over]`, Tailwind `data-over:ring-2`) work directly. Driven by the
72/// shared context, so they light up for pointer, touch and keyboard drags
73/// alike.
74///
75/// Opting into `edge` adds the closest-edge signal for insertion
76/// indicators: while an acceptable *pointer* drag hovers this zone, the div
77/// also carries `data-edge="top" | "right" | "bottom" | "left"` (the zone
78/// edge nearest the pointer, live on every move - see [`edge_of`]), and the
79/// delivered [`DropOutcome::edge`] records it at release. Style it with
80/// value selectors, e.g. Tailwind
81/// `data-[edge=top]:shadow-[0_-2px_0_0_currentColor]`.
82///
83/// Overlap precedence follows registry order, not browser paint order: among
84/// overlapping acceptable zones, the later record receives the drop. CSS
85/// `z-index`, stacking contexts, and portals are not inspected. Keep registry
86/// and visual order aligned when targets overlap, or avoid the overlap; a
87/// rejecting later record is skipped at release. Replacing a same-id record
88/// retains its slot.
89#[component]
90pub fn DropZone<T: Clone + PartialEq + 'static>(
91    /// Stable identity for this zone. Auto-generated if omitted.
92    #[props(default)]
93    id: Option<ZoneId>,
94    /// Human label for screen-reader announcements ("Over {label}").
95    #[props(default)]
96    label: Option<String>,
97    /// Return `false` to reject a payload (zone won't highlight or accept it).
98    /// Keep this predicate cheap. Registry queries snapshot their candidates
99    /// before invoking application callbacks, so reentrant registry work does
100    /// not collide with a live signal borrow.
101    #[props(default)]
102    accepts: Option<Callback<T, bool>>,
103    /// Rich acceptance predicate with source, effect, input mode, pointer
104    /// kind, and drag identity.
105    #[props(default)]
106    accepts_query: Option<Callback<DropQuery<T>, bool>>,
107    /// Effects this zone supports. Defaults to all effects for 3.x
108    /// compatibility; prefer `DropEffects::STANDARD` for new zones.
109    #[props(default)]
110    allowed_effects: DropEffects,
111    /// Track the zone edge nearest the pointer: `EdgeSet::Vertical` for
112    /// top/bottom (a vertical stack), `EdgeSet::Horizontal` for left/right,
113    /// `EdgeSet::All` for all four. Renders `data-edge` while hovered and
114    /// fills [`DropOutcome::edge`]. Off (absent, `None`) by default.
115    #[props(default)]
116    edge: Option<EdgeSet>,
117    /// Fired on a successful drop.
118    on_drop: EventHandler<DropOutcome<T>>,
119    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
120    children: Element,
121) -> Element {
122    let auto_id = use_zone_id();
123    let zone_id = id.unwrap_or(auto_id);
124    let parent = use_parent_zone();
125    rsx! {
126        for (keyed_zone_id, keyed_parent) in [(zone_id, parent)] {
127            DropZoneInstance::<T> {
128                key: "{keyed_zone_id.0}:{keyed_parent:?}",
129                zone_id: keyed_zone_id,
130                parent: keyed_parent,
131                provide_parent: true,
132                label: label.clone(),
133                accepts,
134                accepts_query,
135                allowed_effects,
136                edge,
137                on_drop,
138                attributes: attributes.clone(),
139                {children.clone()}
140            }
141        }
142    }
143}
144
145#[component]
146fn DropZoneInstance<T: Clone + PartialEq + 'static>(
147    zone_id: ZoneId,
148    parent: Option<ZoneId>,
149    provide_parent: bool,
150    label: Option<String>,
151    accepts: Option<Callback<T, bool>>,
152    accepts_query: Option<Callback<DropQuery<T>, bool>>,
153    allowed_effects: DropEffects,
154    edge: Option<EdgeSet>,
155    on_drop: EventHandler<DropOutcome<T>>,
156    attributes: Vec<Attribute>,
157    children: Element,
158) -> Element {
159    let dnd = use_dnd::<T>();
160    let joined = use_joined_window::<T>();
161    let mut registry = use_zone_registry::<T>();
162    // Nesting is automatic: a DropZone inside another discovers its parent
163    // via context, and provides itself to zones deeper down.
164    // Register with the zone registry so keyboard navigation and pointer
165    // hit-testing can find this zone. Callbacks are stable handles, so
166    // registering once per mount is enough.
167    let registered_label = label.clone();
168    let registration = use_hook(|| {
169        registry.register_with_policy(
170            ZoneRecord {
171                id: zone_id,
172                parent,
173                label: registered_label.clone(),
174                on_drop: Callback::new(move |outcome: DropOutcome<T>| on_drop.call(outcome)),
175                accepts,
176                mounted: None,
177                rect: None,
178            },
179            ZonePolicy {
180                accepts_query,
181                allowed_effects,
182                edge,
183            },
184        )
185    });
186    use_drop(move || {
187        registry.unregister_registration(registration);
188    });
189    let label_for_sync = label.clone();
190    use_effect(use_reactive!(|(label_for_sync)| {
191        registry.sync_label(zone_id, label_for_sync);
192    }));
193    use_effect(use_reactive!(|(parent)| {
194        registry.sync_parent(registration, parent);
195    }));
196    use_effect(use_reactive!(|(
197        accepts,
198        accepts_query,
199        allowed_effects,
200        edge,
201    )| {
202        registry.sync_policy(
203            registration,
204            accepts,
205            ZonePolicy {
206                accepts_query,
207                allowed_effects,
208                edge,
209            },
210        );
211    }));
212
213    let acceptable = move || -> bool {
214        let Some(payload) = dnd.payload() else {
215            return false;
216        };
217        let query = super::delivery::drop_query(&dnd, payload.clone(), dnd.proposed_effect());
218        query.proposed_effect != crate::core::DropEffect::None
219            && accepts.is_none_or(|callback| callback.call(payload.clone()))
220            && accepts_query.is_none_or(|callback| callback.call(query.clone()))
221            && allowed_effects.negotiate(query.proposed_effect).is_some()
222    };
223    let is_over = move || match joined {
224        Some(joined) => joined.is_over(zone_id),
225        None => dnd.over() == Some(zone_id),
226    };
227    // Live closest-edge readout while an acceptable pointer drag hovers.
228    // Guards run cheapest-first, and the pointer signal is only read (so
229    // this zone only re-renders per pointer move) once actually hovered
230    // with the prop set.
231    let live_edge = move || -> Option<&'static str> {
232        let set = edge?;
233        if !is_over() || dnd.mode() != DragMode::Pointer || !acceptable() {
234            return None;
235        }
236        let r = registry.cached_rect(zone_id)?;
237        let pointer = joined
238            .and_then(|joined| joined.local_pointer())
239            .unwrap_or_else(|| dnd.pointer());
240        Some(edge_of(pointer, r, set).as_str())
241    };
242    let mut attributes = attributes;
243    super::protect_attributes(
244        &mut attributes,
245        &["data-active", "data-over", "data-edge", "onmounted"],
246    );
247
248    let content = if provide_parent {
249        rsx! {
250            FixedParentZoneBoundary { zone_id, {children} }
251        }
252    } else {
253        children
254    };
255
256    rsx! {
257        div {
258            "data-active": if dnd.dragging() && acceptable() { "true" },
259            "data-over": if is_over() && acceptable() { "true" },
260            "data-edge": live_edge(),
261            onmounted: move |evt: Event<MountedData>| {
262                let m: Rc<MountedData> = evt.data();
263                let mut registry = registry;
264                registry.set_mounted(registration, m.clone());
265                // Measure immediately, not just at drag start: a zone that
266                // mounts mid-drag (a virtualized list recycling rows under
267                // the pointer) missed the pickup measurement, and the last
268                // scroll ping ran before this row rendered. Hit-testing
269                // must see the zone as soon as it exists.
270                spawn(async move {
271                    if let Ok(r) = m.get_client_rect().await {
272                        registry.set_rect_if_present(registration, Rect::new(
273                            r.origin.x,
274                            r.origin.y,
275                            r.size.width,
276                            r.size.height,
277                        ));
278                    }
279                });
280            },
281            ..attributes,
282            {content}
283        }
284    }
285}
286
287/// Internal flat target: registered like a `DropZone`, but it deliberately
288/// does not become the hierarchical parent of the zones rendered inside it.
289#[component]
290pub(crate) fn FlatDropZone<T: Clone + PartialEq + 'static>(
291    zone_id: ZoneId,
292    #[props(default)] label: Option<String>,
293    #[props(default)] accepts_query: Option<Callback<DropQuery<T>, bool>>,
294    #[props(default)] edge: Option<EdgeSet>,
295    on_drop: EventHandler<DropOutcome<T>>,
296    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
297    children: Element,
298) -> Element {
299    let parent = use_parent_zone();
300    rsx! {
301        for (keyed_zone_id, keyed_parent) in [(zone_id, parent)] {
302            DropZoneInstance::<T> {
303                key: "{keyed_zone_id.0}:{keyed_parent:?}",
304                zone_id: keyed_zone_id,
305                parent: keyed_parent,
306                provide_parent: false,
307                label: label.clone(),
308                accepts: None,
309                accepts_query,
310                allowed_effects: DropEffects::default(),
311                edge,
312                on_drop,
313                attributes: attributes.clone(),
314                {children.clone()}
315            }
316        }
317    }
318}
319
320/// A drop target registered in two payload worlds at once - the bridge
321/// between two coexisting providers (`DndProvider<A>` and `DndProvider<B>`).
322///
323/// Zone ids are process-global while registries are per-type, so one element
324/// can hold the *same* `ZoneId` in both registries. The element fans its
325/// mounted handle and each measurement into both provider-owned geometry
326/// records. Each world's machinery - hit-testing, `accepts` filtering,
327/// keyboard navigation - then finds the zone independently, and every drop
328/// arrives through its own typed callback: an `A` drag can only reach
329/// `on_drop_a`, a `B` drag only `on_drop_b`. No downcasts, no shared erased
330/// channel.
331///
332/// Reach for this only when two providers genuinely coexist (say, tickets
333/// and teammates as separate features). If one drag world merely carries
334/// several shapes, make the payload an enum and use a plain [`DropZone`].
335/// For more than two worlds, generate a component for your exact type list
336/// with [`crate::bridge_drop_zone!`] - or go lower-level and call
337/// [`use_bridge_world`] once per world yourself.
338///
339/// Styling hooks match `DropZone`: `data-active="true"` while an acceptable
340/// drag from *either* world is in flight, `data-over="true"` while one
341/// hovers this zone.
342#[component]
343pub fn BridgeDropZone<A: Clone + PartialEq + 'static, B: Clone + PartialEq + 'static>(
344    /// Stable identity for this zone, valid in both worlds. Auto-generated
345    /// if omitted.
346    #[props(default)]
347    id: Option<ZoneId>,
348    /// Human label for screen-reader announcements, used by both worlds.
349    #[props(default)]
350    label: Option<String>,
351    /// Return `false` to reject a payload from the first world.
352    #[props(default)]
353    accepts_a: Option<Callback<A, bool>>,
354    /// Return `false` to reject a payload from the second world.
355    #[props(default)]
356    accepts_b: Option<Callback<B, bool>>,
357    /// Fired when a drag from the first world drops here.
358    on_drop_a: EventHandler<DropOutcome<A>>,
359    /// Fired when a drag from the second world drops here.
360    on_drop_b: EventHandler<DropOutcome<B>>,
361    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
362    children: Element,
363) -> Element {
364    let auto_id = use_zone_id();
365    let zone_id = id.unwrap_or(auto_id);
366    let parent = use_parent_zone();
367    rsx! {
368        for (keyed_zone_id, keyed_parent) in [(zone_id, parent)] {
369            BridgeDropZoneInstance::<A, B> {
370                key: "{keyed_zone_id.0}:{keyed_parent:?}",
371                zone_id: keyed_zone_id,
372                parent: keyed_parent,
373                label: label.clone(),
374                accepts_a,
375                accepts_b,
376                on_drop_a,
377                on_drop_b,
378                attributes: attributes.clone(),
379                {children.clone()}
380            }
381        }
382    }
383}
384
385#[component]
386fn BridgeDropZoneInstance<A: Clone + PartialEq + 'static, B: Clone + PartialEq + 'static>(
387    zone_id: ZoneId,
388    parent: Option<ZoneId>,
389    label: Option<String>,
390    accepts_a: Option<Callback<A, bool>>,
391    accepts_b: Option<Callback<B, bool>>,
392    on_drop_a: EventHandler<DropOutcome<A>>,
393    on_drop_b: EventHandler<DropOutcome<B>>,
394    attributes: Vec<Attribute>,
395    children: Element,
396) -> Element {
397    // One unambiguous parent id that resolves in both registries, so nested
398    // zones of either type ascend correctly.
399    provide_context(ParentZone(zone_id));
400    let geometry = use_hook(BridgeGeometry::default);
401    // One `use_bridge_world` per world: same id and element, independent
402    // provider-owned geometry, each drop through its own typed callback.
403    let a = use_bridge_world::<A>(
404        zone_id,
405        parent,
406        label.clone(),
407        accepts_a,
408        on_drop_a,
409        geometry.clone(),
410    );
411    let b = use_bridge_world::<B>(
412        zone_id,
413        parent,
414        label,
415        accepts_b,
416        on_drop_b,
417        geometry.clone(),
418    );
419    let mut attributes = attributes;
420    super::protect_attributes(&mut attributes, &["data-active", "data-over", "onmounted"]);
421
422    rsx! {
423        div {
424            "data-active": if a.active || b.active { "true" },
425            "data-over": if a.over || b.over { "true" },
426            onmounted: move |evt: Event<MountedData>| {
427                let m: Rc<MountedData> = evt.data();
428                geometry.set_mounted(&m);
429                // Same as DropZone: measure at mount so a bridge appearing
430                // mid-drag is immediately hit-testable in both worlds. One
431                // DOM read fans out into both provider-owned registries.
432                let geometry = geometry.clone();
433                spawn(async move {
434                    if let Ok(r) = m.get_client_rect().await {
435                        let rect = Rect::new(
436                            r.origin.x,
437                            r.origin.y,
438                            r.size.width,
439                            r.size.height,
440                        );
441                        geometry.set_rect_if_present(rect);
442                    }
443                });
444            },
445            ..attributes,
446            {children}
447        }
448    }
449}
450
451/// Generate a bridge drop-zone component for **any number** of coexisting
452/// payload worlds - [`BridgeDropZone`]'s recipe, packaged for N > 2 without
453/// `dyn Any` (Rust has no variadic generics, so the component is generated
454/// per concrete type list rather than parameterized over one).
455///
456/// Each `(Type, accepts_prop, on_drop_prop)` row becomes one world: an
457/// optional `accepts_prop: Callback<Type, bool>` filter and a required
458/// `on_drop_prop: EventHandler<DropOutcome<Type>>`. The generated component
459/// also takes the shared `id`/`label` props, forwards extra attributes to
460/// its div, and carries the same styling hooks as [`DropZone`]
461/// (`data-active` / `data-over`, lit by whichever world's drag qualifies).
462///
463/// Requires `use dioxus::prelude::*;` in scope, and an ancestor
464/// `DndProvider` for every listed type. Before reaching for three worlds,
465/// consider whether one provider with an enum payload reads better.
466///
467/// ```text
468/// use dioxus::prelude::*;
469/// use dioxus_dnd::prelude::*;
470///
471/// dioxus_dnd::bridge_drop_zone!(pub StandupZone {
472///     (Ticket, accepts_ticket, on_drop_ticket),
473///     (Person, accepts_person, on_drop_person),
474///     (Alert, accepts_alert, on_drop_alert),
475/// });
476///
477/// rsx! {
478///     StandupZone {
479///         label: "agenda",
480///         accepts_ticket: move |t: Ticket| !t.done,
481///         on_drop_ticket: move |o: DropOutcome<Ticket>| { /* … */ },
482///         on_drop_person: move |o: DropOutcome<Person>| { /* … */ },
483///         on_drop_alert: move |o: DropOutcome<Alert>| { /* … */ },
484///         "standup agenda"
485///     }
486/// }
487/// ```
488#[macro_export]
489macro_rules! bridge_drop_zone {
490    (
491        $(#[$meta:meta])*
492        $vis:vis $name:ident {
493            $( ($ty:ty, $accepts:ident, $on_drop:ident) ),+ $(,)?
494        }
495    ) => {
496        $(#[$meta])*
497        #[::dioxus::prelude::component]
498        #[allow(non_snake_case)]
499        $vis fn $name(
500            /// Stable identity for this zone, valid in every world.
501            /// Auto-generated if omitted.
502            #[props(default)]
503            id: ::std::option::Option<$crate::core::ZoneId>,
504            /// Human label for screen-reader announcements, used by every
505            /// world.
506            #[props(default)]
507            label: ::std::option::Option<::std::string::String>,
508            $(
509                #[props(default)]
510                $accepts: ::std::option::Option<::dioxus::prelude::Callback<$ty, bool>>,
511                $on_drop: ::dioxus::prelude::EventHandler<$crate::core::DropOutcome<$ty>>,
512            )+
513            #[props(extends = div, extends = GlobalAttributes)]
514            attributes: ::std::vec::Vec<::dioxus::prelude::Attribute>,
515            children: ::dioxus::prelude::Element,
516        ) -> ::dioxus::prelude::Element {
517            use ::dioxus::prelude::*;
518
519            let auto_id = $crate::core::use_zone_id();
520            let zone_id = id.unwrap_or(auto_id);
521            let parent = $crate::core::use_parent_zone();
522            let geometry = use_hook($crate::core::BridgeGeometry::default);
523            let mut attributes = attributes;
524            attributes.retain(|attribute| {
525                !matches!(attribute.name, "data-active" | "data-over" | "onmounted")
526            });
527            let mut active = false;
528            let mut over = false;
529            $(
530                let world = $crate::core::use_bridge_world::<$ty>(
531                    zone_id,
532                    parent,
533                    label.clone(),
534                    $accepts,
535                    $on_drop,
536                    geometry.clone(),
537                );
538                active |= world.active;
539                over |= world.over;
540            )+
541
542            rsx! {
543                $crate::core::BridgeParentZoneBoundary {
544                    key: "{zone_id.0}",
545                    zone_id,
546                    div {
547                        "data-active": if active { "true" },
548                        "data-over": if over { "true" },
549                        onmounted: move |evt: Event<::dioxus::html::MountedData>| {
550                            let m = evt.data();
551                            geometry.set_mounted(&m);
552                            // Same as DropZone: measure at mount so a bridge
553                            // appearing mid-drag is immediately hit-testable in
554                            // every world. One DOM read fans out into every
555                            // provider-owned registry.
556                            let geometry = geometry.clone();
557                            spawn(async move {
558                                if let Ok(r) = m.get_client_rect().await {
559                                    let rect = $crate::core::Rect::new(
560                                        r.origin.x,
561                                        r.origin.y,
562                                        r.size.width,
563                                        r.size.height,
564                                    );
565                                    geometry.set_rect_if_present(rect);
566                                }
567                            });
568                        },
569                        ..attributes,
570                        {children}
571                    }
572                }
573            }
574        }
575    };
576}
577
578#[cfg(test)]
579mod tests {
580    use std::cell::Cell;
581    use std::rc::Rc;
582
583    use super::*;
584    use crate::core::{DndProvider, DragMode, DropEffect, Point};
585
586    #[component]
587    fn ProposedEffectProbe() -> Element {
588        let mut dnd = use_dnd::<u8>();
589        use_hook(move || {
590            dnd.start(
591                7,
592                None,
593                Point::new(10.0, 10.0),
594                Point::default(),
595                DropEffect::Move,
596                DragMode::Pointer,
597            );
598            dnd.set_proposed_effect(DropEffect::Copy);
599        });
600        rsx! {
601            DropZone::<u8> {
602                allowed_effects: DropEffects::COPY,
603                accepts_query: move |query: DropQuery<u8>| {
604                    query.proposed_effect == DropEffect::Copy
605                },
606                on_drop: move |_| {},
607                "copy target"
608            }
609        }
610    }
611
612    fn proposed_effect_app() -> Element {
613        rsx! {
614            DndProvider::<u8> { ProposedEffectProbe {} }
615        }
616    }
617
618    #[test]
619    fn active_state_uses_the_live_proposed_effect() {
620        let mut dom = VirtualDom::new(proposed_effect_app);
621        dom.rebuild_in_place();
622        let html = dioxus_ssr::render(&dom);
623        assert!(
624            html.contains(r#"data-active="true""#),
625            "copy target stayed dark: {html}"
626        );
627    }
628
629    #[derive(Clone, Props)]
630    struct DynamicIdProps {
631        phase: Rc<Cell<bool>>,
632    }
633
634    impl PartialEq for DynamicIdProps {
635        fn eq(&self, other: &Self) -> bool {
636            Rc::ptr_eq(&self.phase, &other.phase)
637        }
638    }
639
640    fn dynamic_id_app(props: DynamicIdProps) -> Element {
641        let id = if props.phase.get() {
642            ZoneId(2)
643        } else {
644            ZoneId(1)
645        };
646        rsx! {
647            DndProvider::<u8> {
648                DropZone::<u8> {
649                    id,
650                    on_drop: move |_| {},
651                    DynamicIdProbe { expected: id }
652                }
653            }
654        }
655    }
656
657    #[component]
658    fn DynamicIdProbe(expected: ZoneId) -> Element {
659        let registry = use_zone_registry::<u8>();
660        let stale = if expected == ZoneId(1) {
661            ZoneId(2)
662        } else {
663            ZoneId(1)
664        };
665        assert!(registry.contains(expected));
666        assert!(!registry.contains(stale));
667        rsx! { div {} }
668    }
669
670    #[test]
671    fn changing_an_explicit_id_replaces_the_registered_instance() {
672        let phase = Rc::new(Cell::new(false));
673        let mut dom = VirtualDom::new_with_props(
674            dynamic_id_app,
675            DynamicIdProps {
676                phase: phase.clone(),
677            },
678        );
679        dom.rebuild_in_place();
680
681        phase.set(true);
682        dom.mark_dirty(ScopeId::APP);
683        dom.render_immediate(&mut dioxus::core::NoOpMutations);
684    }
685}