diff --git a/packages/yew/src/html/component/lifecycle.rs b/packages/yew/src/html/component/lifecycle.rs index 655f1a2a15b..4452cbbc57f 100644 --- a/packages/yew/src/html/component/lifecycle.rs +++ b/packages/yew/src/html/component/lifecycle.rs @@ -487,6 +487,8 @@ impl ComponentState { fn commit_render(&mut self, shared_state: &Shared>, new_vdom: Html) { // Currently not suspended, we remove any previous suspension and update // normally. + #[cfg(feature = "csr")] + let resuming_from_suspension = self.suspension.is_some(); self.resume_existing_suspension(); match self.render_state { @@ -508,14 +510,28 @@ impl ComponentState { let first_render = !self.has_rendered; self.has_rendered = true; - scheduler::push_component_rendered( - self.comp_id, - Box::new(RenderedRunner { - state: shared_state.clone(), + if resuming_from_suspension { + // The DOM we just reconciled still lives in the ancestor + // Suspense's detached parent. The Suspense must process + // the Resume message and re-render to shift children into + // the live tree before our effects observe the DOM. + // Hand the pending `rendered` to the Suspense; it will + // re-schedule it once it fully un-suspends. + let pending = PendingRendered::new(shared_state.clone(), first_render); + let suspense_scope = scope + .find_parent_scope::() + .expect("a resuming component must have a Suspense ancestor"); + BaseSuspense::defer_rendered(&suspense_scope, self.comp_id, pending); + } else { + scheduler::push_component_rendered( + self.comp_id, + Box::new(RenderedRunner { + state: shared_state.clone(), + first_render, + }), first_render, - }), - first_render, - ); + ); + } } #[cfg(feature = "hydration")] @@ -701,6 +717,47 @@ mod feat_csr { pub first_render: bool, } + /// A `rendered` lifecycle deferred by the ancestor `` so it fires + /// only after Suspense un-suspends and children's DOM has been shifted into + /// the live tree. See `BaseSuspense::defer_rendered`. + pub(crate) struct PendingRendered { + pub state: Shared>, + pub first_render: bool, + } + + impl PendingRendered { + pub(crate) fn new(state: Shared>, first_render: bool) -> Self { + Self { + state, + first_render, + } + } + + /// Absorb a later-committed pending rendered for the same component: + /// keep the latest state but preserve `first_render=true` if either + /// side carried it. + pub(crate) fn absorb(&mut self, later: Self) { + self.state = later.state; + self.first_render |= later.first_render; + } + + /// Push this onto the scheduler's `rendered` queue. + pub(crate) fn schedule(self, comp_id: usize) { + let PendingRendered { + state, + first_render, + } = self; + scheduler::push_component_rendered( + comp_id, + Box::new(RenderedRunner { + state, + first_render, + }), + false, + ); + } + } + impl ComponentState { #[tracing::instrument( level = tracing::Level::DEBUG, @@ -741,7 +798,7 @@ mod feat_csr { } #[cfg(feature = "csr")] -pub(super) use feat_csr::*; +pub(crate) use feat_csr::*; #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] #[cfg(test)] diff --git a/packages/yew/src/html/component/mod.rs b/packages/yew/src/html/component/mod.rs index a1585c4ec73..57bebce7cc9 100644 --- a/packages/yew/src/html/component/mod.rs +++ b/packages/yew/src/html/component/mod.rs @@ -10,6 +10,8 @@ mod scope; use std::rc::Rc; pub use children::*; +#[cfg(feature = "csr")] +pub(crate) use lifecycle::PendingRendered; pub use marker::*; pub use properties::*; #[cfg(feature = "csr")] diff --git a/packages/yew/src/suspense/component.rs b/packages/yew/src/suspense/component.rs index 324be4ec7a8..f2ba12134d5 100644 --- a/packages/yew/src/suspense/component.rs +++ b/packages/yew/src/suspense/component.rs @@ -14,7 +14,12 @@ pub struct SuspenseProps { #[cfg(any(feature = "csr", feature = "ssr"))] mod feat_csr_ssr { + #[cfg(feature = "csr")] + use std::cell::RefCell; + use super::*; + #[cfg(feature = "csr")] + use crate::html::PendingRendered; use crate::html::{Component, Context, Html, Scope}; use crate::suspense::Suspension; #[cfg(feature = "hydration")] @@ -35,11 +40,29 @@ mod feat_csr_ssr { Resume(Suspension), } - #[derive(Debug)] pub(crate) struct BaseSuspense { suspensions: Vec, #[cfg(feature = "hydration")] hydration_handle: Option, + /// Rendered runners for child components that resumed while this + /// Suspense was still suspended (because of other pending siblings). + /// Drained in `rendered` once the Suspense fully un-suspends, so + /// effects fire only after children's DOM has been shifted into the + /// live tree. + /// + /// A small `Vec` is used over a map; the expected population is the + /// number of suspending direct descendants resumed in one boundary + /// transition, typically just a handful. + #[cfg(feature = "csr")] + pending_rendered: RefCell>, + } + + impl std::fmt::Debug for BaseSuspense { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BaseSuspense") + .field("suspensions", &self.suspensions) + .finish() + } } impl Component for BaseSuspense { @@ -73,6 +96,8 @@ mod feat_csr_ssr { suspensions, #[cfg(feature = "hydration")] hydration_handle, + #[cfg(feature = "csr")] + pending_rendered: RefCell::new(Vec::new()), } } @@ -128,13 +153,25 @@ mod feat_csr_ssr { } } - #[cfg(feature = "hydration")] fn rendered(&mut self, _ctx: &Context, first_render: bool) { + #[cfg(not(feature = "hydration"))] + let _ = first_render; + #[cfg(feature = "hydration")] if first_render { if let Some(m) = self.hydration_handle.take() { m.resume(); } } + // Fire deferred rendered callbacks for children that resumed while + // we were still suspended. Only safe now that we're un-suspended: + // the last reconcile shifted their DOM into the live tree. + #[cfg(feature = "csr")] + if self.suspensions.is_empty() { + let pending = std::mem::take(&mut *self.pending_rendered.borrow_mut()); + for (comp_id, p) in pending { + p.schedule(comp_id); + } + } } } @@ -146,6 +183,28 @@ mod feat_csr_ssr { pub(crate) fn resume(scope: &Scope, s: Suspension) { scope.send_message(BaseSuspenseMsg::Resume(s)); } + + /// Queue a child component's `rendered` lifecycle to be scheduled once + /// this Suspense fully un-suspends and its reconcile has shifted the + /// child's DOM into the live tree. If the child already has a pending + /// entry (e.g. it re-committed between suspensions), the two are + /// merged so `first_render=true` is not lost. + #[cfg(feature = "csr")] + pub(crate) fn defer_rendered( + scope: &Scope, + comp_id: usize, + pending: PendingRendered, + ) { + let Some(comp) = scope.get_component() else { + return; + }; + let mut q = comp.pending_rendered.borrow_mut(); + if let Some(slot) = q.iter_mut().find(|(id, _)| *id == comp_id) { + slot.1.absorb(pending); + } else { + q.push((comp_id, pending)); + } + } } /// Suspend rendering and show a fallback UI until the underlying task completes. diff --git a/packages/yew/tests/suspense.rs b/packages/yew/tests/suspense.rs index f75b909b6f1..2c80b549e3c 100644 --- a/packages/yew/tests/suspense.rs +++ b/packages/yew/tests/suspense.rs @@ -816,3 +816,169 @@ async fn test_duplicate_suspension() { let result = obtain_result(); assert_eq!(result.as_str(), "hello!"); } + +// Regression test for https://github.com/yewstack/yew/issues/3780 +// use_future causes use_effect to fire before DOM is updated, so +// document.get_element_by_id returns None for elements the component renders. +#[wasm_bindgen_test] +async fn use_effect_can_access_dom_after_use_future_resolves() { + #[derive(Properties, Clone)] + struct ContentProps { + dom_observed: Rc>>, + } + + impl PartialEq for ContentProps { + fn eq(&self, _other: &Self) -> bool { + true + } + } + + #[component(Content)] + fn content(props: &ContentProps) -> HtmlResult { + use_future(|| async { + sleep(Duration::ZERO).await; + })?; + + { + let dom_observed = props.dom_observed.clone(); + use_effect_with((), move |_| { + let element = gloo::utils::document().get_element_by_id("foo"); + *dom_observed.borrow_mut() = Some(element.is_some()); + || {} + }); + } + + Ok(html! { +
+
+
+ }) + } + + #[derive(Properties, Clone)] + struct AppProps { + dom_observed: Rc>>, + } + + impl PartialEq for AppProps { + fn eq(&self, _other: &Self) -> bool { + true + } + } + + #[component(App)] + fn app(props: &AppProps) -> Html { + html! { + {"loading"}}}> + + + } + } + + let dom_observed: Rc>> = Rc::new(RefCell::new(None)); + + yew::Renderer::::with_root_and_props( + gloo::utils::document().get_element_by_id("output").unwrap(), + AppProps { + dom_observed: dom_observed.clone(), + }, + ) + .render(); + + // After everything settles (suspension resolves, component renders, effects run), + // use_effect should have found the #foo element in the DOM. + // + // Bug (issue #3780): use_effect fires before the DOM is updated when use_future + // is involved, so get_element_by_id("foo") returns None and dom_observed is Some(false). + // Expected: use_effect fires after the DOM is committed, so dom_observed should be + // Some(true). + sleep(Duration::from_millis(50)).await; + assert_eq!( + *dom_observed.borrow(), + Some(true), + "use_effect should see the rendered DOM element after use_future resolves" + ); +} + +// Companion to #3780: when multiple siblings under the same each +// suspend independently and resume at different times, every child's effect +// must see the DOM in the live tree, not the detached parent where Suspense +// parks children while at least one of them is still pending. +#[wasm_bindgen_test] +async fn sibling_suspensions_effects_see_dom() { + #[derive(Properties, Clone)] + struct ChildProps { + id: &'static str, + observed: Rc>>, + } + + impl PartialEq for ChildProps { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } + } + + #[component(Child)] + fn child(props: &ChildProps) -> HtmlResult { + use_future(|| async { + sleep(Duration::ZERO).await; + })?; + + let id = props.id; + let observed = props.observed.clone(); + use_effect_with((), move |_| { + let found = gloo::utils::document().get_element_by_id(id).is_some(); + observed.borrow_mut().push((id, found)); + || {} + }); + + Ok(html! {
}) + } + + #[derive(Properties, Clone)] + struct AppProps { + observed: Rc>>, + } + + impl PartialEq for AppProps { + fn eq(&self, _other: &Self) -> bool { + true + } + } + + #[component(App)] + fn app(props: &AppProps) -> Html { + html! { + {"loading"} }}> + + + + } + } + + let observed: Rc>> = Rc::new(RefCell::new(Vec::new())); + yew::Renderer::::with_root_and_props( + gloo::utils::document().get_element_by_id("output").unwrap(), + AppProps { + observed: observed.clone(), + }, + ) + .render(); + + sleep(Duration::from_millis(100)).await; + + let observed = observed.borrow(); + assert_eq!( + observed.len(), + 2, + "both sibling effects should fire exactly once: {:?}", + *observed + ); + for (id, found) in observed.iter() { + assert!( + *found, + "effect for {} did not see DOM in live tree: {:?}", + id, *observed + ); + } +}