fix: do not show Transition fallback on 2nd change if 1st change resolved synchronously (closes #4492, closes #3868) (#4495)

This commit is contained in:
Greg Johnston
2025-12-19 10:42:44 -05:00
committed by GitHub
parent 8f5c34de8a
commit 65940cbefa
13 changed files with 280 additions and 29 deletions

View File

@@ -17,7 +17,7 @@ leptos_router = { path = "../../router" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.39", features = [ "rt-multi-thread", "macros", "time" ], optional = true } tokio = { version = "1.39", features = [ "rt-multi-thread", "macros", "time" ], optional = true }
wasm-bindgen = "0.2.92" wasm-bindgen = "0.2.106"
[features] [features]
hydrate = [ hydrate = [

View File

@@ -0,0 +1,38 @@
@check_issue_4492
Feature: Regression test for issue #4492
Scenario: Scenario A should show Loading once on first load.
Given I see the app
And I can access regression test 4492
When I click the button a-toggle
Then I see a-result has the text Loading...
When I wait 100ms
Then I see a-result has the text 0
When I click the button a-button
Then I see a-result has the text 0
When I wait 100ms
Then I see a-result has the text 1
Scenario: Scenario B should never show Loading
Given I see the app
And I can access regression test 4492
When I click the button b-toggle
Then I see b-result has the text 0
When I click the button b-button
Then I see b-result has the text 0
When I wait 100ms
Then I see b-result has the text 1
When I click the button b-button
Then I see b-result has the text 1
When I wait 100ms
Then I see b-result has the text 2
Scenario: Scenario C should never show Loading
Given I see the app
And I can access regression test 4492
When I click the button c-toggle
Then I see c-result has the text 0
When I click the button c-button
Then I see c-result has the text 42
When I wait 100ms
Then I see c-result has the text 1

View File

@@ -15,3 +15,9 @@ pub async fn click_link(client: &Client, text: &str) -> Result<()> {
link.click().await?; link.click().await?;
Ok(()) Ok(())
} }
pub async fn click_button(client: &Client, id: &str) -> Result<()> {
let btn = find::element_by_id(&client, &id).await?;
btn.click().await?;
Ok(())
}

View File

@@ -7,7 +7,15 @@ pub async fn result_text_is(
client: &Client, client: &Client,
expected_text: &str, expected_text: &str,
) -> Result<()> { ) -> Result<()> {
let actual = find::text_at_id(client, "result").await?; element_text_is(client, "result", expected_text).await
}
pub async fn element_text_is(
client: &Client,
id: &str,
expected_text: &str,
) -> Result<()> {
let actual = find::text_at_id(client, id).await?;
assert_eq!(&actual, expected_text); assert_eq!(&actual, expected_text);
Ok(()) Ok(())
} }

View File

@@ -20,6 +20,14 @@ async fn i_select_the_link(world: &mut AppWorld, text: String) -> Result<()> {
Ok(()) Ok(())
} }
#[when(regex = "^I click the button (.*)$")]
async fn i_click_the_button(world: &mut AppWorld, id: String) -> Result<()> {
let client = &world.client;
action::click_button(client, &id).await?;
Ok(())
}
#[given(expr = "I select the following links")] #[given(expr = "I select the following links")]
#[when(expr = "I select the following links")] #[when(expr = "I select the following links")]
async fn i_select_the_following_links( async fn i_select_the_following_links(
@@ -54,3 +62,10 @@ async fn i_go_back(world: &mut AppWorld) -> Result<()> {
Ok(()) Ok(())
} }
#[when(regex = r"^I wait (\d+)ms$")]
async fn i_wait_ms(_world: &mut AppWorld, ms: u64) -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
Ok(())
}

View File

@@ -19,6 +19,17 @@ async fn i_see_the_result_is_the_string(
Ok(()) Ok(())
} }
#[then(regex = r"^I see ([\w-]+) has the text (.*)$")]
async fn i_see_element_has_text(
world: &mut AppWorld,
id: String,
text: String,
) -> Result<()> {
let client = &world.client;
check::element_text_is(client, &id, &text).await?;
Ok(())
}
#[then(regex = r"^I see the navbar$")] #[then(regex = r"^I see the navbar$")]
async fn i_see_the_navbar(world: &mut AppWorld) -> Result<()> { async fn i_see_the_navbar(world: &mut AppWorld) -> Result<()> {
let client = &world.client; let client = &world.client;

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
issue_4005::Routes4005, issue_4088::Routes4088, issue_4217::Routes4217, issue_4005::Routes4005, issue_4088::Routes4088, issue_4217::Routes4217,
issue_4285::Routes4285, issue_4296::Routes4296, issue_4324::Routes4324, issue_4285::Routes4285, issue_4296::Routes4296, issue_4324::Routes4324,
pr_4015::Routes4015, pr_4091::Routes4091, issue_4492::Routes4492, pr_4015::Routes4015, pr_4091::Routes4091,
}; };
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::{MetaTags, *}; use leptos_meta::{MetaTags, *};
@@ -48,6 +48,7 @@ pub fn App() -> impl IntoView {
<Routes4285/> <Routes4285/>
<Routes4296/> <Routes4296/>
<Routes4324/> <Routes4324/>
<Routes4492/>
</Routes> </Routes>
</main> </main>
</Router> </Router>
@@ -75,6 +76,7 @@ fn HomePage() -> impl IntoView {
<li><a href="/4285/">"4285"</a></li> <li><a href="/4285/">"4285"</a></li>
<li><a href="/4296/">"4296"</a></li> <li><a href="/4296/">"4296"</a></li>
<li><a href="/4324/">"4324"</a></li> <li><a href="/4324/">"4324"</a></li>
<li><a href="/4492/">"4492"</a></li>
</ul> </ul>
</nav> </nav>
} }

View File

@@ -0,0 +1,114 @@
use leptos::prelude::*;
#[allow(unused_imports)]
use leptos_router::{
components::Route, path, MatchNestedRoutes, NavigateOptions,
};
#[component]
pub fn Routes4492() -> impl MatchNestedRoutes + Clone {
view! {
<Route path=path!("4492") view=Issue4492/>
}
.into_inner()
}
#[component]
fn Issue4492() -> impl IntoView {
let show_a = RwSignal::new(false);
let show_b = RwSignal::new(false);
let show_c = RwSignal::new(false);
view! {
<button id="a-toggle" on:click=move |_| show_a.set(!show_a.get())>"Toggle A"</button>
<button id="b-toggle" on:click=move |_| show_b.set(!show_b.get())>"Toggle B"</button>
<button id="c-toggle" on:click=move |_| show_c.set(!show_c.get())>"Toggle C"</button>
<Show when=move || show_a.get()>
<ScenarioA/>
</Show>
<Show when=move || show_b.get()>
<ScenarioB/>
</Show>
<Show when=move || show_c.get()>
<ScenarioC/>
</Show>
}
}
#[component]
fn ScenarioA() -> impl IntoView {
// scenario A: one truly-async resource is read on click
let counter = RwSignal::new(0);
let resource = Resource::new(
move || counter.get(),
|count| async move {
sleep(50).await.unwrap();
count
},
);
view! {
<Transition fallback=|| view! { <p id="a-result">"Loading..."</p> }>
<p id="a-result">{resource}</p>
</Transition>
<button id="a-button" on:click=move |_| *counter.write() += 1>"+1"</button>
}
}
#[component]
fn ScenarioB() -> impl IntoView {
// scenario B: resource immediately available first time, then after 250ms
let counter = RwSignal::new(0);
let resource = Resource::new(
move || counter.get(),
|count| async move {
if count == 0 {
count
} else {
sleep(50).await.unwrap();
count
}
},
);
view! {
<Transition fallback=|| view! { <p id="b-result">"Loading..."</p> }>
<p id="b-result">{resource}</p>
</Transition>
<button id="b-button" on:click=move |_| *counter.write() += 1>"+1"</button>
}
}
#[component]
fn ScenarioC() -> impl IntoView {
// scenario C: not even a resource on the first run, just a value
// see https://github.com/leptos-rs/leptos/issues/3868
let counter = RwSignal::new(0);
let s_res = StoredValue::new(None::<ArcLocalResource<i32>>);
let resource = move || {
let count = counter.get();
if count == 0 {
count
} else {
let r = s_res.get_value().unwrap_or_else(|| {
let res = ArcLocalResource::new(move || async move {
sleep(50).await.unwrap();
count
});
s_res.set_value(Some(res.clone()));
res
});
r.get().unwrap_or(42)
}
};
view! {
<Transition fallback=|| view! { <p id="c-result">"Loading..."</p> }>
<p id="c-result">{resource}</p>
</Transition>
<button id="c-button" on:click=move |_| *counter.write() += 1>"+1"</button>
}
}
#[server]
async fn sleep(ms: u64) -> Result<(), ServerFnError> {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
Ok(())
}

View File

@@ -5,6 +5,7 @@ mod issue_4217;
mod issue_4285; mod issue_4285;
mod issue_4296; mod issue_4296;
mod issue_4324; mod issue_4324;
mod issue_4492;
mod pr_4015; mod pr_4015;
mod pr_4091; mod pr_4091;

View File

@@ -15,7 +15,10 @@ use reactive_graph::{
effect::RenderEffect, effect::RenderEffect,
owner::{provide_context, use_context, Owner}, owner::{provide_context, use_context, Owner},
signal::ArcRwSignal, signal::ArcRwSignal,
traits::{Dispose, Get, Read, ReadUntracked, Track, With, WriteValue}, traits::{
Dispose, Get, Read, ReadUntracked, Track, With, WithUntracked,
WriteValue,
},
}; };
use slotmap::{DefaultKey, SlotMap}; use slotmap::{DefaultKey, SlotMap};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -119,14 +122,19 @@ where
provide_context(SuspenseContext { provide_context(SuspenseContext {
tasks: tasks.clone(), tasks: tasks.clone(),
}); });
let none_pending = ArcMemo::new(move |prev: Option<&bool>| { let none_pending = ArcMemo::new({
let tasks = tasks.clone();
move |prev: Option<&bool>| {
tasks.track(); tasks.track();
if prev.is_none() && starts_local { if prev.is_none() && starts_local {
false false
} else { } else {
tasks.with(SlotMap::is_empty) tasks.with(SlotMap::is_empty)
} }
}
}); });
let has_tasks =
Arc::new(move || !tasks.with_untracked(SlotMap::is_empty));
OwnedView::new(SuspenseBoundary::<false, _, _> { OwnedView::new(SuspenseBoundary::<false, _, _> {
id, id,
@@ -134,6 +142,7 @@ where
fallback, fallback,
children, children,
error_boundary_parent, error_boundary_parent,
has_tasks,
}) })
}) })
} }
@@ -156,6 +165,7 @@ pub(crate) struct SuspenseBoundary<const TRANSITION: bool, Fal, Chil> {
pub fallback: Fal, pub fallback: Fal,
pub children: Chil, pub children: Chil,
pub error_boundary_parent: Option<ErrorBoundarySuspendedChildren>, pub error_boundary_parent: Option<ErrorBoundarySuspendedChildren>,
pub has_tasks: Arc<dyn Fn() -> bool + Send + Sync>,
} }
impl<const TRANSITION: bool, Fal, Chil> Render impl<const TRANSITION: bool, Fal, Chil> Render
@@ -192,12 +202,26 @@ where
outer_owner.clone(), outer_owner.clone(),
); );
if let Some(mut state) = prev { let state = if let Some(mut state) = prev {
this.rebuild(&mut state); this.rebuild(&mut state);
state state
} else { } else {
this.build() this.build()
};
if nth_run == 1 && !(self.has_tasks)() {
// if this is the first run, and there are no pending resources at this point,
// it means that there were no actually-async resources read while rendering the children
// this means that we're effectively on the settled second run: none_pending
// won't change false => true and cause this to rerender (and therefore increment nth_run)
//
// we increment it manually here so that future resource changes won't cause the transition fallback
// to be displayed for the first time
// see https://github.com/leptos-rs/leptos/issues/3868, https://github.com/leptos-rs/leptos/issues/4492
nth_run += 1;
} }
state
}) })
} }
@@ -235,6 +259,7 @@ where
fallback, fallback,
children, children,
error_boundary_parent, error_boundary_parent,
has_tasks,
} = self; } = self;
SuspenseBoundary { SuspenseBoundary {
id, id,
@@ -242,6 +267,7 @@ where
fallback, fallback,
children: children.add_any_attr(attr), children: children.add_any_attr(attr),
error_boundary_parent, error_boundary_parent,
has_tasks,
} }
} }
} }

View File

@@ -10,10 +10,11 @@ use reactive_graph::{
effect::Effect, effect::Effect,
owner::{provide_context, use_context, Owner}, owner::{provide_context, use_context, Owner},
signal::ArcRwSignal, signal::ArcRwSignal,
traits::{Get, Set, Track, With}, traits::{Get, Set, Track, With, WithUntracked},
wrappers::write::SignalSetter, wrappers::write::SignalSetter,
}; };
use slotmap::{DefaultKey, SlotMap}; use slotmap::{DefaultKey, SlotMap};
use std::sync::Arc;
use tachys::reactive_graph::OwnedView; use tachys::reactive_graph::OwnedView;
/// If any [`Resource`](crate::prelude::Resource) is read in the `children` of this /// If any [`Resource`](crate::prelude::Resource) is read in the `children` of this
@@ -104,14 +105,19 @@ where
provide_context(SuspenseContext { provide_context(SuspenseContext {
tasks: tasks.clone(), tasks: tasks.clone(),
}); });
let none_pending = ArcMemo::new(move |prev: Option<&bool>| { let none_pending = ArcMemo::new({
let tasks = tasks.clone();
move |prev: Option<&bool>| {
tasks.track(); tasks.track();
if prev.is_none() && starts_local { if prev.is_none() && starts_local {
false false
} else { } else {
tasks.with(SlotMap::is_empty) tasks.with(SlotMap::is_empty)
} }
}
}); });
let has_tasks =
Arc::new(move || !tasks.with_untracked(SlotMap::is_empty));
if let Some(set_pending) = set_pending { if let Some(set_pending) = set_pending {
Effect::new_isomorphic({ Effect::new_isomorphic({
let none_pending = none_pending.clone(); let none_pending = none_pending.clone();
@@ -127,6 +133,7 @@ where
fallback, fallback,
children, children,
error_boundary_parent, error_boundary_parent,
has_tasks,
}) })
}) })
} }

View File

@@ -15,7 +15,7 @@ use codee::{
Decoder, Encoder, Decoder, Encoder,
}; };
use core::{fmt::Debug, marker::PhantomData}; use core::{fmt::Debug, marker::PhantomData};
use futures::Future; use futures::{Future, FutureExt};
use or_poisoned::OrPoisoned; use or_poisoned::OrPoisoned;
use reactive_graph::{ use reactive_graph::{
computed::{ computed::{
@@ -258,11 +258,17 @@ where
if let Some(suspense_context) = use_context::<SuspenseContext>() { if let Some(suspense_context) = use_context::<SuspenseContext>() {
if self.value.read().or_poisoned().is_none() { if self.value.read().or_poisoned().is_none() {
let handle = suspense_context.task_id(); let handle = suspense_context.task_id();
let ready = SpecialNonReactiveFuture::new(self.ready()); let mut ready =
Box::pin(SpecialNonReactiveFuture::new(self.ready()));
match ready.as_mut().now_or_never() {
Some(_) => drop(handle),
None => {
reactive_graph::spawn(async move { reactive_graph::spawn(async move {
ready.await; ready.await;
drop(handle); drop(handle);
}); });
}
}
self.suspenses.write().or_poisoned().push(suspense_context); self.suspenses.write().or_poisoned().push(suspense_context);
} }
} }

View File

@@ -632,12 +632,29 @@ impl<T: 'static> ReadUntracked for ArcAsyncDerived<T> {
fn try_read_untracked(&self) -> Option<Self::Value> { fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(suspense_context) = use_context::<SuspenseContext>() { if let Some(suspense_context) = use_context::<SuspenseContext>() {
// create a handle to register it with suspense
let handle = suspense_context.task_id(); let handle = suspense_context.task_id();
let ready = SpecialNonReactiveFuture::new(self.ready());
// check if the task is *already* ready
let mut ready =
Box::pin(SpecialNonReactiveFuture::new(self.ready()));
match ready.as_mut().now_or_never() {
Some(_) => {
// if it's already ready, drop the handle immediately
// this will immediately notify the suspense context that it's complete
drop(handle);
}
None => {
// otherwise, spawn a task to wait for it to be ready, then drop the handle,
// which will notify the suspense
crate::spawn(async move { crate::spawn(async move {
ready.await; ready.await;
drop(handle); drop(handle);
}); });
}
}
// register the suspense context with our list of them, to be notified later if this re-runs
self.inner self.inner
.write() .write()
.or_poisoned() .or_poisoned()