max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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, ¶ms, 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 | + | } |