Skip to main content

max / quasi

Fill a bespoke region server-side, from a per-request renderer A public website has no client moment. A bespoke region filled by JS after load is absent from first paint, absent with JS off and absent to a crawler, which is exactly what the one surface in the tree that cannot pay any of those would have had to. So the fill arrives with the document. `Webview` gains `fills`, keyed by slot id rather than by the bespoke name because a page of N rows carrying one shares a single name, and the emitted div carries both. The markup is host code's string, inserted verbatim the way `Shell::head` already is: a description still cannot produce markup, so `Node::Html` stays refused and the escaping guarantee is untouched. `quasi-axum` grows `Adapter::per_request`, which builds the renderer from a factory after dispatch, so the factory sees the params and the answer together and fills only the regions the screen actually has. Nothing in `quasi-http` or `quasi-router` changes, and `Adapter::new` keeps its shared renderer, so the scaffolded template and every client host are untouched.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 13:45 UTC
Signed with PGP, not checked
Commit: 9d37f7b2d5eba36cff7c6ec1c93c8edd2efaca36
Parent: 1f2f512
5 files changed, +345 insertions, -18 deletions
@@ -70,15 +70,38 @@
70 70 use axum::extract::Request;
71 71 use axum::response::Response as HttpResponse;
72 72 use quasi_http::Refusal;
73 - use quasi_router::{RouteError, Router};
73 + use quasi_router::{Params, Response, RouteError, Router};
74 74
75 75 pub use quasi_http::{DEFAULT_BODY_LIMIT, Render, htmx};
76 76
77 + /// Builds the renderer for one request.
78 + ///
79 + /// It is handed the state, the request's parameters and the answer the router
80 + /// gave, and returns the renderer that answer is rendered with. The
81 + /// [`Response`] is there so the factory can walk the screen and do work only
82 + /// for the regions that are actually on it: a factory that highlighted a blob
83 + /// without looking would run syntect for every request that never shows a file.
84 + ///
85 + /// `None` is the router refusing. There is no screen, the answer is a notice
86 + /// fragment, and a renderer built for it has nothing per-request to say.
87 + pub type RenderFactory<S, R> = dyn Fn(&S, &Params, Option<&Response>) -> R + Send + Sync;
88 +
89 + /// Where the renderer for a request comes from.
90 + ///
91 + /// A host with nothing per-request to say shares one, which is what every
92 + /// client-side host wants and what it cost before this existed. A host serving
93 + /// a public website builds one per request, because that is the only moment it
94 + /// knows what the page contains. See [`Adapter::per_request`].
95 + enum Renderers<S, R> {
96 + Shared(Arc<R>),
97 + PerRequest(Box<RenderFactory<S, R>>),
98 + }
99 +
77 100 /// The router, the app's state and a renderer, mounted as an axum service.
78 101 pub struct Adapter<S, R> {
79 102 router: Router<S>,
80 103 state: Arc<S>,
81 - render: Arc<R>,
104 + render: Renderers<S, R>,
82 105 body_limit: usize,
83 106 }
84 107
@@ -86,7 +109,7 @@
86 109 struct Context<S, R> {
87 110 router: Router<S>,
88 111 state: Arc<S>,
89 - render: Arc<R>,
112 + render: Renderers<S, R>,
90 113 body_limit: usize,
91 114 }
92 115
@@ -96,12 +119,67 @@
96 119 R: Render,
97 120 {
98 121 /// Mount a router over this state, rendered by this renderer.
122 + ///
123 + /// One renderer, shared by every request. Right for a host whose renderer
124 + /// is configuration: where the assets live and what the classes are called
125 + /// do not vary by request.
99 126 #[must_use]
100 127 pub fn new(router: Router<S>, state: Arc<S>, render: Arc<R>) -> Self {
101 128 Self {
102 129 router,
103 130 state,
104 - render,
131 + render: Renderers::Shared(render),
132 + body_limit: DEFAULT_BODY_LIMIT,
133 + }
134 + }
135 +
136 + /// Mount a router over this state, building a renderer per request.
137 + ///
138 + /// For the host that has something to say about this page and not about
139 + /// the next one: the markup filling a bespoke region, a per-screen head, a
140 + /// theme block computed from the signed-in reader. A renderer is
141 + /// configuration and an allocation, so building one per request is cheap;
142 + /// what it buys is that the host gets its say at the one moment it knows
143 + /// what the answer contains.
144 + ///
145 + /// This is the only channel for it, and deliberately. A handler is
146 + /// `fn(&S, Params) -> Result<Response, RouteError>` with no request object
147 + /// and no side channel, so anything it computed could only ride in the
148 + /// [`Response`], and host markup in the router's response type would make
149 + /// the router host-aware. So the factory does the work and the handler
150 + /// stops doing it.
151 + ///
152 + /// ```no_run
153 + /// # use std::sync::Arc;
154 + /// # use quasi_axum::Adapter;
155 + /// # use quasi_router::{Outcome, Router};
156 + /// # struct App;
157 + /// # #[derive(Default)] struct Html { fills: std::collections::HashMap<String, String> }
158 + /// # impl quasi_axum::Render for Html {
159 + /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() }
160 + /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() }
161 + /// # }
162 + /// # let quasi = Router::<App>::new();
163 + /// Adapter::per_request(quasi, Arc::new(App), |_app, _params, answer| {
164 + /// let mut render = Html::default();
165 + /// if let Some(Outcome::Screen(screen)) = answer.map(|a| &a.outcome) {
166 + /// for slot in &screen.slots {
167 + /// // Only the regions this screen actually has.
168 + /// render.fills.insert(slot.id.clone(), String::new());
169 + /// }
170 + /// }
171 + /// render
172 + /// });
173 + /// ```
174 + #[must_use]
175 + pub fn per_request<F>(router: Router<S>, state: Arc<S>, factory: F) -> Self
176 + where
177 + F: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
178 + {
179 + Self {
180 + router,
181 + state,
182 + render: Renderers::PerRequest(Box::new(factory)),
105 183 body_limit: DEFAULT_BODY_LIMIT,
106 184 }
107 185 }
@@ -161,6 +239,11 @@
161 239 Err(refusal) => return convert(quasi_http::refuse(refusal)),
162 240 };
163 241
242 + // Kept because the router consumes them and a per-request renderer is
243 + // built after dispatch, when what the factory needs to see is both what
244 + // was asked and what was answered.
245 + let params = incoming.params.clone();
246 +
164 247 // Decision 6: the router is sync. Calling it directly would block the
165 248 // executor for however long the store takes.
166 249 let dispatch = {
@@ -181,7 +264,16 @@
181 264 Err(RouteError::internal("the request could not be completed"))
182 265 });
183 266
184 - convert(quasi_http::respond(&*context.render, outcome))
267 + match &context.render {
268 + Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome)),
269 + Renderers::PerRequest(factory) => {
270 + // Built from the answer rather than before it, so the factory can
271 + // fill the regions this screen has and skip the work for the ones
272 + // it does not.
273 + let render = factory(&context.state, &params, outcome.as_ref().ok());
274 + convert(quasi_http::respond(&render, outcome))
275 + }
276 + }
185 277 }
186 278
187 279 /// An `http` response with a `Vec` body becomes an axum one.
@@ -256,6 +256,79 @@
256 256 );
257 257 }
258 258
259 + /// A renderer carrying something the factory decided for one request.
260 + struct PerRequest {
261 + greeting: String,
262 + }
263 +
264 + impl super::Render for PerRequest {
265 + fn screen(&self, screen: &Screen) -> String {
266 + format!("{}:{}", self.greeting, screen.title)
267 + }
268 +
269 + fn fragment(&self, _node: &Node) -> String {
270 + self.greeting.clone()
271 + }
272 + }
273 +
274 + #[tokio::test]
275 + async fn a_per_request_renderer_sees_the_params_and_the_answer() {
276 + // The whole point of the factory: the host gets its say at the one moment
277 + // it knows both what was asked and what is being answered. Two requests to
278 + // one route, two different renderers.
279 + let router = Router::<App>::new().get("/", home);
280 + let service = super::Adapter::per_request(router, Arc::new(App), |_app, params, answer| {
281 + let title = match answer.map(|a| &a.outcome) {
282 + Some(quasi_router::Outcome::Screen(screen)) => screen.title.clone(),
283 + _ => "none".to_owned(),
284 + };
285 + PerRequest {
286 + greeting: format!("{}/{title}", params.get("who").unwrap_or("nobody")),
287 + }
288 + })
289 + .into_router();
290 +
291 + let first = service.clone().oneshot(get("/?who=ada")).await.unwrap();
292 + let first = first.into_body().collect().await.unwrap().to_bytes();
293 + assert_eq!(String::from_utf8_lossy(&first), "ada/Home:Home");
294 +
295 + let second = service.oneshot(get("/?who=grace")).await.unwrap();
296 + let second = second.into_body().collect().await.unwrap().to_bytes();
297 + assert_eq!(String::from_utf8_lossy(&second), "grace/Home:Home");
298 + }
299 +
300 + #[tokio::test]
301 + async fn a_refusal_reaches_the_factory_with_no_answer_to_read() {
302 + // There is no screen to fill when the router refuses, and the factory is
303 + // told so rather than handed something invented.
304 + let router = Router::<App>::new().post("/task/:id/delete", denied);
305 + let service =
306 + super::Adapter::per_request(router, Arc::new(App), |_app, _params, answer| PerRequest {
307 + greeting: match answer {
308 + Some(_) => "answered".to_owned(),
309 + None => "refused".to_owned(),
310 + },
311 + })
312 + .into_router();
313 +
314 + let response = service
315 + .oneshot(post_form("/task/7/delete", ""))
316 + .await
317 + .unwrap();
318 + assert_eq!(response.status(), StatusCode::FORBIDDEN);
319 + let body = response.into_body().collect().await.unwrap().to_bytes();
320 + assert_eq!(String::from_utf8_lossy(&body), "refused");
321 + }
322 +
323 + #[tokio::test]
324 + async fn a_shared_renderer_still_serves() {
325 + // The change is additive. A host with nothing per-request to say keeps the
326 + // constructor it had.
327 + let (status, body, _) = send(get("/")).await;
328 + assert_eq!(status, StatusCode::OK);
329 + assert_eq!(body, "screen:Home");
330 + }
331 +
259 332 /// The one screen the end-to-end test renders for real.
260 333 ///
261 334 /// Deliberately not the `Spy`. Everything above proves the adapter's own
@@ -42,17 +42,26 @@
42 42 pub use crate::shell::Shell;
43 43 pub use makeover_webview::Emit;
44 44
45 + use std::collections::HashMap;
46 +
45 47 use makeover_layout::Arrangement;
46 48 use quasi_http::Render;
47 49 use quasi_router::{Node, Screen};
48 50
49 51 /// A renderer that answers HTML.
50 52 ///
51 - /// Holds the two things a webview needs and a description never carries: where
52 - /// the host's assets live, and what to prefix class names with. Both are
53 - /// values rather than constants because they are the parts that genuinely
54 - /// differ between an axum route and a Tauri custom-protocol handler, and
55 - /// neither is anything the router can know.
53 + /// Holds the things a webview needs and a description never carries: where the
54 + /// host's assets live, what to prefix class names with, and what goes inside a
55 + /// bespoke region. All values rather than constants because they are the parts
56 + /// that genuinely differ between an axum route and a Tauri custom-protocol
57 + /// handler, and none of them is anything the router can know.
58 + ///
59 + /// # Why a renderer is cheap
60 + ///
61 + /// Three fields, two of them usually shared configuration. A host with
62 + /// something per-request to say builds one per request — that is what
63 + /// [`fills`](Self::fills) is for, and it is an allocation rather than a
64 + /// rebuild. `quasi-axum` takes a factory for exactly this.
56 65 #[derive(Debug, Clone, Default)]
57 66 pub struct Webview {
58 67 /// The document around a screen.
@@ -60,6 +69,22 @@
60 69 /// Class naming, shared with `makeover-webview`'s stylesheet half so the
61 70 /// emitted markup and the emitted CSS agree on every name.
62 71 pub emit: Emit,
72 + /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot).
73 + ///
74 + /// Markup, inserted verbatim and unescaped, exactly as
75 + /// [`Shell::head`] is. It is host code's string: the description never sees
76 + /// it, never carries it and cannot be made to produce one. That is what
77 + /// keeps decision 4 intact while giving a server-rendered page something to
78 + /// serve, and it is why `Node::Html` is still refused.
79 + ///
80 + /// Keyed by slot id and not by the bespoke name, because a page of N rows
81 + /// each carrying a fill shares one name and has N ids. The emitted div
82 + /// carries both.
83 + ///
84 + /// A slot with no entry here renders empty, which is what every client host
85 + /// relies on. An entry naming an id the screen does not have is ignored
86 + /// rather than appended anywhere.
87 + pub fills: HashMap<String, String>,
63 88 }
64 89
65 90 impl Webview {
@@ -74,7 +99,7 @@
74 99 pub fn under(prefix: &str) -> Self {
75 100 Self {
76 101 shell: Shell::under(prefix),
77 - emit: Emit::default(),
102 + ..Self::default()
78 103 }
79 104 }
80 105
@@ -92,6 +117,17 @@
92 117 self
93 118 }
94 119
120 + /// Fill the bespoke region with this slot id, chaining. Not escaped.
121 + ///
122 + /// Repeated calls for one id replace, rather than appending the way
123 + /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill
124 + /// is one region's whole contents.
125 + #[must_use]
126 + pub fn with_fill(mut self, slot_id: impl Into<String>, markup: impl Into<String>) -> Self {
127 + self.fills.insert(slot_id.into(), markup.into());
128 + self
129 + }
130 +
95 131 /// The class naming the arrangement of a screen's regions.
96 132 ///
97 133 /// Two, because our apps have two. A third arrives when an app has one,
@@ -127,13 +163,19 @@
127 163 out.push_str(&node::class("notices", &self.emit));
128 164 out.push_str("\">");
129 165 for notice in &screen.notices {
130 - node::node_html(notice, self.shell.morphs(), &self.emit, &mut out);
166 + node::node_html(
167 + notice,
168 + self.shell.morphs(),
169 + &self.emit,
170 + &self.fills,
171 + &mut out,
172 + );
131 173 }
132 174 out.push_str("</div>");
133 175 }
134 176
135 177 for slot in &screen.slots {
136 - node::slot_html(slot, self.shell.morphs(), &self.emit, &mut out);
178 + node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out);
137 179 }
138 180
139 181 out.push_str("</main>");
@@ -146,7 +188,7 @@
146 188 // htmx puts it there. The router already said which element through
147 189 // `HX-Retarget`, so nothing here needs to know.
148 190 let mut out = String::with_capacity(256);
149 - node::node_html(node, self.shell.morphs(), &self.emit, &mut out);
191 + node::node_html(node, self.shell.morphs(), &self.emit, &self.fills, &mut out);
150 192 out
151 193 }
152 194 }
@@ -19,6 +19,7 @@
19 19 //! is the only party that knows what it just changed. A control that also named
20 20 //! a target would be a second party deciding one thing.
21 21
22 + use std::collections::HashMap;
22 23 use std::fmt::Write as _;
23 24
24 25 use makeover_layout as layout;
@@ -533,7 +534,13 @@
533 534 }
534 535
535 536 /// One thing on a screen.
536 - pub(crate) fn node_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
537 + pub(crate) fn node_html(
538 + node: &Node,
539 + morphs: bool,
540 + opts: &Emit,
541 + fills: &HashMap<String, String>,
542 + out: &mut String,
543 + ) {
537 544 match node {
538 545 Node::Heading { level, text } => {
539 546 let tag = match level {
@@ -780,7 +787,7 @@
780 787 out.push_str("</div>");
781 788 }
782 789
783 - Node::Region(slot) => slot_html(slot, morphs, opts, out),
790 + Node::Region(slot) => slot_html(slot, morphs, opts, fills, out),
784 791 }
785 792 }
786 793
@@ -910,7 +917,13 @@
910 917 }
911 918
912 919 /// A region, and everything under it.
913 - pub(crate) fn slot_html(slot: &Slot, morphs: bool, opts: &Emit, out: &mut String) {
920 + pub(crate) fn slot_html(
921 + slot: &Slot,
922 + morphs: bool,
923 + opts: &Emit,
924 + fills: &HashMap<String, String>,
925 + out: &mut String,
926 + ) {
914 927 let kind = match &slot.kind {
915 928 quasi_router::RegionKind::Band => "band",
916 929 quasi_router::RegionKind::Sidebar => "sidebar",
@@ -949,7 +962,21 @@
949 962
950 963 out.push('>');
951 964 for node in &slot.body {
952 - node_html(node, morphs, opts, out);
965 + node_html(node, morphs, opts, fills, out);
953 966 }
967 +
968 + // The host's markup for this region, after whatever the description put
969 + // here, and only for a bespoke one: a fill named against a pane is a host
970 + // reaching into a region the description already owns.
971 + //
972 + // Verbatim. See `Webview::fills` for why that is not the hole it looks
973 + // like: the string is host code's, never a description's, so the escaping
974 + // guarantee the whole vocabulary rests on is untouched.
975 + if matches!(slot.kind, quasi_router::RegionKind::Bespoke { .. })
976 + && let Some(fill) = fills.get(&slot.id)
977 + {
978 + out.push_str(fill);
979 + }
980 +
954 981 out.push_str("</div>");
955 982 }
@@ -424,6 +424,99 @@
424 424 assert!(html.contains("data-bespoke=\"media-player\"></div>"));
425 425 }
426 426
427 + #[test]
428 + fn a_bespoke_region_the_host_filled_carries_its_markup() {
429 + // The server case: there is no client moment, so the fill has to be in the
430 + // bytes the browser gets or it is absent from first paint, absent with JS
431 + // off and absent to a crawler.
432 + let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
433 + let html = Webview::new()
434 + .with_fill("file", "<pre class=\"hl\">fn main() {}</pre>")
435 + .screen(&screen);
436 +
437 + assert!(
438 + html.contains("<pre class=\"hl\">fn main() {}</pre></div>"),
439 + "{html}"
440 + );
441 + }
442 +
443 + #[test]
444 + fn a_bespoke_region_with_no_fill_is_still_empty() {
445 + // What every client host relies on: the div is a place, and a host that
446 + // fills one region has not changed what the others are.
447 + let screen = Screen::list_detail("Files", false)
448 + .with(Slot::bespoke("file", "git-file"))
449 + .with(Slot::bespoke("player", "media-player"));
450 + let html = Webview::new()
451 + .with_fill("file", "<pre></pre>")
452 + .screen(&screen);
453 +
454 + assert!(
455 + html.contains("data-bespoke=\"media-player\"></div>"),
456 + "{html}"
457 + );
458 + }
459 +
460 + #[test]
461 + fn two_regions_sharing_a_name_are_filled_by_id() {
462 + // The re-entrancy case, and the reason fills are keyed by slot id: a page
463 + // of N rows each carrying one shares a single bespoke name.
464 + let screen = Screen::list_detail("Files", false)
465 + .with(Slot::bespoke("row-1", "diff"))
466 + .with(Slot::bespoke("row-2", "diff"));
467 + let html = Webview::new()
468 + .with_fill("row-1", "<i>one</i>")
469 + .with_fill("row-2", "<i>two</i>")
470 + .screen(&screen);
471 +
472 + assert!(html.contains("id=\"row-1\""), "{html}");
473 + let one = html.find("<i>one</i>").expect("the first row is filled");
474 + let two = html.find("<i>two</i>").expect("the second row is filled");
475 + assert!(one < two);
476 + assert!(html.find("id=\"row-2\"").expect("the second row exists") < two);
477 + }
478 +
479 + #[test]
480 + fn a_fill_is_markup_and_is_not_escaped() {
481 + // The one string in this renderer that is not escaped, and the reason it
482 + // is safe: it comes from host code, never from a description. A
483 + // description still cannot produce markup, which is what `Node::Html` was
484 + // refused to protect.
485 + let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
486 + let html = Webview::new()
487 + .with_fill("file", "<span data-x=\"1\">&amp;</span>")
488 + .screen(&screen);
489 +
490 + assert!(html.contains("<span data-x=\"1\">&amp;</span>"), "{html}");
491 + assert!(!html.contains("&lt;span"), "{html}");
492 + }
493 +
494 + #[test]
495 + fn a_fill_for_a_region_the_screen_lacks_goes_nowhere() {
496 + // Ignored rather than appended somewhere. A renderer built for one screen
497 + // and handed another emits that other screen unchanged.
498 + let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file"));
499 + let plain = render(&screen);
500 + let html = Webview::new()
501 + .with_fill("absent", "<b>stray</b>")
502 + .screen(&screen);
503 +
504 + assert!(!html.contains("stray"), "{html}");
505 + assert_eq!(html, plain);
506 + }
507 +
508 + #[test]
509 + fn a_fill_is_only_for_a_bespoke_region() {
510 + // A pane's contents are the description's. A host reaching into one is
511 + // reaching past the vocabulary rather than into the space it was given.
512 + let screen = Screen::list_detail("Files", false).with(Slot::new("detail", RegionKind::Pane));
513 + let html = Webview::new()
514 + .with_fill("detail", "<b>stray</b>")
515 + .screen(&screen);
516 +
517 + assert!(!html.contains("stray"), "{html}");
518 + }
519 +
427 520 #[test]
428 521 fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() {
429 522 let screen = Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar));