From e35dc357137a3563aa891662a626b3310a7078c4 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 18:56:26 -0400 Subject: [PATCH] stream-dom-dioxus: spell an asset handle as asset: in attribute values Dioxus attribute values are strings, so the writer had no way to emit the protocol's asset arm; every href/src reached the receiver as text, which a policy requiring asset-valued URLs rejects. An attribute value of the form asset: now decodes to the asset arm on both the static-template and dynamic set-attribute paths. Anything else is text, as before. --- crates/stream-dom-dioxus/src/writer.rs | 41 ++++++++- .../stream-dom-dioxus/tests/writer_stream.rs | 83 +++++++++++++++++++ docs/design.md | 7 ++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/stream-dom-dioxus/src/writer.rs b/crates/stream-dom-dioxus/src/writer.rs index 7da9d97..52ac2a8 100644 --- a/crates/stream-dom-dioxus/src/writer.rs +++ b/crates/stream-dom-dioxus/src/writer.rs @@ -379,10 +379,14 @@ impl MutationWriter { { let name = self.intern(name); let ns = self.intern_opt(*namespace); + let value = match asset_handle(value) { + Some(h) => proto::template_attr::Value::Asset(h), + None => proto::template_attr::Value::Text((*value).to_string()), + }; out_attrs.push(proto::TemplateAttr { name, ns, - value: Some(proto::template_attr::Value::Text((*value).to_string())), + value: Some(value), }); } } @@ -558,6 +562,15 @@ impl WriteMutations for MutationWriter { return; }; + // An asset handle is never a property, never a style: check before + // either branch below. + if let Some(h) = text.as_deref().and_then(asset_handle) { + let name = self.intern(name); + let ns = self.intern_opt(ns); + self.batch.set_attribute_asset(nid, name, ns, &h); + return; + } + if ns == Some("style") { // The protocol carries one `style` attribute, not a style map, so // Dioxus's per-property writes accumulate and re-serialize whole. @@ -678,6 +691,32 @@ impl WriteMutations for MutationWriter { } } +/// The Dioxus producer's spelling for an asset handle (docs/design.md +/// "Assets are handles, not bytes and not URLs"). Dioxus attribute values are +/// strings; the protocol's `asset` arm wants opaque bytes, so a Dioxus app +/// names a handle as `asset:` and this decodes it back. Anything not +/// matching the grammar — including a bare `"asset:"` prefix — is left as +/// text, unchanged. +/// +/// Returns `None` unless `value` is `asset:` followed by a non-empty, +/// even-length run of hex digits (either case). +fn asset_handle(value: &str) -> Option> { + let hex = value.strip_prefix("asset:")?; + if hex.is_empty() || hex.len() % 2 != 0 { + return None; + } + let mut bytes = Vec::with_capacity(hex.len() / 2); + let digits = hex.as_bytes(); + let mut i = 0; + while i < digits.len() { + let hi = (digits[i] as char).to_digit(16)?; + let lo = (digits[i + 1] as char).to_digit(16)?; + bytes.push((hi as u8) << 4 | lo as u8); + i += 2; + } + Some(bytes) +} + /// Reduce an `AttributeValue` to the string dioxus's own renderer hands its /// interpreter, before any attribute-vs-property decision is made /// (dioxus-interpreter-js-0.7.10 src/write_native_mutations.rs diff --git a/crates/stream-dom-dioxus/tests/writer_stream.rs b/crates/stream-dom-dioxus/tests/writer_stream.rs index 99c2bf0..fdb4556 100644 --- a/crates/stream-dom-dioxus/tests/writer_stream.rs +++ b/crates/stream-dom-dioxus/tests/writer_stream.rs @@ -542,6 +542,89 @@ fn attribute_property_table_matches_dioxus_web() { } } +/// A static template attribute matching the `asset:` convention, plus +/// four dynamic attributes exercising the boundary: a real handle and three +/// values that merely resemble the prefix (bare, non-hex, odd-length). +fn asset_app() -> Element { + let a = use_signal(|| "asset:deadbeef".to_string()); + let b = use_signal(|| "asset:".to_string()); + let c = use_signal(|| "asset:xyz".to_string()); + let d = use_signal(|| "asset:abc".to_string()); + rsx! { + link { rel: "stylesheet", href: "asset:0a0B" } + input { "data-a": "{a}" } + input { "data-b": "{b}" } + input { "data-c": "{c}" } + input { "data-d": "{d}" } + } +} + +/// Both the static-template and dynamic `set_attribute` paths recognize the +/// `asset:` convention (writer.rs `asset_handle`; docs/design.md +/// "Assets are handles, not bytes and not URLs") and only that grammar: a +/// bare prefix, non-hex digits, or an odd-length hex run all stay `Text`. +#[test] +fn asset_convention_marks_the_asset_arm_and_only_the_asset_arm() { + let interner = Rc::new(RefCell::new(Interner::new())); + let mut writer = MutationWriter::new(interner.clone()); + let mut dom = VirtualDom::new(asset_app); + dom.rebuild(&mut writer); + let bytes = writer.batch.finish().expect("mount produces a batch"); + let frames = decode_all(&bytes); + + let mut template_href = None; + for f in &frames { + if let Some(proto::frame::Op::RegisterTemplate(t)) = &f.op { + for node in &t.nodes { + if let Some(proto::template_node::Kind::Element(e)) = &node.kind { + for a in &e.attrs { + if interner.borrow().resolve(a.name) == Some("href") { + template_href = a.value.clone(); + } + } + } + } + } + } + assert_eq!( + template_href, + Some(proto::template_attr::Value::Asset(vec![0x0a, 0x0b])), + "static href=\"asset:0a0B\" must decode to Value::Asset([0x0a, 0x0b])" + ); + + // Dynamic `data-*` attrs, keyed by name so each signal's fate is checked + // independently of frame order. + let mut dynamic: std::collections::HashMap> = + std::collections::HashMap::new(); + for f in &frames { + if let Some(proto::frame::Op::SetAttribute(s)) = &f.op { + if let Some(name) = interner.borrow().resolve(s.name) { + if name.starts_with("data-") { + dynamic.insert(name.to_string(), s.value.clone()); + } + } + } + } + assert_eq!( + dynamic.get("data-a"), + Some(&Some(proto::set_attribute::Value::Asset(vec![ + 0xde, 0xad, 0xbe, 0xef + ]))), + "data-a=\"asset:deadbeef\" must decode to Value::Asset; got {dynamic:?}" + ); + for (name, text) in [ + ("data-b", "asset:"), + ("data-c", "asset:xyz"), + ("data-d", "asset:abc"), + ] { + assert_eq!( + dynamic.get(name), + Some(&Some(proto::set_attribute::Value::Text(text.to_string()))), + "{name}={text:?} does not match the asset grammar and must stay Text; got {dynamic:?}" + ); + } +} + /// Each row's `button` is a template interior with its own node id, so /// removing a row must forget the button too. fn removable_list() -> Element { diff --git a/docs/design.md b/docs/design.md index e9e7215..b9811b3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -513,6 +513,13 @@ wire rule. clone. A receiver with no `resolveAsset` configured treats an asset value as an error. +Dioxus attribute values are always strings, with no `asset` arm of their +own to write into — so `stream-dom-dioxus` spells a handle as the string +`asset:` (an even-length run of hex digits) and decodes it back to +the `asset` arm at both the static-template and dynamic `set-attribute` +sites. Anything not matching that grammar, including a value that merely +resembles the prefix, is text, exactly as before. + ## Events Dispatch: `handle-event(target, name, payload: list, ev)` export.