Skip to main content

max / quasi

The answer says whether it is a place, so the back button works Nothing in a description said a navigation should enter browser history, so quasi-webview emitted no hx-push-url and every navigation on a described site replaced the current entry. GO never hit it: a Tauri window has no address bar and no back button anyone uses. Derived, not described. quasi-http decides from what was asked and what the router answered, together, which is the one point that holds both halves: a read answering with a screen -> HX-Push-Url, at the address it was read from, params included a write answering with a screen -> nothing. The address is where the form was, and going back should not re-offer the result as a page a fragment -> nothing, unless it says otherwise a Goto -> nothing. HX-Location issues the request client-side and htmx pushes for it Response::address is the override, for the two cases derivation cannot reach: a fragment that IS a place, which is the 32 addressable tab panels, and a screen that is not. Builders beside toast/banner: at(), replacing(), in_place(). Rejected: a `pushes` flag on Action. That asks the control to predict what its answer will be, which is the thing decision 7 exists to stop, and the server's 24 hand-written hx-push-url uses across 13 files are what that looks like once it has drifted. No push-url is ever emitted into markup, asserted. quasi-http gains Asked, taken before the router consumes the request, because the URL a push carries has to be the one the route was actually reached at. quasi-tauri drops both headers after the shared path sets them: a window with no address bar has nowhere to put a history entry, and suppressing it where the difference is beats threading a flag through for one host.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 18:43 UTC
Signed with PGP, not checked
Commit: a1124d30f45d8984325c0bccfbc964933cd97e17
Parent: 3af3bd1
8 files changed, +311 insertions, -17 deletions
@@ -357,6 +357,10 @@
357 357 // carried half that is kept rather than the write's payload.
358 358 let params = incoming.carried.clone();
359 359
360 + // Kept before the router consumes the request, because whether the answer
361 + // is a place is decided from what was asked and the answer together.
362 + let asked = quasi_http::Asked::new(&incoming);
363 +
360 364 // After the envelope is checked and before the router is called. An
361 365 // oversized body should not cost a session lookup, and a handler must not
362 366 // run before the viewer it reads is resolved.
@@ -382,13 +386,13 @@
382 386 });
383 387
384 388 match &context.render {
385 - Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome)),
389 + Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome, &asked)),
386 390 Renderers::PerRequest(factory) => {
387 391 // Built from the answer rather than before it, so the factory can
388 392 // fill the regions this screen has and skip the work for the ones
389 393 // it does not.
390 394 let render = factory(&state, &params, outcome.as_ref().ok());
391 - convert(quasi_http::respond(&render, outcome))
395 + convert(quasi_http::respond(&render, outcome, &asked))
392 396 }
393 397 }
394 398 }
@@ -60,6 +60,30 @@
60 60 /// The event name [`TRIGGER`] carries.
61 61 pub const NOTICE_EVENT: &str = "quasi:notice";
62 62
63 + /// The header saying this answer is a new place in history.
64 + ///
65 + /// Derived rather than described. A `GET` of a
66 + /// [`Destination::Route`](quasi_router::Destination::Route) answering with a
67 + /// whole screen is a place, and that is the whole of the common case: the
68 + /// address is the request's own, so nothing has to be computed and no control
69 + /// has to predict what its answer will be.
70 + ///
71 + /// [`Address`](quasi_router::Address) is the override, for the answers the
72 + /// derivation cannot reach. A fragment that is a place says
73 + /// `.at(url)`; a screen that is not says `.in_place()`.
74 + ///
75 + /// Never emitted as markup. `hx-push-url` on a control is the same fact decided
76 + /// a step too early, by the party that does not know it yet.
77 + pub const PUSH_URL: &str = "HX-Push-Url";
78 +
79 + /// The header saying this answer is a place that takes the current slot.
80 + ///
81 + /// [`PUSH_URL`]'s sibling, from
82 + /// [`Address::Replaces`](quasi_router::Address::Replaces). The address moves
83 + /// and history does not grow, which is what a filter over a list wants: the
84 + /// back button should leave the list, not walk back through the filters.
85 + pub const REPLACE_URL: &str = "HX-Replace-Url";
86 +
63 87 /// The header naming how a response is put in place.
64 88 ///
65 89 /// Not set by this adapter. How a swap happens is the renderer's business and
@@ -38,7 +38,7 @@
38 38 //! [`quasi_axum`]: https://makenot.work/git/max/quasi
39 39
40 40 use quasi_router::{
41 - Action, Destination, Method, Node, Outcome, Params, Request, Response, RouteError,
41 + Action, Address, Destination, Method, Node, Outcome, Params, Request, Response, RouteError,
42 42 };
43 43
44 44 pub mod htmx;
@@ -75,6 +75,31 @@
75 75 pub carried: Params,
76 76 }
77 77
78 + /// What was asked, kept back so the answer can be placed in history.
79 + ///
80 + /// [`Incoming`] is consumed by the router, and the two facts history needs — was
81 + /// this a read, and of what address — outlive it. Taken here rather than
82 + /// re-derived from the http request, so the URL a push carries is exactly the
83 + /// one the route was reached at, params and all.
84 + #[derive(Debug, Clone, PartialEq, Eq)]
85 + pub struct Asked {
86 + /// Asking or telling. Only a read can be a place.
87 + pub method: Method,
88 + /// The address this request was made at, carried params included.
89 + pub url: String,
90 + }
91 +
92 + impl Asked {
93 + /// What a request was, before the router takes it.
94 + #[must_use]
95 + pub fn new(incoming: &Incoming) -> Self {
96 + Self {
97 + method: incoming.method,
98 + url: route_url(&incoming.path, &incoming.carried),
99 + }
100 + }
101 + }
102 +
78 103 impl From<Incoming> for Request {
79 104 fn from(incoming: Incoming) -> Self {
80 105 Self {
@@ -172,9 +197,15 @@
172 197 pub fn respond<R: Render + ?Sized>(
173 198 render: &R,
174 199 outcome: Result<Response, RouteError>,
200 + asked: &Asked,
175 201 ) -> http::Response<Vec<u8>> {
176 202 match outcome {
177 203 Ok(answer) => {
204 + // Whether this answer is a place, decided here because this is the
205 + // one point that holds both halves: what was asked, and what the
206 + // router did about it. A control cannot know the second, which is
207 + // why no `hx-push-url` is ever emitted into markup.
208 + let address = placement(&answer, asked);
178 209 // The notice is orthogonal to the outcome and is applied to all
179 210 // three, including a redirect, which has no body to carry one.
180 211 let trigger = answer
@@ -202,6 +233,11 @@
202 233 {
203 234 response.headers_mut().insert(htmx::TRIGGER, value);
204 235 }
236 + if let Some((header, url)) = address
237 + && let Ok(value) = http::HeaderValue::from_str(&url)
238 + {
239 + response.headers_mut().insert(header, value);
240 + }
205 241 response
206 242 }
207 243 Err(error) => {
@@ -220,6 +256,38 @@
220 256 }
221 257 }
222 258
259 + /// Which history header this answer earns, and what address it carries.
260 + ///
261 + /// The override first, then the derivation, because the whole point of
262 + /// [`Address`] is to be able to say something the derivation cannot reach.
263 + ///
264 + /// The derivation:
265 + ///
266 + /// - a read answering with a whole screen is a place, at the address it was
267 + /// read from
268 + /// - a write answering with a screen is not: the address is where the form was,
269 + /// and going back to it should not re-offer the write's result as a page
270 + /// - a fragment is not a place, unless it says otherwise. This is where the
271 + /// addressable tab panel says otherwise
272 + /// - a [`Goto`](Outcome::Goto) sets nothing, because
273 + /// [`HX-Location`](htmx::LOCATION) issues the request client-side and htmx
274 + /// pushes for it, and [`HX-Redirect`](htmx::REDIRECT) is a real navigation
275 + fn placement(answer: &Response, asked: &Asked) -> Option<(&'static str, String)> {
276 + match &answer.address {
277 + Some(Address::Enters(url)) => return Some((htmx::PUSH_URL, url.clone())),
278 + Some(Address::Replaces(url)) => return Some((htmx::REPLACE_URL, url.clone())),
279 + Some(Address::Unchanged) => return None,
280 + None => {}
281 + }
282 +
283 + match answer.outcome {
284 + Outcome::Screen(_) if asked.method == Method::Get => {
285 + Some((htmx::PUSH_URL, asked.url.clone()))
286 + }
287 + _ => None,
288 + }
289 + }
290 +
223 291 /// Send the user somewhere instead of answering with content.
224 292 ///
225 293 /// 200 and an empty body, with the whole answer in the header. See
@@ -63,6 +63,13 @@
63 63 .join(",")
64 64 }
65 65
66 + /// What was asked, for a `respond` that has to decide whether the answer is a
67 + /// place. Built through `decode` rather than by hand, so the URL a push carries
68 + /// is the one a real request would have produced.
69 + fn asked(method: &str, uri: &str) -> super::Asked {
70 + super::Asked::new(&read(method, uri, None, "").unwrap())
71 + }
72 +
66 73 /// The body of a response, as text.
67 74 fn text(response: &http::Response<Vec<u8>>) -> String {
68 75 String::from_utf8(response.body().clone()).unwrap()
@@ -231,7 +238,7 @@
231 238 #[test]
232 239 fn a_screen_is_served_whole_and_names_no_target() {
233 240 let screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
234 - let response = respond(&Spy, Ok(screen.into()));
241 + let response = respond(&Spy, Ok(screen.into()), &asked("GET", "/"));
235 242 assert_eq!(response.status(), 200);
236 243 assert_eq!(text(&response), "screen:Home");
237 244 assert!(response.headers().get(super::htmx::RETARGET).is_none());
@@ -240,7 +247,7 @@
240 247 #[test]
241 248 fn a_fragment_carries_the_region_it_replaces() {
242 249 let answer = Response::fragment("detail", Node::text("hello"));
243 - let response = respond(&Spy, Ok(answer));
250 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
244 251 assert_eq!(text(&response), "text:hello");
245 252 // A slot id becomes a CSS selector, which is what htmx wants.
246 253 assert_eq!(
@@ -254,7 +261,7 @@
254 261 // The finding this closes (`80afd652`): deleting the thing a screen is
255 262 // about used to answer with a tombstone, because every response was content.
256 263 let answer = Response::goto(Action::get("/tasks"));
257 - let response = respond(&Spy, Ok(answer));
264 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
258 265 assert_eq!(response.status(), 200);
259 266 assert!(response.body().is_empty());
260 267 assert_eq!(
@@ -269,7 +276,7 @@
269 276 fn a_redirect_keeps_its_params_rather_than_dropping_the_filter() {
270 277 // Back to a filtered list is a different place from back to the list.
271 278 let answer = Response::goto(Action::get("/tasks").with("status", "open"));
272 - let response = respond(&Spy, Ok(answer));
279 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
273 280 assert_eq!(
274 281 response.headers().get(super::htmx::LOCATION).unwrap(),
275 282 "/tasks?status=open"
@@ -279,7 +286,7 @@
279 286 #[test]
280 287 fn a_param_that_needs_encoding_is_encoded_once() {
281 288 let answer = Response::goto(Action::get("/tasks").with("q", "a b&c"));
282 - let response = respond(&Spy, Ok(answer));
289 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
283 290 assert_eq!(
284 291 response.headers().get(super::htmx::LOCATION).unwrap(),
285 292 "/tasks?q=a+b%26c"
@@ -291,7 +298,7 @@
291 298 // `844b5ae0`'s opening half. An external address cannot be a swap, because
292 299 // nothing comes back from it.
293 300 let answer = Response::goto(Action::external("file:///home/max/notes.pdf"));
294 - let response = respond(&Spy, Ok(answer));
301 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
295 302 assert_eq!(
296 303 response.headers().get(super::htmx::REDIRECT).unwrap(),
297 304 "file:///home/max/notes.pdf"
@@ -305,7 +312,7 @@
305 312 // not mistaken for the region's new contents.
306 313 let answer = Response::fragment("detail", Node::text("hello"))
307 314 .toast(quasi_router::layout::Tone::Success, "Saved");
308 - let response = respond(&Spy, Ok(answer));
315 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
309 316 assert_eq!(text(&response), "text:hello");
310 317 assert_eq!(
311 318 response.headers().get(super::htmx::RETARGET).unwrap(),
@@ -323,7 +330,7 @@
323 330 // could not express: a delete both goes elsewhere and says it is gone.
324 331 let answer =
325 332 Response::goto(Action::get("/tasks")).toast(quasi_router::layout::Tone::Success, "Deleted");
326 - let response = respond(&Spy, Ok(answer));
333 + let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
327 334 assert!(response.body().is_empty());
328 335 assert_eq!(
329 336 response.headers().get(super::htmx::LOCATION).unwrap(),
@@ -342,7 +349,11 @@
342 349
343 350 #[test]
344 351 fn a_response_that_says_nothing_sets_no_trigger() {
345 - let response = respond(&Spy, Ok(Response::fragment("detail", Node::text("hello"))));
352 + let response = respond(
353 + &Spy,
354 + Ok(Response::fragment("detail", Node::text("hello"))),
355 + &asked("GET", "/"),
356 + );
346 357 assert!(response.headers().get(super::htmx::TRIGGER).is_none());
347 358 }
348 359
@@ -354,6 +365,7 @@
354 365 &Spy,
355 366 Ok(Response::fragment("detail", Node::text("x"))
356 367 .banner(quasi_router::layout::Tone::Danger, "Sync is down")),
368 + &asked("GET", "/"),
357 369 );
358 370 assert!(
359 371 banner
@@ -374,7 +386,7 @@
374 386 (RouteError::internal("our fault"), 500),
375 387 ] {
376 388 let expected = format!("notice:{:?}:{}", error.tone(), error.message);
377 - let response = respond(&Spy, Err(error));
389 + let response = respond(&Spy, Err(error), &asked("GET", "/"));
378 390 assert_eq!(response.status(), status);
379 391 assert_eq!(text(&response), expected);
380 392 }
@@ -384,7 +396,11 @@
384 396 fn an_error_body_is_sent_rather_than_left_to_the_status() {
385 397 // htmx will drop it unless the page carries `htmx::CONFIG_META`, which is
386 398 // exactly why that constant is not optional.
387 - let response = respond(&Spy, Err(RouteError::denied("not yours")));
399 + let response = respond(
400 + &Spy,
401 + Err(RouteError::denied("not yours")),
402 + &asked("GET", "/"),
403 + );
388 404 assert!(!response.body().is_empty());
389 405 }
390 406
@@ -404,9 +420,99 @@
404 420 }
405 421
406 422 let screen = Screen::sidebar_content("Home");
407 - let response = respond(&Json, Ok(screen.into()));
423 + let response = respond(&Json, Ok(screen.into()), &asked("GET", "/"));
408 424 assert_eq!(
409 425 response.headers().get(http::header::CONTENT_TYPE).unwrap(),
410 426 "application/json"
411 427 );
412 428 }
429 +
430 + /// The two history headers on a response, for the assertions below.
431 + fn history(response: &http::Response<Vec<u8>>) -> (Option<&str>, Option<&str>) {
432 + let get = |name| {
433 + response
434 + .headers()
435 + .get(name)
436 + .map(|value| value.to_str().unwrap())
437 + };
438 + (get(super::htmx::PUSH_URL), get(super::htmx::REPLACE_URL))
439 + }
440 +
441 + #[test]
442 + fn a_read_of_a_screen_is_a_place_and_carries_the_address_it_was_read_from() {
443 + // The common case, and it costs a description nothing: the address is the
444 + // request's own, so no control had to predict what its answer would be.
445 + let screen = Screen::sidebar_content("Projects");
446 + let response = respond(
447 + &Spy,
448 + Ok(screen.into()),
449 + &asked("GET", "/projects?sort=name"),
450 + );
451 +
452 + // Params included. A filtered list is a different place from the list.
453 + assert_eq!(history(&response).0, Some("/projects?sort=name"));
454 + assert_eq!(history(&response).1, None);
455 + }
456 +
457 + #[test]
458 + fn a_write_answering_with_a_screen_is_not_a_place() {
459 + // The address is where the form was. Coming back to it should not re-offer
460 + // the write's result as a page.
461 + let screen = Screen::sidebar_content("Saved");
462 + let response = respond(&Spy, Ok(screen.into()), &asked("POST", "/projects/7"));
463 + assert_eq!(history(&response), (None, None));
464 + }
465 +
466 + #[test]
467 + fn a_fragment_is_not_a_place_unless_it_says_so() {
468 + let plain = respond(
469 + &Spy,
470 + Ok(Response::fragment("detail", Node::text("hello"))),
471 + &asked("GET", "/projects/7/tab/files"),
472 + );
473 + assert_eq!(history(&plain), (None, None));
474 +
475 + // The addressable tab panel: the answer is a fragment and a place, and the
476 + // address is not the route that was fetched. 32 of the server's panels.
477 + let addressed = respond(
478 + &Spy,
479 + Ok(Response::fragment("tab-content", Node::text("hello")).at("/dashboard#tab-projects")),
480 + &asked("GET", "/projects/7/tab/files"),
481 + );
482 + assert_eq!(history(&addressed).0, Some("/dashboard#tab-projects"));
483 + }
484 +
485 + #[test]
486 + fn the_override_can_say_no_as_well_as_yes() {
487 + let screen = Screen::sidebar_content("Transient");
488 +
489 + // A read of a screen that should not come back on the back button.
490 + let suppressed = respond(
491 + &Spy,
492 + Ok(Response::screen(screen.clone()).in_place()),
493 + &asked("GET", "/wizard/step-2"),
494 + );
495 + assert_eq!(history(&suppressed), (None, None));
496 +
497 + // And a place that takes the current entry's slot rather than adding one.
498 + let replaced = respond(
499 + &Spy,
500 + Ok(Response::screen(screen).replacing("/projects?sort=age")),
501 + &asked("GET", "/projects"),
502 + );
503 + assert_eq!(history(&replaced).1, Some("/projects?sort=age"));
504 + assert_eq!(history(&replaced).0, None);
505 + }
506 +
507 + #[test]
508 + fn a_redirect_sets_neither_because_htmx_already_pushes() {
509 + // HX-Location issues the request client-side and htmx pushes for it, and
510 + // HX-Redirect is a real navigation. A second answer here would be two
511 + // parties deciding one thing.
512 + let response = respond(
513 + &Spy,
514 + Ok(Response::goto(Action::get("/projects"))),
515 + &asked("POST", "/projects/7/delete"),
516 + );
517 + assert_eq!(history(&response), (None, None));
518 + }
@@ -107,7 +107,7 @@
107 107
108 108 pub use crate::error::{Class, RouteError};
109 109 pub use crate::request::{Method, Params, Request};
110 - pub use crate::response::{Message, Outcome, Response};
110 + pub use crate::response::{Address, Message, Outcome, Response};
111 111 pub use crate::router::{Handler, Router};
112 112 pub use crate::screen::{
113 113 Act, Action, Cell, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose,
@@ -55,6 +55,36 @@
55 55 pub outcome: Outcome,
56 56 /// What to tell the user, if anything. Independent of the outcome.
57 57 pub notice: Option<Message>,
58 + /// Whether this answer is a place, when the derivation cannot tell.
59 + ///
60 + /// `None` on almost every response, and that is the design. A host derives
61 + /// the common cases from what it already has — a read of a route is a
62 + /// place, a write and a fragment are not — so a control never has to
63 + /// predict what its answer will be. See [`Address`].
64 + pub address: Option<Address>,
65 + }
66 +
67 + /// Whether an answer is somewhere the user can come back to.
68 + ///
69 + /// Decision 7's argument, applied to history: the response says it, because the
70 + /// router is the only party that knows what it just did. The alternative was a
71 + /// flag on [`Action`], decided when the control is rendered, which asks the
72 + /// control to predict the answer — and the MNW server has 24 hand-written
73 + /// `hx-push-url` uses across 13 files showing how that drifts.
74 + ///
75 + /// This is the override and not the mechanism. The host derives history from
76 + /// the request it is answering, and this is for the two cases derivation cannot
77 + /// reach: a fragment that *is* a place (an addressable tab panel, of which the
78 + /// server has 32), and a screen that is not (a transient state that should not
79 + /// come back on the back button).
80 + #[derive(Debug, Clone, PartialEq, Eq)]
81 + pub enum Address {
82 + /// A new place. This URL enters history.
83 + Enters(String),
84 + /// A place, replacing the current entry rather than adding one.
85 + Replaces(String),
86 + /// Not a place. Nothing in the address bar moves.
87 + Unchanged,
58 88 }
59 89
60 90 /// The content half of an answer.
@@ -194,6 +224,38 @@
194 224 self
195 225 }
196 226
227 + /// This answer is a place, at this address.
228 + ///
229 + /// For the answer a derivation cannot reach: a fragment that is a place.
230 + /// A tab panel answers `Response::fragment("tab-content", node)
231 + /// .at("/dashboard#tab-projects")`, which reproduces by construction what
232 + /// the server does by hand today.
233 + #[must_use]
234 + pub fn at(mut self, url: impl Into<String>) -> Self {
235 + self.address = Some(Address::Enters(url.into()));
236 + self
237 + }
238 +
239 + /// This answer is a place, and takes the current entry's slot.
240 + ///
241 + /// For a state the back button should skip: a filter applied over a list,
242 + /// a step within a flow. The address moves and history does not grow.
243 + #[must_use]
244 + pub fn replacing(mut self, url: impl Into<String>) -> Self {
245 + self.address = Some(Address::Replaces(url.into()));
246 + self
247 + }
248 +
249 + /// This answer is not a place, whatever the derivation would have said.
250 + ///
251 + /// The other half of the override: a read of a route is a place by default,
252 + /// and this is how a transient one says it is not.
253 + #[must_use]
254 + pub fn in_place(mut self) -> Self {
255 + self.address = Some(Address::Unchanged);
256 + self
257 + }
258 +
197 259 /// The region being replaced, or `None` for a whole screen or a redirect.
198 260 ///
199 261 /// A webview reads this to set `hx-retarget`. Renderers that repaint
@@ -224,6 +286,7 @@
224 286 Self {
225 287 outcome,
226 288 notice: None,
289 + address: None,
227 290 }
228 291 }
229 292 }
@@ -307,12 +307,23 @@
307 307 // A handler that panics takes down the blocking worker, and with it the
308 308 // responder, and the webview waits forever on a request nobody will answer.
309 309 // A hosted server gets this from its executor; here it has to be caught.
310 + let asked = quasi_http::Asked::new(&incoming);
310 311 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
311 312 context.router.handle(&context.state, incoming.into())
312 313 }))
313 314 .unwrap_or_else(|_| Err(RouteError::internal("the request could not be completed")));
314 315
315 - quasi_http::respond(&*context.render, outcome)
316 + let mut response = quasi_http::respond(&*context.render, outcome, &asked);
317 +
318 + // A Tauri window has no address bar and no back button anyone uses, so a
319 + // history entry here is noise at best. The shared path derives one because
320 + // it is answering an http request and cannot see which coat it is wearing;
321 + // dropping it is cheaper than a flag threaded through for one host, and the
322 + // suppression is visible where the difference actually is.
323 + let headers = response.headers_mut();
324 + headers.remove(quasi_http::htmx::PUSH_URL);
325 + headers.remove(quasi_http::htmx::REPLACE_URL);
326 + response
316 327 }
317 328
318 329 /// Whether this is a legal URL scheme.
@@ -1747,3 +1747,21 @@
1747 1747 assert!(!html.contains("onerror"), "got: {html}");
1748 1748 assert!(!html.contains("javascript:"), "got: {html}");
1749 1749 }
1750 +
1751 + #[test]
1752 + fn no_control_ever_says_whether_its_answer_is_a_place() {
1753 + // History is derived from the answer in quasi-http, not decided by the
1754 + // control at render time. The MNW server has 24 hand-written hx-push-url
1755 + // uses across 13 files, which is what asking the control looks like after
1756 + // a while: each one is a prediction of what a route will do, made by the
1757 + // party that does not know.
1758 + let html = fragment(&Node::Table {
1759 + columns: vec![Column::new("Title").width(layout::Width::Fill)],
1760 + rows: vec![
1761 + Cells::new([Cell::new("Release notes").activate(Action::get("/blog/7"))])
1762 + .activate(Action::get("/blog/7/edit")),
1763 + ],
1764 + });
1765 + assert!(!html.contains("push-url"), "{html}");
1766 + assert!(!html.contains("replace-url"), "{html}");
1767 + }