Skip to main content

max / quasi

A control calling an undescribed route can say where the answer goes
Author: Max Johnson <me@maxj.phd> · 2026-08-11 15:43 UTC
Signed with PGP, not checked
Commit: 1c48b9f972b20c632d17577c261a7b9a344d808e
Parent: 995c9ab
3 files changed, +103 insertions, -4 deletions
@@ -148,6 +148,29 @@
148 148 /// belongs: the link is then the view, and a middle-click reaches the same
149 149 /// place the control does.
150 150 pub carried: Params,
151 + /// The region this call's answer replaces, when the responder cannot say.
152 + ///
153 + /// Normally nothing sets this and nothing should: a described route answers
154 + /// with a `Response::Fragment` naming the region it changed, `quasi-http`
155 + /// turns that into the transport's retarget header, and the router is the
156 + /// only party that knows what it just changed. That is decision 7 and it is
157 + /// unchanged.
158 + ///
159 + /// **Decision 7 assumes the responder is described, and a control may call
160 + /// a route that is not.** Every write on the MNW server's dashboard goes to
161 + /// a plain API route that quasi never sees and that answers with a status or
162 + /// a hand-rendered fragment. Those routes cannot name a region, so if the
163 + /// control does not either, nobody does: the answer lands wherever the
164 + /// transport's default puts it, which for htmx is inside the button that was
165 + /// pressed. That is not a second party deciding one thing. It is the only
166 + /// party that can decide, because the other one is outside the description
167 + /// layer.
168 + ///
169 + /// So: leave it unset when calling a described route, and set it when
170 + /// calling something else. A screen that sets it against a described route
171 + /// is overriding an answer that already knew better, and that is the misuse
172 + /// decision 7 was guarding against.
173 + pub replaces: Option<String>,
151 174 /// The name to keep the answer under, when the answer is a file.
152 175 ///
153 176 /// `Some` means the response is not a view: nothing swaps, and the reader
@@ -179,6 +202,7 @@
179 202 params: Params::new(),
180 203 carried: Params::new(),
181 204 saves: None,
205 + replaces: None,
182 206 }
183 207 }
184 208
@@ -190,6 +214,7 @@
190 214 params: Params::new(),
191 215 carried: Params::new(),
192 216 saves: None,
217 + replaces: None,
193 218 }
194 219 }
195 220
@@ -206,6 +231,7 @@
206 231 params: Params::new(),
207 232 carried: Params::new(),
208 233 saves: None,
234 + replaces: None,
209 235 }
210 236 }
211 237
@@ -217,6 +243,7 @@
217 243 params: Params::new(),
218 244 carried: Params::new(),
219 245 saves: None,
246 + replaces: None,
220 247 }
221 248 }
222 249
@@ -231,9 +258,20 @@
231 258 params: Params::new(),
232 259 carried: Params::new(),
233 260 saves: None,
261 + replaces: None,
234 262 }
235 263 }
236 264
265 + /// Put this call's answer into the region with this id.
266 + ///
267 + /// For a route the description layer does not serve. See
268 + /// [`replaces`](Self::replaces) before reaching for it.
269 + #[must_use]
270 + pub fn replacing(mut self, region: impl Into<String>) -> Self {
271 + self.replaces = Some(region.into());
272 + self
273 + }
274 +
237 275 /// Keep the answer as a file with this name, rather than showing it.
238 276 #[must_use]
239 277 pub fn saving(mut self, filename: impl Into<String>) -> Self {
@@ -13,11 +13,16 @@
13 13 //! this file knows the word htmx, so decision 13's claim that the transport is
14 14 //! replaceable is a claim about one function rather than about the crate.
15 15 //!
16 - //! No `hx-target` is ever emitted. Decision 7 puts the target on the response,
17 - //! where `quasi-http` sets `HX-Retarget` from
16 + //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set
17 + //! when a control calls a route the description layer does not serve. Decision 7
18 + //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from
18 19 //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router
19 - //! is the only party that knows what it just changed. A control that also named
20 - //! a target would be a second party deciding one thing.
20 + //! is the only party that knows what it just changed, and a control that also
21 + //! named a target would be a second party deciding one thing. That reasoning
22 + //! assumes the responder is described. A plain API route is not, cannot name a
23 + //! region, and leaves the answer to land wherever the transport defaults, which
24 + //! for htmx is inside the pressed button. See `Action::replaces` for the whole
25 + //! of it.
21 26
22 27 use std::collections::HashMap;
23 28 use std::fmt::Write as _;
@@ -300,6 +305,15 @@
300 305 out.push('"');
301 306 }
302 307
308 + // Where the answer goes, when the responder is not ours to ask. Emitted
309 + // before the verb so the attributes read in the order they are reasoned
310 + // about: where it lands, then what is sent.
311 + if let Some(region) = &action.replaces {
312 + out.push_str(" hx-target=\"#");
313 + out.push_str(&escape(region));
314 + out.push('"');
315 + }
316 +
303 317 // The answer is a file the reader keeps, not a view. On a link the browser
304 318 // does the whole job from the attribute, so nothing else is needed and the
305 319 // control still works with JS off. On a write it cannot: a response has to
@@ -759,6 +759,53 @@
759 759 assert!(!plain.contains("figure-change"), "{plain}");
760 760 }
761 761
762 + #[test]
763 + fn a_control_calling_an_undescribed_route_can_say_where_the_answer_goes() {
764 + // Decision 7 holds for a described responder and cannot for any other. A
765 + // plain API route answers with a status or a hand-rendered fragment and
766 + // names no region, so without this htmx swaps that answer into the button
767 + // that was pressed. Which is what the MNW server's described Remove button
768 + // did: the delete endpoint answers with the whole re-rendered list, and it
769 + // landed inside the control.
770 + let html = fragment(&Node::list([Row::new("fw13").act(Act::new(
771 + "Remove",
772 + Action::delete("/api/users/me/ssh-keys/7").replacing("ssh-keys-list"),
773 + ))]));
774 +
775 + assert!(html.contains("hx-target=\"#ssh-keys-list\""), "{html}");
776 + assert!(
777 + html.contains("hx-delete=\"/api/users/me/ssh-keys/7\""),
778 + "{html}"
779 + );
780 + }
781 +
782 + #[test]
783 + fn nothing_else_ever_emits_a_target() {
784 + // The rule the carve-out is carved out of. A control calling a described
785 + // route says nothing about where its answer lands, because the answer says
786 + // so itself.
787 + let html = fragment(&Node::list([Row::new("fw13")
788 + .act(Act::new("Open", Action::get("/keys/7")))
789 + .activate(Action::get("/keys/7"))]));
790 + assert!(!html.contains("hx-target"), "{html}");
791 +
792 + let form = fragment(&Node::Form {
793 + action: Action::post("/keys"),
794 + submit: "Add".into(),
795 + fields: vec![Field::new(layout::FieldKind::Text, "label", "Label")],
796 + });
797 + assert!(!form.contains("hx-target"), "{form}");
798 + }
799 +
800 + #[test]
801 + fn a_region_id_cannot_break_out_of_the_target_attribute() {
802 + let html = fragment(&Node::act(
803 + "Remove",
804 + Action::delete("/api/x").replacing("a\" onload=\"x()"),
805 + ));
806 + assert!(!html.contains("\" onload="), "{html}");
807 + }
808 +
762 809 #[test]
763 810 fn a_control_whose_answer_is_a_file_says_so() {
764 811 // Nine sites in MNW: five CSV exports across four templates, a sixth in the