From b3e506d71cae83d68ec3efacb183f73cdb95f818 Mon Sep 17 00:00:00 2001 From: Interchouette <484423+Interchouette@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:32:53 +0200 Subject: [PATCH] fix: treat [checked] as bool like disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prop_str("false") was truthy in the DOM; banana writeback now uses (change) + el.checked → Value::Bool. Dogfood in two-way demo. Closes #85 Co-authored-by: Cursor --- crates/rangular-aot/src/glue.rs | 9 ++- crates/rangular-aot/src/lower.rs | 23 +++++- crates/rangular-aot/tests/smoke.rs | 59 ++++++++++++++ crates/rangular-parser/src/banana.rs | 8 +- crates/rangular-parser/tests/edge_cases.rs | 1 + crates/rangular-runtime/src/render.rs | 6 +- crates/rangular-runtime/tests/parity.rs | 79 +++++++++++++++++-- demo-leptos/README.md | 2 +- .../src/components/two_way/two_way.html | 10 +++ demo-leptos/src/components/two_way/two_way.rs | 27 +++++-- .../src/components/two_way/two_way.scss | 22 ++++++ demo-leptos/style/components.generated.css | 13 +++ docs/CHANGELOG.md | 9 +++ docs/SPEC.md | 12 ++- .../fixtures/components/two-way/two-way.html | 10 +++ 15 files changed, 262 insertions(+), 28 deletions(-) diff --git a/crates/rangular-aot/src/glue.rs b/crates/rangular-aot/src/glue.rs index 74c589f..85c6022 100644 --- a/crates/rangular-aot/src/glue.rs +++ b/crates/rangular-aot/src/glue.rs @@ -180,10 +180,11 @@ impl HostCell { loop_scope: LoopScope<'_>, ) { if let Some(path) = rangular_parser::banana_set_target(expr) { - let value = if event_name == "input" { - Value::Str(event_value) - } else { - Value::from(EventPayload::from_dom(event_name, event_value)) + let value = match event_name { + "input" => Value::Str(event_value), + // `[(checked)]` → `(change)` writeback; AOT reads `el.checked`. + "change" => Value::Bool(event_value == "true"), + _ => Value::from(EventPayload::from_dom(event_name, event_value)), }; let _ = self.host.borrow_mut().set(path, value); return; diff --git a/crates/rangular-aot/src/lower.rs b/crates/rangular-aot/src/lower.rs index a1fc20a..4cbfcc9 100644 --- a/crates/rangular-aot/src/lower.rs +++ b/crates/rangular-aot/src/lower.rs @@ -412,7 +412,7 @@ fn lower_one_attr(attr: &Attr, scope: &Scope<'_>, hoist: &mut HoistState) -> Tok Attr::Static { name, value: None, .. } => html_name(name), - Attr::Property { name, expr, .. } if name == "disabled" => { + Attr::Property { name, expr, .. } if name == "disabled" || name == "checked" => { let ex = hoist.hoist_expr(expr); let handler = hoist_host_closure( hoist, @@ -468,6 +468,27 @@ fn event_value_tokens(event_name: &str) -> TokenStream { "click" | "dblclick" | "auxclick" => { quote! { format!("{},{}", ev.client_x(), ev.client_y()) } } + "change" => quote! { + { + use wasm_bindgen::JsCast; + ev.target() + .and_then(|t| t.dyn_into::().ok()) + .map(|el| { + let ty = el.type_(); + if ty == "checkbox" || ty == "radio" { + el.checked().to_string() + } else { + el.value() + } + }) + .or_else(|| { + ev.target() + .and_then(|t| t.dyn_into::().ok()) + .map(|el| el.value()) + }) + .unwrap_or_default() + } + }, _ => quote! { { use wasm_bindgen::JsCast; diff --git a/crates/rangular-aot/tests/smoke.rs b/crates/rangular-aot/tests/smoke.rs index c36f84e..f69312e 100644 --- a/crates/rangular-aot/tests/smoke.rs +++ b/crates/rangular-aot/tests/smoke.rs @@ -71,6 +71,21 @@ fn two_way_emits_leptos_view() { ); } +#[test] +fn checked_prop_emits_eval_bool_and_change_banana() { + let out = compile( + r#""#, + "checkbox_view", + ); + assert!(out.ok(), "{:?}", out.issues); + assert!( + out.code.contains("eval_bool_scoped") && out.code.contains("change"), + "expected bool checked prop + change banana:\n{}", + out.code + ); + parse_file(&out.code).unwrap_or_else(|err| panic!("invalid Rust: {err}")); +} + #[test] fn named_slots_emits_slot_params() { let html = include_str!("../../../tests/fixtures/components/named-slots/named-slots.html"); @@ -138,6 +153,50 @@ fn banana_hostcell_sets_via_dom_event() { assert_eq!(*seed.borrow(), "xyz"); } +#[test] +fn banana_checked_hostcell_sets_bool_via_change() { + use std::cell::RefCell; + use std::rc::Rc; + + use rangular_aot::HostCell; + use rangular_expr::{Expr, Host, Value}; + use rangular_host::HostError; + use rangular_parser::banana_write_expr; + + struct FlagHost { + flag: Rc>, + } + + impl Host for FlagHost { + fn get(&self, name: &str) -> Option { + (name == "flag").then(|| Value::Bool(*self.flag.borrow())) + } + + fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> { + if name == "flag" { + if let Some(b) = value.as_bool() { + *self.flag.borrow_mut() = b; + } + } + Ok(()) + } + + fn call(&mut self, _: &str, _: &[Value]) -> Result { + Ok(Value::Unit) + } + } + + let flag = Rc::new(RefCell::new(false)); + let cell = HostCell::new(FlagHost { + flag: Rc::clone(&flag), + }); + let write = banana_write_expr(&Expr::Ident("flag".into())); + cell.emit_dom_event_call("$bananaSet", &write, "change", "true".into()); + assert!(*flag.borrow()); + cell.emit_dom_event_call("$bananaSet", &write, "change", "false".into()); + assert!(!*flag.borrow()); +} + #[test] fn hostcell_dom_event_passes_dollar_event_to_handler() { use std::cell::RefCell; diff --git a/crates/rangular-parser/src/banana.rs b/crates/rangular-parser/src/banana.rs index cde8a4d..451a621 100644 --- a/crates/rangular-parser/src/banana.rs +++ b/crates/rangular-parser/src/banana.rs @@ -8,10 +8,10 @@ pub const BANANA_SET_CALLEE: &str = "$bananaSet"; /// DOM / Angular-like event for a two-way property. #[must_use] pub fn banana_event_name(prop: &str) -> String { - if prop == "value" { - "input".into() - } else { - format!("{prop}Change") + match prop { + "value" => "input".into(), + "checked" => "change".into(), + _ => format!("{prop}Change"), } } diff --git a/crates/rangular-parser/tests/edge_cases.rs b/crates/rangular-parser/tests/edge_cases.rs index c4119ff..80ccd45 100644 --- a/crates/rangular-parser/tests/edge_cases.rs +++ b/crates/rangular-parser/tests/edge_cases.rs @@ -131,6 +131,7 @@ fn control_flow_blocks_and_errors() { #[test] fn banana_helpers_and_attrs() { assert_eq!(banana_event_name("value"), "input"); + assert_eq!(banana_event_name("checked"), "change"); assert_eq!(banana_event_name("seed"), "seedChange"); let write = banana_write_expr(&Expr::Ident("seed".into())); assert_eq!(banana_set_target(&write), Some("seed")); diff --git a/crates/rangular-runtime/src/render.rs b/crates/rangular-runtime/src/render.rs index dc113cf..823ac9c 100644 --- a/crates/rangular-runtime/src/render.rs +++ b/crates/rangular-runtime/src/render.rs @@ -267,12 +267,12 @@ fn render_attrs(attrs: &[Attr], ctx: &mut Ctx<'_, H>) -> Vec<(String, S } => out.push((name.clone(), String::new())), Attr::Ref { .. } => {} Attr::Property { name, .. } if name == "ngTemplateOutlet" => {} - Attr::Property { name, expr, .. } if name == "disabled" => { - let disabled = match eval_expr(expr, ctx) { + Attr::Property { name, expr, .. } if name == "disabled" || name == "checked" => { + let flag = match eval_expr(expr, ctx) { Value::Bool(b) => b, other => other.is_truthy(), }; - out.push((format!("prop:{name}"), bool_str(disabled))); + out.push((format!("prop:{name}"), bool_str(flag))); } Attr::Property { name, expr, .. } => { out.push((format!("prop:{name}"), display_value(&eval_expr(expr, ctx)))); diff --git a/crates/rangular-runtime/tests/parity.rs b/crates/rangular-runtime/tests/parity.rs index 2c39c9d..7c7c39f 100644 --- a/crates/rangular-runtime/tests/parity.rs +++ b/crates/rangular-runtime/tests/parity.rs @@ -592,18 +592,31 @@ fn pipes_runtime_snapshot() { fn two_way_runtime_snapshot() { struct TwoWayHost { seed: String, + on: bool, } impl Host for TwoWayHost { fn get(&self, name: &str) -> Option { - (name == "seed").then(|| Value::Str(self.seed.clone())) + match name { + "seed" => Some(Value::Str(self.seed.clone())), + "on" => Some(Value::Bool(self.on)), + _ => None, + } } fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> { - if name == "seed" { - if let Some(s) = value.as_str() { - self.seed = s.to_owned(); + match name { + "seed" => { + if let Some(s) = value.as_str() { + self.seed = s.to_owned(); + } + } + "on" => { + if let Some(b) = value.as_bool() { + self.on = b; + } } + _ => {} } Ok(()) } @@ -611,6 +624,7 @@ fn two_way_runtime_snapshot() { fn call(&mut self, name: &str, _: &[Value]) -> Result { if name == "pushFromHost" { self.seed = "host-push".into(); + self.on = true; } Ok(Value::Unit) } @@ -618,20 +632,28 @@ fn two_way_runtime_snapshot() { let src = std::fs::read_to_string(fixture_root().join("components/two-way/two-way.html")).unwrap(); - let mut host = TwoWayHost { seed: "abc".into() }; + let mut host = TwoWayHost { + seed: "abc".into(), + on: false, + }; let out = interpret(&src, "two-way.html", &mut host); assert!(out.ok(), "{:?}", out.issues); let snap = out.snapshot(); assert!(snap.contains(r#"prop:value="abc""#), "{snap}"); assert!(snap.contains(r#"on:input="$bananaSet""#), "{snap}"); + assert!(snap.contains(r#"prop:checked="false""#), "{snap}"); + assert!(snap.contains(r#"on:change="$bananaSet""#), "{snap}"); assert!(snap.contains(r#"on:click="pushFromHost""#), "{snap}"); assert!(snap.contains("Mirror:"), "{snap}"); + assert!(snap.contains("Checked:"), "{snap}"); assert!(snap.contains(">abc<") || snap.contains("abc"), "{snap}"); assert!(compile(&src, "two_way_view").ok()); let ir = binding_ir_snapshot(&binding_ir(&parse(&src, "two-way.html").template)); assert!(ir.contains("prop:value"), "{ir}"); assert!(ir.contains(r#"on:input="$bananaSet""#), "{ir}"); + assert!(ir.contains("prop:checked"), "{ir}"); + assert!(ir.contains(r#"on:change="$bananaSet""#), "{ir}"); let aot_ir = rangular_aot::structural_ir(&src, "two-way.html").expect("aot ir"); let rt_ir = rangular_runtime::structural_ir(&src, "two-way.html").expect("rt ir"); @@ -639,6 +661,53 @@ fn two_way_runtime_snapshot() { host.set("seed", Value::Str("xyz".into())).unwrap(); assert_eq!(host.seed, "xyz"); + host.set("on", Value::Bool(true)).unwrap(); + assert!(host.on); +} + +#[test] +fn checkbox_checked_bool_prop_and_banana_change() { + struct CheckHost { + on: bool, + } + + impl Host for CheckHost { + fn get(&self, key: &str) -> Option { + (key == "on").then_some(Value::Bool(self.on)) + } + + fn set(&mut self, key: &str, value: Value) -> Result<(), HostError> { + if key == "on" { + if let Some(b) = value.as_bool() { + self.on = b; + } + } + Ok(()) + } + + fn call(&mut self, _: &str, _: &[Value]) -> Result { + Ok(Value::Unit) + } + } + + let src = r#""#; + let mut host = CheckHost { on: false }; + let out = interpret(src, "check.html", &mut host); + assert!(out.ok(), "{:?}", out.issues); + let snap = out.snapshot(); + assert!(snap.contains(r#"prop:checked="false""#), "{snap}"); + assert!(snap.contains(r#"on:change="$bananaSet""#), "{snap}"); + + host.on = true; + let snap_on = interpret(src, "check.html", &mut host).snapshot(); + assert!(snap_on.contains(r#"prop:checked="true""#), "{snap_on}"); + + let parsed = parse(src, "check.html"); + assert!(parsed.ok(), "{:?}", parsed.diagnostics); + let ir = binding_ir_snapshot(&binding_ir(&parsed.template)); + assert!(ir.contains("prop:checked"), "{ir}"); + assert!(ir.contains(r#"on:change="$bananaSet""#), "{ir}"); + assert!(compile(src, "checkbox_view").ok()); } #[test] diff --git a/demo-leptos/README.md b/demo-leptos/README.md index 243d364..0e6d488 100644 --- a/demo-leptos/README.md +++ b/demo-leptos/README.md @@ -20,7 +20,7 @@ Each feature panel is its own folder: | `io_child` | `[label]` / `[muted]` in, `(muteToggle)` out | | `io_parent` | AOT heading + live AOT `io_child` (nested tags are not live yet) | | `pipes` | builtins + demo custom `crab` | -| `two_way` | `[(value)]` banana | +| `two_way` | `[(value)]` + `[(checked)]` banana | | `field_required` | Host `required` + dirty error | | `field_validators`| Multi-field Host helpers (`first_error`, length, pattern) | | `event_payload` | typed `$event` / `EventPayload` | diff --git a/demo-leptos/src/components/two_way/two_way.html b/demo-leptos/src/components/two_way/two_way.html index 29d6f92..7b49eb4 100644 --- a/demo-leptos/src/components/two_way/two_way.html +++ b/demo-leptos/src/components/two_way/two_way.html @@ -16,4 +16,14 @@ Push from Host +
+ +

+ Checked: + {{ on }} +

+
diff --git a/demo-leptos/src/components/two_way/two_way.rs b/demo-leptos/src/components/two_way/two_way.rs index 110c26f..eecccc8 100644 --- a/demo-leptos/src/components/two_way/two_way.rs +++ b/demo-leptos/src/components/two_way/two_way.rs @@ -7,6 +7,7 @@ include!(concat!(env!("OUT_DIR"), "/rangular/two_way_view.rs")); #[component] pub fn TwoWayPanel(applied_seed: RwSignal) -> impl IntoView { let seed = RwSignal::new(String::from("abc")); + let on = RwSignal::new(false); Effect::new(move |_| { let value = applied_seed.get(); @@ -16,23 +17,36 @@ pub fn TwoWayPanel(applied_seed: RwSignal) -> impl IntoView { seed.set(value); }); - two_way_view(HostCell::new(TwoWayHost { seed })) + two_way_view(HostCell::new(TwoWayHost { seed, on })) } struct TwoWayHost { seed: RwSignal, + on: RwSignal, } impl Host for TwoWayHost { fn get(&self, name: &str) -> Option { - (name == "seed").then(|| Value::Str(self.seed.get())) + match name { + "seed" => Some(Value::Str(self.seed.get())), + "on" => Some(Value::Bool(self.on.get())), + _ => None, + } } fn set(&mut self, name: &str, value: Value) -> Result<(), HostError> { - if name == "seed" - && let Some(s) = value.as_str() - { - self.seed.set(s.to_owned()); + match name { + "seed" => { + if let Some(s) = value.as_str() { + self.seed.set(s.to_owned()); + } + } + "on" => { + if let Some(b) = value.as_bool() { + self.on.set(b); + } + } + _ => {} } Ok(()) } @@ -40,6 +54,7 @@ impl Host for TwoWayHost { fn call(&mut self, name: &str, _: &[Value]) -> Result { if name == "pushFromHost" { self.seed.set(String::from("host-push")); + self.on.set(true); } Ok(Value::Unit) } diff --git a/demo-leptos/src/components/two_way/two_way.scss b/demo-leptos/src/components/two_way/two_way.scss index a781908..02f27fe 100644 --- a/demo-leptos/src/components/two_way/two_way.scss +++ b/demo-leptos/src/components/two_way/two_way.scss @@ -16,6 +16,28 @@ min-width: 0; } +.two-way__row--check { + grid-template-columns: auto minmax(0, 1fr); + margin-top: 0.65rem; +} + +.two-way__check { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.875rem; + color: var(--app-text, #d1d4dc); + cursor: pointer; + white-space: nowrap; +} + +.two-way__check input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--app-accent, #5b8def); + cursor: pointer; +} + .two-way__label { font-size: 0.875rem; color: var(--app-text-muted, #787b86); diff --git a/demo-leptos/style/components.generated.css b/demo-leptos/style/components.generated.css index a9d7dc5..7a34826 100644 --- a/demo-leptos/style/components.generated.css +++ b/demo-leptos/style/components.generated.css @@ -330,6 +330,19 @@ align-items: center; width: 100%; min-width: 0;} +.two-way__row--check {grid-template-columns: auto minmax(0, 1fr); + margin-top: 0.65rem;} +.two-way__check {display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.875rem; + color: var(--app-text, #d1d4dc); + cursor: pointer; + white-space: nowrap;} +.two-way__check input[type=checkbox] {width: 1rem; + height: 1rem; + accent-color: var(--app-accent, #5b8def); + cursor: pointer;} .two-way__label {font-size: 0.875rem; color: var(--app-text-muted, #787b86); white-space: nowrap;} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8794cf3..95f0fe2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `[checked]` / `[(checked)]` treat the property as a bool (like `[disabled]` via + `eval_bool`), so `false` is not a truthy `prop_str` in the DOM. Banana writeback + uses the `(change)` event and `el.checked` → `Host::set` with `Value::Bool`. + Dogfood: `two-way` fixture + demo-leptos checkbox. + ## [1.0.3] - 2026-09-16 ### Changed diff --git a/docs/SPEC.md b/docs/SPEC.md index 451bdc2..6d491c8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -59,10 +59,14 @@ Static attributes without brackets pass through unchanged. ### Two-way banana `[(…)]` -| Surface syntax | Desugars to | -| ------------------ | ----------------------------------------------------------- | -| `[(value)]="seed"` | `[value]="seed"` + `(input)` writeback via `Host::set` | -| `[(name)]="ident"` | `[name]="ident"` + `(nameChange)` writeback via `Host::set` | +| Surface syntax | Desugars to | +| -------------------- | -------------------------------------------------------------- | +| `[(value)]="seed"` | `[value]="seed"` + `(input)` writeback via `Host::set` (str) | +| `[(checked)]="flag"` | `[checked]="flag"` + `(change)` writeback via `Host::set` (bool); AOT reads `el.checked` | +| `[(name)]="ident"` | `[name]="ident"` + `(nameChange)` writeback via `Host::set` | + +Boolean DOM properties `[disabled]` and `[checked]` evaluate with `eval_bool` +(not `prop_str`), so `false` does not become a truthy string in the DOM. Parse expands banana into a property binding plus an event handler that calls internal `$bananaSet(ident, $event)`. AOT `HostCell` and the Host `set` path diff --git a/tests/fixtures/components/two-way/two-way.html b/tests/fixtures/components/two-way/two-way.html index 29d6f92..7b49eb4 100644 --- a/tests/fixtures/components/two-way/two-way.html +++ b/tests/fixtures/components/two-way/two-way.html @@ -16,4 +16,14 @@ Push from Host +
+ +

+ Checked: + {{ on }} +

+