Skip to content

Commit ee1b3eb

Browse files
committed
Added optional cleanup support to onmounted
1 parent da72b63 commit ee1b3eb

File tree

5 files changed

+311
-71
lines changed

5 files changed

+311
-71
lines changed

packages/html/src/events/mounted.rs

Lines changed: 207 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -207,76 +207,219 @@ impl MountedData {
207207
}
208208
}
209209

210+
use std::cell::RefCell;
211+
210212
use dioxus_core::Event;
211213

212214
use crate::geometry::{PixelsRect, PixelsSize, PixelsVector2D};
215+
use crate::PlatformEventData;
213216

214217
pub type MountedEvent = Event<MountedData>;
215218

216-
impl_event! [
217-
MountedData;
218-
219-
#[doc(alias = "ref")]
220-
#[doc(alias = "createRef")]
221-
#[doc(alias = "useRef")]
222-
/// The onmounted event is fired when the element is first added to the DOM. This event gives you a [`MountedData`] object and lets you interact with the raw DOM element.
223-
///
224-
/// This event is fired once per element. If you need to access the element multiple times, you can store the [`MountedData`] object in a [`use_signal`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_signal.html) hook and use it as needed.
225-
///
226-
/// # Examples
227-
///
228-
/// ```rust, no_run
229-
/// # use dioxus::prelude::*;
230-
/// fn App() -> Element {
231-
/// let mut header_element = use_signal(|| None);
232-
///
233-
/// rsx! {
234-
/// div {
235-
/// h1 {
236-
/// // The onmounted event will run the first time the h1 element is mounted
237-
/// onmounted: move |element| header_element.set(Some(element.data())),
238-
/// "Scroll to top example"
239-
/// }
240-
///
241-
/// for i in 0..100 {
242-
/// div { "Item {i}" }
243-
/// }
244-
///
245-
/// button {
246-
/// // When you click the button, if the header element has been mounted, we scroll to that element
247-
/// onclick: move |_| async move {
248-
/// if let Some(header) = header_element.cloned() {
249-
/// let _ = header.scroll_to(ScrollBehavior::Smooth).await;
250-
/// }
251-
/// },
252-
/// "Scroll to top"
253-
/// }
254-
/// }
255-
/// }
256-
/// }
257-
/// ```
258-
///
259-
/// The `MountedData` struct contains cross platform APIs that work on the desktop, mobile, liveview and web platforms. For the web platform, you can also downcast the `MountedData` event to the `web-sys::Element` type for more web specific APIs:
260-
///
261-
/// ```rust, ignore
262-
/// use dioxus::prelude::*;
263-
/// use dioxus_web::WebEventExt; // provides [`as_web_event()`] method
264-
///
265-
/// fn App() -> Element {
266-
/// rsx! {
267-
/// div {
268-
/// id: "some-id",
269-
/// onmounted: move |element| {
270-
/// // You can use the web_event trait to downcast the element to a web specific event. For the mounted event, this will be a web_sys::Element
271-
/// let web_sys_element = element.as_web_event();
272-
/// assert_eq!(web_sys_element.id(), "some-id");
273-
/// }
274-
/// }
275-
/// }
276-
/// }
277-
/// ```
278-
onmounted
279-
];
219+
// ============================================================================
220+
// Cleanup support for onmounted
221+
// ============================================================================
222+
223+
/// A cleanup function to run when the element is unmounted.
224+
pub type MountedCleanup = Box<dyn FnOnce() + 'static>;
225+
226+
thread_local! {
227+
/// Storage for cleanup closures returned by onmounted handlers.
228+
/// After firing a mounted event, the renderer should call `take_mounted_cleanup()`
229+
/// to retrieve any cleanup closure that was registered.
230+
static MOUNTED_CLEANUP: RefCell<Option<MountedCleanup>> = const { RefCell::new(None) };
231+
}
232+
233+
/// Retrieve and clear any pending cleanup from the last mounted event.
234+
///
235+
/// Renderers should call this after firing a mounted event to capture
236+
/// any cleanup closure that was returned by the handler.
237+
pub fn take_mounted_cleanup() -> Option<MountedCleanup> {
238+
MOUNTED_CLEANUP.with(|c| c.borrow_mut().take())
239+
}
240+
241+
/// Trait to allow onmounted handlers to optionally return a cleanup closure.
242+
///
243+
/// This enables the pattern:
244+
/// ```rust,ignore
245+
/// onmounted: move |e| {
246+
/// start_animation(e.data());
247+
/// move || stop_animation(e.data()) // cleanup returned
248+
/// }
249+
/// ```
250+
///
251+
/// Handlers can return:
252+
/// - `()` - no cleanup
253+
/// - Any `FnOnce()` closure - cleanup to run on unmount
254+
/// - `async {}` block - spawned as task, no cleanup support
255+
pub trait SpawnIfAsyncWithCleanup<Marker>: Sized {
256+
/// Process the return value, storing any cleanup closure and spawning async blocks.
257+
fn spawn_with_cleanup(self);
258+
}
259+
260+
// No cleanup - handler returns ()
261+
impl SpawnIfAsyncWithCleanup<()> for () {
262+
fn spawn_with_cleanup(self) {
263+
// No cleanup needed
264+
}
265+
}
266+
267+
/// Marker for cleanup closures
268+
#[doc(hidden)]
269+
pub struct CleanupMarker;
270+
271+
// Handler returns a cleanup closure
272+
impl<F: FnOnce() + 'static> SpawnIfAsyncWithCleanup<CleanupMarker> for F {
273+
fn spawn_with_cleanup(self) {
274+
MOUNTED_CLEANUP.with(|c| *c.borrow_mut() = Some(Box::new(self)));
275+
}
276+
}
277+
278+
/// Marker for async handlers (no cleanup support for async yet)
279+
#[doc(hidden)]
280+
pub struct AsyncMountedMarker;
281+
282+
impl<F: std::future::Future<Output = ()> + 'static> SpawnIfAsyncWithCleanup<AsyncMountedMarker>
283+
for F
284+
{
285+
fn spawn_with_cleanup(self) {
286+
// Spawn the async block but no cleanup support
287+
use futures_util::FutureExt;
288+
let mut fut = Box::pin(self);
289+
let res = fut.as_mut().now_or_never();
290+
291+
if res.is_none() {
292+
dioxus_core::spawn(async move {
293+
fut.await;
294+
});
295+
}
296+
}
297+
}
298+
299+
// ============================================================================
300+
// onmounted event handler
301+
// ============================================================================
302+
303+
#[doc(alias = "ref")]
304+
#[doc(alias = "createRef")]
305+
#[doc(alias = "useRef")]
306+
/// The onmounted event is fired when the element is first added to the DOM. This event gives you a [`MountedData`] object and lets you interact with the raw DOM element.
307+
///
308+
/// This event is fired once per element. If you need to access the element multiple times, you can store the [`MountedData`] object in a [`use_signal`](https://docs.rs/dioxus-hooks/latest/dioxus_hooks/fn.use_signal.html) hook and use it as needed.
309+
///
310+
/// You can optionally return a cleanup closure that will be called when the element is removed from the DOM:
311+
///
312+
/// # Examples
313+
///
314+
/// ## Basic usage (no cleanup)
315+
/// ```rust, no_run
316+
/// # use dioxus::prelude::*;
317+
/// fn App() -> Element {
318+
/// let mut header_element = use_signal(|| None);
319+
///
320+
/// rsx! {
321+
/// div {
322+
/// h1 {
323+
/// // The onmounted event will run the first time the h1 element is mounted
324+
/// onmounted: move |element| header_element.set(Some(element.data())),
325+
/// "Scroll to top example"
326+
/// }
327+
///
328+
/// for i in 0..100 {
329+
/// div { "Item {i}" }
330+
/// }
331+
///
332+
/// button {
333+
/// // When you click the button, if the header element has been mounted, we scroll to that element
334+
/// onclick: move |_| async move {
335+
/// if let Some(header) = header_element.cloned() {
336+
/// let _ = header.scroll_to(ScrollBehavior::Smooth).await;
337+
/// }
338+
/// },
339+
/// "Scroll to top"
340+
/// }
341+
/// }
342+
/// }
343+
/// }
344+
/// ```
345+
///
346+
/// ## With cleanup closure
347+
/// ```rust, no_run
348+
/// # use dioxus::prelude::*;
349+
/// fn App() -> Element {
350+
/// let mut cleanup_called = use_signal(|| false);
351+
///
352+
/// rsx! {
353+
/// div {
354+
/// onmounted: move |_| {
355+
/// // Return a cleanup closure that runs when the element is removed
356+
/// move || {
357+
/// cleanup_called.set(true);
358+
/// }
359+
/// },
360+
/// "Element with cleanup"
361+
/// }
362+
/// }
363+
/// }
364+
/// ```
365+
///
366+
/// The `MountedData` struct contains cross platform APIs that work on the desktop, mobile, liveview and web platforms. For the web platform, you can also downcast the `MountedData` event to the `web-sys::Element` type for more web specific APIs:
367+
///
368+
/// ```rust, ignore
369+
/// use dioxus::prelude::*;
370+
/// use dioxus_web::WebEventExt; // provides [`as_web_event()`] method
371+
///
372+
/// fn App() -> Element {
373+
/// rsx! {
374+
/// div {
375+
/// id: "some-id",
376+
/// onmounted: move |element| {
377+
/// // You can use the web_event trait to downcast the element to a web specific event. For the mounted event, this will be a web_sys::Element
378+
/// let web_sys_element = element.as_web_event();
379+
/// assert_eq!(web_sys_element.id(), "some-id");
380+
/// }
381+
/// }
382+
/// }
383+
/// }
384+
/// ```
385+
#[inline]
386+
pub fn onmounted<__Marker>(
387+
f: impl ::dioxus_core::SuperInto<::dioxus_core::ListenerCallback<MountedData>, __Marker>,
388+
) -> ::dioxus_core::Attribute {
389+
let event_handler = f.super_into();
390+
::dioxus_core::Attribute::new(
391+
"onmounted", // Core strips "on" prefix when matching
392+
::dioxus_core::AttributeValue::listener(move |e: ::dioxus_core::Event<PlatformEventData>| {
393+
let event: ::dioxus_core::Event<MountedData> = e.map(|data| {
394+
data.into()
395+
});
396+
event_handler.call(event.into_any());
397+
}),
398+
None,
399+
false,
400+
)
401+
}
402+
403+
#[doc(hidden)]
404+
pub mod onmounted {
405+
use super::*;
406+
407+
/// Called by RSX macro when explicit closure is detected.
408+
/// Uses SpawnIfAsyncWithCleanup to handle cleanup return values.
409+
pub fn call_with_explicit_closure<
410+
__Marker,
411+
Return: SpawnIfAsyncWithCleanup<__Marker> + 'static,
412+
>(
413+
mut handler: impl FnMut(::dioxus_core::Event<MountedData>) -> Return + 'static,
414+
) -> ::dioxus_core::Attribute {
415+
// Wrap the handler to process the return value for cleanup
416+
super::onmounted(move |event: ::dioxus_core::Event<MountedData>| {
417+
let result = handler(event);
418+
// Process the result - this handles (), FnOnce cleanup closures, and async futures
419+
result.spawn_with_cleanup();
420+
})
421+
}
422+
}
280423

281424
pub use onmounted as onmount;
282425

packages/playwright-tests/fullstack-mounted.spec.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ test("hydration", async ({ page }) => {
55
await page.goto("http://localhost:7777");
66

77
// Expect the page to contain the pending text.
8-
const main = page.locator("#main");
9-
await expect(main).toContainText("The mounted event was triggered.");
8+
const mountedTest = page.locator("#mounted-test");
9+
await expect(mountedTest).toContainText("The mounted event was triggered.");
10+
});
11+
12+
test("cleanup closure runs when element is removed", async ({ page }) => {
13+
await page.goto("http://localhost:7777");
14+
15+
// Wait for hydration to complete - the mounted event should have fired
16+
const mountedTest = page.locator("#mounted-test");
17+
await expect(mountedTest).toContainText("The mounted event was triggered.");
18+
19+
// Element with cleanup should be visible initially
20+
const cleanupElement = page.locator("#cleanup-test-element");
21+
await expect(cleanupElement).toBeVisible();
22+
23+
// Cleanup indicator should not be visible yet
24+
const cleanupTriggered = page.locator("#cleanup-triggered");
25+
await expect(cleanupTriggered).not.toBeVisible();
26+
27+
// Click button to remove the element
28+
const toggleButton = page.locator("#toggle-cleanup-element");
29+
await toggleButton.click();
30+
31+
// Element should be removed
32+
await expect(cleanupElement).not.toBeVisible();
33+
34+
// Cleanup should have been called
35+
await expect(cleanupTriggered).toBeVisible();
36+
await expect(cleanupTriggered).toContainText("Cleanup was called.");
1037
});

packages/playwright-tests/fullstack-mounted/src/main.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,51 @@ fn App() -> Element {
1212

1313
rsx! {
1414
div {
15-
onmounted: move |_| {
16-
mounted.set(true);
15+
id: "main",
16+
div {
17+
id: "mounted-test",
18+
onmounted: move |_| {
19+
mounted.set(true);
20+
},
21+
if mounted() {
22+
"The mounted event was triggered."
23+
}
24+
}
25+
26+
CleanupTest {}
27+
}
28+
}
29+
}
30+
31+
#[component]
32+
fn CleanupTest() -> Element {
33+
let mut cleanup_triggered = use_signal(|| false);
34+
let mut show_cleanup_element = use_signal(|| true);
35+
36+
rsx! {
37+
// Cleanup test section
38+
div {
39+
id: "cleanup-status",
40+
if cleanup_triggered() {
41+
span { id: "cleanup-triggered", "Cleanup was called." }
42+
}
43+
}
44+
45+
button {
46+
id: "toggle-cleanup-element",
47+
onclick: move |_| {
48+
show_cleanup_element.set(!show_cleanup_element());
1749
},
18-
if mounted() {
19-
"The mounted event was triggered."
50+
"Toggle Cleanup Element"
51+
}
52+
53+
if show_cleanup_element() {
54+
div {
55+
id: "cleanup-test-element",
56+
onmounted: move |_| {
57+
move || { cleanup_triggered.set(true); }
58+
},
59+
"Element with cleanup"
2060
}
2161
}
2262
}

packages/web/src/dom.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ pub struct WebsysDom {
3030
#[cfg(feature = "mounted")]
3131
pub(crate) queued_mounted_events: Vec<ElementId>,
3232

33+
/// Storage for cleanup closures returned from onmounted handlers.
34+
/// When these elements are removed, we invoke the cleanup closure.
35+
#[cfg(feature = "mounted")]
36+
pub(crate) element_cleanup_closures: FxHashMap<ElementId, Box<dyn FnOnce()>>,
37+
3338
// We originally started with a different `WriteMutations` for collecting templates during hydration.
3439
// When profiling the binary size of web applications, this caused a large increase in binary size
3540
// because diffing code in core is generic over the `WriteMutation` object.
@@ -125,6 +130,8 @@ impl WebsysDom {
125130
runtime,
126131
#[cfg(feature = "mounted")]
127132
queued_mounted_events: Default::default(),
133+
#[cfg(feature = "mounted")]
134+
element_cleanup_closures: Default::default(),
128135
#[cfg(feature = "hydrate")]
129136
skip_mutations: false,
130137
#[cfg(feature = "hydrate")]

0 commit comments

Comments
 (0)