Skip to main content

max / quasi

Say the other slots a write changed A response could name the region it replaced and nothing else, so a write that moves a row and the count above it had two ways to say so and both were bad: a second request for a fact the router already had, or a full-page swap that loses scroll and focus on exactly the list screens where that hurts. Response::invalidates, and `.also(region, node)` to add one. A slot id is the address a description already uses for a region, so there is no second naming scheme, and each carries its new contents rather than only its name -- a renderer told a region is stale can only ask again or re-derive it, and re-deriving means the router's view logic runs twice per write and the two runs have to agree. Each renderer decides what invalidation means. The webview swaps out of band, addressed as `innerHTML:#id` and not a bare `true`: a bare one replaces the element carrying the id, which is the region div with its classes on it, so the region would keep its contents and lose its layout. The terminal puts them on the screen, which is the whole of it there -- the next frame redraws anyway. Not applied to a whole screen or a redirect, and neither is a case dropped: a screen replaces every region already, so an out-of-band copy would put a second element under an id the document now has twice, and a redirect has no body. htmx now enters this crate in two functions rather than one, and the guard says so. The claim it protects is unchanged, because both are the same fact and both move together if the transport does. Its pattern is also fixed: it matched on `" hx-`, wanting a quote and a space before the attribute, and `oob_html` opens its own tag -- so the leak the test exists to catch would have gone past it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 23:43 UTC
Signed with PGP, not checked
Commit: 3253b05f4a9edc10b3f75e777d11e54263f8a50b
Parent: 9748fda
13 files changed, +411 insertions, -35 deletions
M Cargo.lock +4
@@ -5163,6 +5163,10 @@
5163 5163 name = "kberg"
5164 5164 version = "0.1.0"
5165 5165
5166 + [[patch.unused]]
5167 + name = "ops-status"
5168 + version = "0.1.0"
5169 +
5166 5170 [[patch.unused]]
5167 5171 name = "painhours"
5168 5172 version = "0.1.0"
@@ -213,19 +213,32 @@
213 213 .as_ref()
214 214 .map(|notice| htmx::notice_trigger(notice.kind, notice.tone, &notice.text));
215 215 let mut response = match answer.outcome {
216 + // An invalidation is not applied to a whole screen, and that is
217 + // not a case being dropped. Every slot is being replaced
218 + // already, so an out-of-band copy would be a second element
219 + // carrying an id the document now has twice. A handler that
220 + // says `.also()` on a screen is stating something the answer
221 + // already made true.
216 222 Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None),
217 223 Outcome::Fragment { region, node } => {
218 224 // The router said what it changed, so the client is told
219 225 // rather than left to infer it from which element was
220 226 // clicked. The webview renderer owes every slot an `id`
221 227 // matching its `Slot::id` for this to land.
222 - body(
223 - render,
224 - 200,
225 - render.fragment(&node),
226 - Some(format!("#{region}")),
227 - )
228 + //
229 + // The other slots the answer changed ride along behind the
230 + // targeted one, each named rather than aimed. Appended in
231 + // the order the router gave them, because a renderer
232 + // reordering them would be inventing a fact the response
233 + // did not state.
234 + let mut markup = render.fragment(&node);
235 + for stale in &answer.invalidates {
236 + markup.push_str(&render.invalidated(&stale.region, &stale.node));
237 + }
238 + body(render, 200, markup, Some(format!("#{region}")))
228 239 }
240 + // Nor to a redirect, which has no body to carry one. The
241 + // destination answers next and answers with everything.
229 242 Outcome::Goto(action) => redirect(&action),
230 243 };
231 244 if let Some(trigger) = trigger
@@ -45,6 +45,31 @@
45 45 /// The inside of one region.
46 46 fn fragment(&self, node: &Node) -> String;
47 47
48 + /// One region's new contents, carried beside the region being replaced.
49 + ///
50 + /// [`Response::invalidates`](quasi_router::Response::invalidates): the row
51 + /// the write was aimed at is the [`fragment`](Self::fragment), and the
52 + /// count in the header is one of these. Both travel on one answer, so the
53 + /// two never disagree and no second request is made for a fact the router
54 + /// already had.
55 + ///
56 + /// Unlike the other two this carries the region's id, because an
57 + /// out-of-band update is applied by name rather than by where it was
58 + /// aimed. A fragment does not need one: the router said where it goes in
59 + /// [`RETARGET`](crate::htmx::RETARGET).
60 + ///
61 + /// # Why it defaults to nothing
62 + ///
63 + /// A renderer answering something other than markup has no out-of-band
64 + /// channel to put this in, the same reason [`content_type`](Self::content_type)
65 + /// is overridable. Returning nothing is that renderer saying so, and it is
66 + /// the honest default: a JSON client is told what changed by the payload it
67 + /// already parses.
68 + fn invalidated(&self, region: &str, node: &Node) -> String {
69 + let _ = (region, node);
70 + String::new()
71 + }
72 +
48 73 /// What the document says it is.
49 74 ///
50 75 /// Overridable for a renderer answering something other than HTML, which is
@@ -24,6 +24,10 @@
24 24 other => format!("other:{other:?}"),
25 25 }
26 26 }
27 +
28 + fn invalidated(&self, region: &str, node: &Node) -> String {
29 + format!("[oob:{region}:{}]", self.fragment(node))
30 + }
27 31 }
28 32
29 33 /// Decode a request built from its parts.
@@ -516,3 +520,78 @@
516 520 );
517 521 assert_eq!(history(&response), (None, None));
518 522 }
523 +
524 + #[test]
525 + fn a_write_naming_two_slots_carries_both_behind_the_one_it_replaced() {
526 + // The row the write was aimed at, and the count above it that also moved.
527 + // Both on one answer, in the order the router named them, so the two can
528 + // never disagree and no second request is made for a fact it already had.
529 + let response = respond(
530 + &Spy,
531 + Ok(Response::fragment("row-7", Node::text("Done"))
532 + .also("task-count", Node::text("4 left"))
533 + .also("sidebar-badge", Node::text("4"))),
534 + &asked("POST", "/tasks/7/done"),
535 + );
536 +
537 + let body = String::from_utf8(response.body().clone()).expect("the spy answers text");
538 + assert_eq!(
539 + body,
540 + "text:Done[oob:task-count:text:4 left][oob:sidebar-badge:text:4]"
541 + );
542 +
543 + // The retarget still names one target. A set of invalidations is a
544 + // different fact, and conflating them was the rejected option.
545 + assert_eq!(
546 + response
547 + .headers()
548 + .get(super::htmx::RETARGET)
549 + .map(|value| value.to_str().expect("a slot id is ascii")),
550 + Some("#row-7")
551 + );
552 + }
553 +
554 + #[test]
555 + fn a_whole_screen_and_a_redirect_carry_no_out_of_band_copy() {
556 + // Not a case being dropped. A screen replaces every slot already, so an
557 + // out-of-band copy would put a second element into the document under an
558 + // id it now has twice; a redirect has no body at all.
559 + let screen = Screen::sidebar_content("Tasks").with(Slot::new("main", RegionKind::Pane));
560 + let swapped = respond(
561 + &Spy,
562 + Ok(Response::screen(screen).also("task-count", Node::text("4 left"))),
563 + &asked("GET", "/tasks"),
564 + );
565 + let body = String::from_utf8(swapped.body().clone()).expect("the spy answers text");
566 + assert_eq!(body, "screen:Tasks");
567 +
568 + let sent = respond(
569 + &Spy,
570 + Ok(Response::goto(Action::get("/tasks")).also("task-count", Node::text("4 left"))),
571 + &asked("POST", "/tasks/7/delete"),
572 + );
573 + assert!(sent.body().is_empty());
574 + }
575 +
576 + #[test]
577 + fn a_renderer_that_does_not_answer_markup_drops_an_invalidation() {
578 + // The default on `Serves::invalidated`, which is the honest answer for a
579 + // renderer with no out-of-band channel rather than an oversight: a JSON
580 + // client learns what changed from the payload it already parses.
581 + struct Json;
582 + impl Serves for Json {
583 + fn screen(&self, _: &Screen) -> String {
584 + "{}".to_owned()
585 + }
586 + fn fragment(&self, _: &Node) -> String {
587 + "{}".to_owned()
588 + }
589 + }
590 +
591 + let response = respond(
592 + &Json,
593 + Ok(Response::fragment("row-7", Node::text("Done")).also("count", Node::text("4"))),
594 + &asked("POST", "/tasks/7/done"),
595 + );
596 + assert_eq!(response.body(), b"{}");
597 + }
@@ -114,7 +114,7 @@
114 114 pub use crate::containment::{Containment, Element, Level, Of};
115 115 pub use crate::error::{Class, RouteError};
116 116 pub use crate::request::{Method, Params, Request};
117 - pub use crate::response::{Address, Message, Outcome, Response};
117 + pub use crate::response::{Address, Invalidated, Message, Outcome, Response};
118 118 pub use crate::router::{Handler, Router};
119 119 pub use crate::screen::{
120 120 Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node,
@@ -28,6 +28,12 @@
28 28 //! [`Outcome`] holds the three ways to answer with content and the notice sits
29 29 //! beside it, optional, orthogonal to all three.
30 30 //!
31 + //! [`invalidates`](Response::invalidates) is the third arrival and the one that
32 + //! settles the shape: a write that changes a row *and* the count above it
33 + //! composes with all three outcomes and with the notice, so it is a fourth
34 + //! field rather than a fourth member. Had this stayed an enum it would have
35 + //! needed a member per combination.
36 + //!
31 37 //! # Why [`Goto`](Outcome::Goto) takes an [`Action`] and not a [`Destination`]
32 38 //!
33 39 //! A redirect has params: back to a list with a filter still applied, back to a
@@ -62,6 +68,44 @@
62 68 /// place, a write and a fragment are not — so a control never has to
63 69 /// predict what its answer will be. See [`Address`].
64 70 pub address: Option<Address>,
71 + /// The other slots this answer changed, beyond the one it replaced.
72 + ///
73 + /// Empty on almost every response. See [`Invalidated`], and [`also`] for
74 + /// the way to add one.
75 + ///
76 + /// [`also`]: Self::also
77 + pub invalidates: Vec<Invalidated>,
78 + }
79 +
80 + /// A slot this answer changed without being aimed at it.
81 + ///
82 + /// The row you edited is the [`Outcome`]; the count in the header is one of
83 + /// these. Both are named by [`Slot::id`](crate::Slot::id), because a slot id is
84 + /// the address a description already uses for a region and there is no reason
85 + /// for a second naming scheme.
86 + ///
87 + /// # Why this carries a node and not just an id
88 + ///
89 + /// A renderer told only that something is stale has two ways to act on it, and
90 + /// both are worse. It can ask again, which is a second round trip for a fact
91 + /// the router had in hand. Or it can re-derive the region, which means the
92 + /// router's view logic runs twice per write and the two runs have to agree.
93 + /// Handing over the new contents makes an invalidation the same shape as a
94 + /// fragment, which is what it is: one region and what now goes in it.
95 + ///
96 + /// # What each renderer does with it
97 + ///
98 + /// A webview swaps it out of band, so the row and the header both move on one
99 + /// response. A terminal redraws that panel. An egui frame does nothing,
100 + /// because it was going to redraw everything anyway. That spread is the reason
101 + /// this says "invalidated" rather than naming a swap: a swap is a DOM idea and
102 + /// two of the three renderers have no answer for it.
103 + #[derive(Debug, Clone, PartialEq, Eq)]
104 + pub struct Invalidated {
105 + /// The [`Slot::id`](crate::Slot::id) whose contents are now stale.
106 + pub region: String,
107 + /// What goes in it instead.
108 + pub node: Node,
65 109 }
66 110
67 111 /// Whether an answer is somewhere the user can come back to.
@@ -224,6 +268,25 @@
224 268 self
225 269 }
226 270
271 + /// This answer also changed that slot, and here is its new content.
272 + ///
273 + /// Chains, so a write that moves three places says so three times. The
274 + /// order is kept, because a renderer applying them in a different order
275 + /// than the router named them would be inventing a fact.
276 + ///
277 + /// Naming the slot the [`Outcome`] already replaces is not rejected here
278 + /// and not special-cased: a renderer applies what it is given, and a
279 + /// response that says the same region twice is a bug in the handler that a
280 + /// silent drop would hide.
281 + #[must_use]
282 + pub fn also(mut self, region: impl Into<String>, node: Node) -> Self {
283 + self.invalidates.push(Invalidated {
284 + region: region.into(),
285 + node,
286 + });
287 + self
288 + }
289 +
227 290 /// This answer is a place, at this address.
228 291 ///
229 292 /// For the answer a derivation cannot reach: a fragment that is a place.
@@ -287,6 +350,7 @@
287 350 outcome,
288 351 notice: None,
289 352 address: None,
353 + invalidates: Vec::new(),
290 354 }
291 355 }
292 356 }
@@ -18,7 +18,7 @@
18 18
19 19 use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot};
20 20
21 - use super::{Context, Protocol, Serves, Served, serve};
21 + use super::{Context, Protocol, Served, Serves, serve};
22 22
23 23 /// An app with nothing in it. The router is what is under test.
24 24 struct App;
@@ -81,7 +81,9 @@
81 81
82 82 let tui = pass.tui;
83 83 match node {
84 - Node::Heading { level, text: title } => text::draw(title, tui.style().heading(*level), area, buf),
84 + Node::Heading { level, text: title } => {
85 + text::draw(title, tui.style().heading(*level), area, buf)
86 + }
85 87
86 88 Node::Text {
87 89 text: content,
@@ -112,7 +114,12 @@
112 114 // address is the runtime's to follow when the link has focus.
113 115 Node::Link { text: label, .. } => {
114 116 let focused = pass.claim();
115 - text::draw(label, tui.style().focused(focused, link_style(tui)), area, buf)
117 + text::draw(
118 + label,
119 + tui.style().focused(focused, link_style(tui)),
120 + area,
121 + buf,
122 + )
116 123 }
117 124
118 125 Node::Token(tag) => {
@@ -216,7 +223,8 @@
216 223 );
217 224 used + text::draw(
218 225 &label,
219 - tui.style().focused(focused, Style::default().fg(tui.theme().action_primary)),
226 + tui.style()
227 + .focused(focused, Style::default().fg(tui.theme().action_primary)),
220 228 below(area, used),
221 229 buf,
222 230 )
@@ -359,7 +367,8 @@
359 367 area.y,
360 368 tick,
361 369 3,
362 - tui.style().focused(focused, Style::default().fg(tui.theme().content_secondary)),
370 + tui.style()
371 + .focused(focused, Style::default().fg(tui.theme().content_secondary)),
363 372 );
364 373 return;
365 374 }
@@ -369,7 +378,8 @@
369 378 area.y,
370 379 ">",
371 380 1,
372 - tui.style().focused(focused, Style::default().fg(tui.theme().action_primary)),
381 + tui.style()
382 + .focused(focused, Style::default().fg(tui.theme().action_primary)),
373 383 );
374 384 }
375 385 }
@@ -607,9 +617,7 @@
607 617 widget::Held::Text(held)
608 618 };
609 619
610 - field.with_layout(|described| {
611 - widget::field(tui.style(), &described, held, focused, area, buf)
612 - })
620 + field.with_layout(|described| widget::field(tui.style(), &described, held, focused, area, buf))
613 621 }
614 622
615 623 /// A tabs strip, a segmented control and a toggle, all as one row of labels.
@@ -328,10 +328,15 @@
328 328 outcome,
329 329 notice,
330 330 address,
331 + invalidates,
331 332 } = response;
332 333 self.saying = notice.or(self.saying.take());
333 334
334 335 match outcome {
336 + // Invalidations are not applied to a whole screen, matching what an
337 + // HTTP host does with them and for the same reason: every region is
338 + // being replaced already, so naming one of them again says nothing
339 + // the new screen does not.
335 340 Outcome::Screen(screen) => {
336 341 self.remember(request, address.as_ref());
337 342 self.screen = screen;
@@ -345,11 +350,34 @@
345 350 // `Screen::replace` describes, and a terminal can say so
346 351 // rather than swallowing it: the region it named is gone, and
347 352 // drawing nothing would look like a control that does nothing.
353 + //
354 + // The slots the answer invalidated go in the same way. On a
355 + // terminal that is the whole of what invalidation means: the
356 + // next frame redraws everything, so putting the new contents
357 + // on the screen is putting them in front of the user. What a
358 + // webview needs an out-of-band swap for, this gets for free.
359 + let mut missing: Vec<String> = Vec::new();
348 360 if !self.screen.replace(&region, node) {
361 + missing.push(region);
362 + }
363 + for stale in invalidates {
364 + if !self.screen.replace(&stale.region, stale.node) {
365 + missing.push(stale.region);
366 + }
367 + }
368 + if !missing.is_empty() {
369 + // One message naming all of them, rather than a banner per
370 + // region where only the last would survive.
371 + let named = missing
372 + .iter()
373 + .map(|region| format!("`{region}`"))
374 + .collect::<Vec<_>>()
375 + .join(", ");
376 + let subject = if missing.len() == 1 { "is" } else { "are" };
349 377 self.saying = Some(Message {
350 378 kind: layout::Notice::Banner,
351 379 tone: layout::Tone::Danger,
352 - text: format!("nothing on this screen is called `{region}`"),
380 + text: format!("nothing on this screen {subject} called {named}"),
353 381 undo: None,
354 382 });
355 383 }