Merge pull request #4235 from leptos-rs/4217

Special-case `value` property to support quirky `<select>` behavior
This commit is contained in:
Greg Johnston
2025-08-25 09:05:29 -04:00
committed by GitHub
13 changed files with 195 additions and 33 deletions

View File

@@ -0,0 +1,7 @@
@check_issue_4005
Feature: Check that issue 4005 does not reappear
Scenario: The second item is selected.
Given I see the app
And I can access regression test 4005
Then I see the value of select is 2

View File

@@ -0,0 +1,9 @@
@check_issue_4217
Feature: Check that issue 4217 does not reappear
Scenario: All items are selected.
Given I see the app
And I can access regression test 4217
Then I see option1 is selected
And I see option2 is selected
And I see option3 is selected

View File

@@ -18,3 +18,28 @@ pub async fn element_exists(client: &Client, id: &str) -> Result<()> {
.expect(&format!("could not find element with id `{id}`"));
Ok(())
}
pub async fn select_option_is_selected(
client: &Client,
id: &str,
) -> Result<()> {
let el = find::element_by_id(client, id)
.await
.expect(&format!("could not find element with id `{id}`"));
let selected = el.prop("selected").await?;
assert_eq!(selected.as_deref(), Some("true"));
Ok(())
}
pub async fn element_value_is(
client: &Client,
id: &str,
expected: &str,
) -> Result<()> {
let el = find::element_by_id(client, id)
.await
.expect(&format!("could not find element with id `{id}`"));
let value = el.prop("value").await?;
assert_eq!(value.as_deref(), Some(expected));
Ok(())
}

View File

@@ -25,3 +25,21 @@ async fn i_see_the_navbar(world: &mut AppWorld) -> Result<()> {
check::element_exists(client, "nav").await?;
Ok(())
}
#[then(regex = r"^I see ([\d\w]+) is selected$")]
async fn i_see_the_select(world: &mut AppWorld, id: String) -> Result<()> {
let client = &world.client;
check::select_option_is_selected(client, &id).await?;
Ok(())
}
#[then(regex = r"^I see the value of (\w+) is (.*)$")]
async fn i_see_the_value(
world: &mut AppWorld,
id: String,
value: String,
) -> Result<()> {
let client = &world.client;
check::element_value_is(client, &id, &value).await?;
Ok(())
}

View File

@@ -1,4 +1,7 @@
use crate::{issue_4088::Routes4088, pr_4015::Routes4015, pr_4091::Routes4091};
use crate::{
issue_4005::Routes4005, issue_4088::Routes4088, issue_4217::Routes4217,
pr_4015::Routes4015, pr_4091::Routes4091,
};
use leptos::prelude::*;
use leptos_meta::{MetaTags, *};
use leptos_router::{
@@ -37,6 +40,8 @@ pub fn App() -> impl IntoView {
<Routes4091/>
<Routes4015/>
<Routes4088/>
<Routes4217/>
<Routes4005/>
</Routes>
</main>
</Router>
@@ -59,6 +64,8 @@ fn HomePage() -> impl IntoView {
<li><a href="/4091/">"4091"</a></li>
<li><a href="/4015/">"4015"</a></li>
<li><a href="/4088/">"4088"</a></li>
<li><a href="/4217/">"4217"</a></li>
<li><a href="/4005/">"4005"</a></li>
</ul>
</nav>
}

View File

@@ -0,0 +1,24 @@
use leptos::prelude::*;
#[allow(unused_imports)]
use leptos_router::{
components::Route, path, MatchNestedRoutes, NavigateOptions,
};
#[component]
pub fn Routes4005() -> impl MatchNestedRoutes + Clone {
view! {
<Route path=path!("4005") view=Issue4005/>
}
.into_inner()
}
#[component]
fn Issue4005() -> impl IntoView {
view! {
<select id="select" prop:value="2">
<option value="1">"Option 1"</option>
<option value="2">"Option 2"</option>
<option value="3">"Option 3"</option>
</select>
}
}

View File

@@ -0,0 +1,24 @@
use leptos::prelude::*;
#[allow(unused_imports)]
use leptos_router::{
components::Route, path, MatchNestedRoutes, NavigateOptions,
};
#[component]
pub fn Routes4217() -> impl MatchNestedRoutes + Clone {
view! {
<Route path=path!("4217") view=Issue4217/>
}
.into_inner()
}
#[component]
fn Issue4217() -> impl IntoView {
view! {
<select multiple=true>
<option id="option1" value="1" selected>"Option 1"</option>
<option id="option2" value="2" selected>"Option 2"</option>
<option id="option3" value="3" selected>"Option 3"</option>
</select>
}
}

View File

@@ -1,5 +1,7 @@
pub mod app;
mod issue_4005;
mod issue_4088;
mod issue_4217;
mod pr_4015;
mod pr_4091;

View File

@@ -258,15 +258,7 @@ pub fn request_idle_callback_with_handle(
///
/// <div class="warning">The task is called outside of the ownership tree, this means that if you want to access for example the context you need to reestablish the owner.</div>
pub fn queue_microtask(task: impl FnOnce() + 'static) {
use js_sys::{Function, Reflect};
let task = Closure::once_into_js(task);
let window = web_sys::window().expect("window not available");
let queue_microtask =
Reflect::get(&window, &JsValue::from_str("queueMicrotask"))
.expect("queueMicrotask not available");
let queue_microtask = queue_microtask.unchecked_into::<Function>();
_ = queue_microtask.call1(&JsValue::UNDEFINED, &task);
tachys::renderer::dom::queue_microtask(task);
}
/// Handle that is generated by [set_timeout_with_handle] and can be used to clear the timeout.

View File

@@ -329,6 +329,8 @@ where
fn build(self) -> Self::State {
let el = Rndr::create_element(self.tag.tag(), E::NAMESPACE);
let attrs = self.attributes.build(&el);
let children = if E::SELF_CLOSING {
None
} else {
@@ -337,8 +339,6 @@ where
Some(children)
};
let attrs = self.attributes.build(&el);
ElementState {
el,
attrs,

View File

@@ -202,7 +202,7 @@ macro_rules! prop_type {
key: &str,
) -> Self::State {
let value = self.into();
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
@@ -212,14 +212,14 @@ macro_rules! prop_type {
key: &str,
) -> Self::State {
let value = self.into();
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = self.into();
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}
@@ -245,7 +245,7 @@ macro_rules! prop_type {
let was_some = self.is_some();
let value = self.into();
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -258,7 +258,7 @@ macro_rules! prop_type {
let was_some = self.is_some();
let value = self.into();
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -266,7 +266,7 @@ macro_rules! prop_type {
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = self.into();
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}
@@ -294,7 +294,7 @@ macro_rules! prop_type_str {
key: &str,
) -> Self::State {
let value = JsValue::from(&*self);
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
@@ -304,14 +304,14 @@ macro_rules! prop_type_str {
key: &str,
) -> Self::State {
let value = JsValue::from(&*self);
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = JsValue::from(&*self);
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}
@@ -339,7 +339,7 @@ macro_rules! prop_type_str {
let was_some = self.is_some();
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -352,7 +352,7 @@ macro_rules! prop_type_str {
let was_some = self.is_some();
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -360,7 +360,7 @@ macro_rules! prop_type_str {
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}
@@ -392,7 +392,7 @@ impl IntoProperty for Arc<str> {
key: &str,
) -> Self::State {
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
@@ -402,14 +402,14 @@ impl IntoProperty for Arc<str> {
key: &str,
) -> Self::State {
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}
@@ -435,7 +435,7 @@ impl IntoProperty for Option<Arc<str>> {
let was_some = self.is_some();
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -448,7 +448,7 @@ impl IntoProperty for Option<Arc<str>> {
let was_some = self.is_some();
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
if was_some {
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
}
(el.clone(), value)
}
@@ -456,7 +456,7 @@ impl IntoProperty for Option<Arc<str>> {
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = JsValue::from(self.map(|n| JsValue::from_str(&n)));
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}

View File

@@ -261,7 +261,7 @@ impl IntoProperty for Oco<'static, str> {
key: &str,
) -> Self::State {
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
@@ -271,14 +271,14 @@ impl IntoProperty for Oco<'static, str> {
key: &str,
) -> Self::State {
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
(el.clone(), value)
}
fn rebuild(self, state: &mut Self::State, key: &str) {
let (el, prev) = state;
let value = JsValue::from_str(self.as_ref());
Rndr::set_property(el, key, &value);
Rndr::set_property_or_value(el, key, &value);
*prev = value;
}

View File

@@ -36,6 +36,46 @@ pub type ClassList = web_sys::DomTokenList;
pub type CssStyleDeclaration = web_sys::CssStyleDeclaration;
pub type TemplateElement = web_sys::HtmlTemplateElement;
/// A microtask is a short function which will run after the current task has
/// completed its work and when there is no other code waiting to be run before
/// control of the execution context is returned to the browser's event loop.
///
/// Microtasks are especially useful for libraries and frameworks that need
/// to perform final cleanup or other just-before-rendering tasks.
///
/// [MDN queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask)
pub fn queue_microtask(task: impl FnOnce() + 'static) {
use js_sys::{Function, Reflect};
let task = Closure::once_into_js(task);
let window = window();
let queue_microtask =
Reflect::get(&window, &JsValue::from_str("queueMicrotask"))
.expect("queueMicrotask not available");
let queue_microtask = queue_microtask.unchecked_into::<Function>();
_ = queue_microtask.call1(&JsValue::UNDEFINED, &task);
}
fn queue(fun: Box<dyn FnOnce()>) {
use std::cell::{Cell, RefCell};
thread_local! {
static PENDING: Cell<bool> = const { Cell::new(false) };
static QUEUE: RefCell<Vec<Box<dyn FnOnce()>>> = RefCell::new(Vec::new());
}
QUEUE.with_borrow_mut(|q| q.push(fun));
if !PENDING.replace(true) {
queue_microtask(|| {
let tasks = QUEUE.take();
for task in tasks {
task();
}
PENDING.set(false);
})
}
}
impl Dom {
pub fn intern(text: &str) -> &str {
intern(text)
@@ -211,6 +251,20 @@ impl Dom {
}
}
pub fn set_property_or_value(el: &Element, key: &str, value: &JsValue) {
if key == "value" {
queue(Box::new({
let el = el.clone();
let value = value.clone();
move || {
Self::set_property(&el, "value", &value);
}
}))
} else {
Self::set_property(el, key, value);
}
}
pub fn set_property(el: &Element, key: &str, value: &JsValue) {
or_debug!(
js_sys::Reflect::set(