Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions packages/yew/src/html/component/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ impl ComponentState {
fn commit_render(&mut self, shared_state: &Shared<Option<ComponentState>>, 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 {
Expand All @@ -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::<BaseSuspense>()
.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")]
Expand Down Expand Up @@ -701,6 +717,47 @@ mod feat_csr {
pub first_render: bool,
}

/// A `rendered` lifecycle deferred by the ancestor `<Suspense>` 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<Option<ComponentState>>,
pub first_render: bool,
}

impl PendingRendered {
pub(crate) fn new(state: Shared<Option<ComponentState>>, 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,
Expand Down Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions packages/yew/src/html/component/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
63 changes: 61 additions & 2 deletions packages/yew/src/suspense/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -35,11 +40,29 @@ mod feat_csr_ssr {
Resume(Suspension),
}

#[derive(Debug)]
pub(crate) struct BaseSuspense {
suspensions: Vec<Suspension>,
#[cfg(feature = "hydration")]
hydration_handle: Option<SuspensionHandle>,
/// 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<Vec<(usize, PendingRendered)>>,
}

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 {
Expand Down Expand Up @@ -73,6 +96,8 @@ mod feat_csr_ssr {
suspensions,
#[cfg(feature = "hydration")]
hydration_handle,
#[cfg(feature = "csr")]
pending_rendered: RefCell::new(Vec::new()),
}
}

Expand Down Expand Up @@ -128,13 +153,25 @@ mod feat_csr_ssr {
}
}

#[cfg(feature = "hydration")]
fn rendered(&mut self, _ctx: &Context<Self>, 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);
}
}
}
}

Expand All @@ -146,6 +183,28 @@ mod feat_csr_ssr {
pub(crate) fn resume(scope: &Scope<Self>, 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<Self>,
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.
Expand Down
166 changes: 166 additions & 0 deletions packages/yew/tests/suspense.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefCell<Option<bool>>>,
}

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! {
<div id="result">
<div id="foo"></div>
</div>
})
}

#[derive(Properties, Clone)]
struct AppProps {
dom_observed: Rc<RefCell<Option<bool>>>,
}

impl PartialEq for AppProps {
fn eq(&self, _other: &Self) -> bool {
true
}
}

#[component(App)]
fn app(props: &AppProps) -> Html {
html! {
<Suspense fallback={html! {<div>{"loading"}</div>}}>
<Content dom_observed={props.dom_observed.clone()} />
</Suspense>
}
}

let dom_observed: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));

yew::Renderer::<App>::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 <Suspense> 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<RefCell<Vec<(&'static str, bool)>>>,
}

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! { <div id={id}></div> })
}

#[derive(Properties, Clone)]
struct AppProps {
observed: Rc<RefCell<Vec<(&'static str, bool)>>>,
}

impl PartialEq for AppProps {
fn eq(&self, _other: &Self) -> bool {
true
}
}

#[component(App)]
fn app(props: &AppProps) -> Html {
html! {
<Suspense fallback={html! { <div>{"loading"}</div> }}>
<Child id="sib-a" observed={props.observed.clone()} />
<Child id="sib-b" observed={props.observed.clone()} />
</Suspense>
}
}

let observed: Rc<RefCell<Vec<(&'static str, bool)>>> = Rc::new(RefCell::new(Vec::new()));
yew::Renderer::<App>::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
);
}
}
Loading