Skip to main content

max / quasi

Take makeover-webview 0.41.0 and call the emitters that stream The emitter half of the render cost was never makeover's to fix: 0.40.0 already shipped cells_html_into, field_html_into, figure_html_into, figures_html_into, meter_html_into, placeholder_html_into and the two column-class pushers. This crate was still calling the returning form at every one of those sites, so a String per field, per figure, per meter, per placeholder and per table row was being built and immediately appended. All six now write into the buffer they were headed for. 0.41.0 also stops a column name breaking out of the class it names, and that changes what a table emits here in two places. The heading escaped `column_classes` and the cells did not, so a name with punctuation gave the header and its body two different class strings -- the exact disagreement the header comment says it calls makeover to avoid -- and the cell path put the raw name in an attribute. Both now go through push_column_classes and agree, and the escape at the heading is gone: makeover reduces the name to identifier characters rather than escaping it, because narrowing_css writes the same name into a CSS selector, and escaping again would encode nothing while breaking the match. Measured on fw13, release, the same harness as 9c7a81a -- one render of a 25-row list plus a 25-row three-column table against a hand-written push_str renderer of the same markup: before 9c7a81a after 9c7a81a now allocations 1,043 326 164 time/render 15.9us 8.7us 6.5us ratio 9.7x 5.4x 4.0x Under the 5x the perf report set. A table row went from ~10 allocations to ~2.5; what is left is the address per action and one Vec of borrowed cells per row. Output is unchanged except for the column-name classes. Verified by rendering one screen carrying every node kind with hostile strings under both an empty and a set class prefix and diffing against 9c7a81a: the only hunks are `col-a"b<c>d&e'f\g` and its escaped twin in the heading collapsing to `col-a-b-c-d-e-f-g-h-i-j` in both.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 15:25 UTC
Signed with PGP, not checked
Commit: 050b2eb8f028774eb55b77c9667d4dc3241d54eb
Parent: 9c7a81a
3 files changed, +27 insertions, -18 deletions
M Cargo.lock +2 -2
@@ -2334,9 +2334,9 @@
2334 2334
2335 2335 [[package]]
2336 2336 name = "makeover-webview"
2337 - version = "0.40.0"
2337 + version = "0.41.0"
2338 2338 source = "registry+https://github.com/rust-lang/crates.io-index"
2339 - checksum = "f654764729b9b286632d195578fd48c737998809a4a495879d0fd624a09e4db4"
2339 + checksum = "8dce89f8aff6c9b0a91e0f5c601b4321b8f059feee437bc7b570850b5c665e26"
2340 2340 dependencies = [
2341 2341 "makeover-geometry",
2342 2342 "makeover-layout",
@@ -16,7 +16,7 @@
16 16 quasi-router = { path = "../quasi-router", version = "0.5.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.5.0" }
18 18 makeover-layout = "0.23.0"
19 - makeover-webview = "0.40.0"
19 + makeover-webview = "0.41.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -31,8 +31,8 @@
31 31 // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
32 32 // than from a match here, so a tone added upstream cannot be named two ways.
33 33 use makeover_layout::Intent as _;
34 - use makeover_webview::figure::figure_html;
35 - use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html};
34 + use makeover_webview::figure::figure_html_into;
35 + use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html_into};
36 36 // `class`, `option_class` and the two part-class mappings below are makeover's,
37 37 // not copies of it. They were copies until makeover-webview 0.27.0 made them
38 38 // public: the prefix helper was byte-identical, and the row and cell part names
@@ -44,10 +44,10 @@
44 44 // is the described one. Aliased rather than qualified at the call site, so the
45 45 // two never read as the same type.
46 46 use makeover_webview::list::{
47 - Cell as Emitted, cell_part_class, cells_html, column_classes, part_class,
47 + Cell as Emitted, cell_part_class, cells_html_into, part_class, push_column_classes,
48 48 };
49 - use makeover_webview::meter::meter_html;
50 - use makeover_webview::placeholder::placeholder_html;
49 + use makeover_webview::meter::meter_html_into;
50 + use makeover_webview::placeholder::placeholder_html_into;
51 51 use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag};
52 52 use quasi_router::{Action, Method, Params};
53 53
@@ -170,7 +170,7 @@
170 170 }
171 171
172 172 field.with_layout(|borrowed| {
173 - out.push_str(&field_html(&borrowed, &filling, opts));
173 + field_html_into(&borrowed, &filling, opts, out);
174 174 });
175 175
176 176 if writes.is_some() {
@@ -719,7 +719,7 @@
719 719 Node::Rich { source } => out.push_str(&docengine::render_phrase(source)),
720 720 Node::Token(tag) => tag_html(tag, morphs, opts, out),
721 721 Node::Act(act) => act_html(act, morphs, opts, out),
722 - Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
722 + Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
723 723 // Every other leaf the model admits into a run. A link in a row and a
724 724 // figure in a row were the two gaps the enumeration left open and could
725 725 // not close without a `RowPart` variant each; here they arrive by
@@ -933,7 +933,7 @@
933 933 content: Markup(value),
934 934 })
935 935 .collect();
936 - out.push_str(&cells_html(columns, &emitted, opts));
936 + cells_html_into(columns, &emitted, opts, out);
937 937
938 938 out.push_str("</div>");
939 939 }
@@ -985,12 +985,13 @@
985 985 if let Some(act) = act {
986 986 act_html(act, morphs, opts, &mut way_out);
987 987 }
988 - out.push_str(&placeholder_html(
988 + placeholder_html_into(
989 989 *state,
990 990 message,
991 991 (!way_out.is_empty()).then_some(Markup(way_out.as_str())),
992 992 opts,
993 - ));
993 + out,
994 + );
994 995 }
995 996
996 997 Node::Rich { source } => {
@@ -1022,7 +1023,7 @@
1022 1023 // One figure, through the same emitter the strip uses. What differs is
1023 1024 // that nothing wraps it: the run it sits in is already the grouping.
1024 1025 Node::Figure(figure) => {
1025 - out.push_str(&figure_html(&figure.as_layout(), opts));
1026 + figure_html_into(&figure.as_layout(), opts, out);
1026 1027 }
1027 1028
1028 1029 Node::Image(picture) => {
@@ -1193,7 +1194,15 @@
1193 1194 out.push_str(" class=\"");
1194 1195 class_into("table-heading", opts, out);
1195 1196 out.push(' ');
1196 - escape_into(&column_classes(column, opts), out);
1197 + // Not escaped, unlike every other app-supplied string here.
1198 + // makeover reduces a column name to identifier characters
1199 + // rather than escaping it (0.41.0), because the same name is
1200 + // written into a CSS selector by `narrowing_css` and an escaped
1201 + // one would be safe in the attribute and unmatchable from the
1202 + // stylesheet. Escaping the result again would encode nothing
1203 + // and is the one way this heading could stop matching the cells
1204 + // below it.
1205 + push_column_classes(out, column, opts);
1197 1206 out.push('"');
1198 1207 // `aria-sort` is what a table actually says about its order;
1199 1208 // the caret in `state_rules` is this renderer's expression of
@@ -1253,7 +1262,7 @@
1253 1262 // same reason the field markup is: a second emitter here would be the
1254 1263 // same anatomy with a different escaping story, and the CSS it has to
1255 1264 // match is emitted there too.
1256 - Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
1265 + Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
1257 1266
1258 1267 // The strip and its tones are makeover-webview's too, and the actions
1259 1268 // are this crate's: a figure that answers a click becomes a control
@@ -1271,10 +1280,10 @@
1271 1280 class_attr(&["figure-act"], opts, out);
1272 1281 action_attrs(action, Fires::Click, None, morphs, None, out);
1273 1282 out.push('>');
1274 - out.push_str(&figure_html(&figure.as_layout(), opts));
1283 + figure_html_into(&figure.as_layout(), opts, out);
1275 1284 out.push_str(close);
1276 1285 }
1277 - None => out.push_str(&figure_html(&figure.as_layout(), opts)),
1286 + None => figure_html_into(&figure.as_layout(), opts, out),
1278 1287 }
1279 1288 }
1280 1289 out.push_str("</div>");