Measure middle-truncated text width via Canvas instead of forcing reflow - #3667
Measure middle-truncated text width via Canvas instead of forcing reflow#3667tom2drum wants to merge 4 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tom2drum
left a comment
There was a problem hiding this comment.
No spec for this PR — standards + correctness only.
| Id | Severity | Axis | Location |
|---|---|---|---|
| F1 | major | correctness | TruncateMiddle.tsx:118 |
| F2 | nit | standards | TruncateMiddle.tsx:7 |
| F3 | nit | standards | TruncateMiddle.tsx:98 |
Counts: blocker 0 · major 1 · nit 2. Spec — (no spec) · standards 2 · correctness 1.
Outcome: findings.
When the overflow branch ran but there was no room for a HEAD_MIN_LENGTH head before the tail, the binary search never iterated and slice(0, rightI - 1) emitted a string longer than the input (e.g. 'abcd' -> 'abc...abcd'). The canvas measureText path makes this reachable for short real values. Skip truncation in that case and keep the full value. Also drop two diff-narration comment clauses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tom2drum
left a comment
There was a problem hiding this comment.
Review clear
Arbitration: F1–F3 verified. No regressions from the fixes.
Counts: blocker 0 · major 0 · nit 0. Spec — (no spec) · standards 0 · correctness 0.
Outcome: clear
— Reviewed by Cursor Grok 4.6
canvas measureText sums ideal glyph advances and runs ~0.3% wider than the browser's actual sub-pixel text layout, enough to truncate a value that really fits when its full width sits a pixel or two under the container. Read the span's own rendered full-text width in the reflow we already do for the parent box, use it directly for the overflow decision, and scale every canvas measurement in the binary search by the observed ratio so the search matches what the DOM will render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The calibrated canvas search lands within a character of the true fit but can't reproduce the browser's per-glyph rounding, so it occasionally dropped one head character that actually fits (e.g. a truncated sender in a narrow mobile column rendered one symbol shorter than before). After the reflow-free binary search narrows the range, settle the final head length with a couple of real getWidth reads so it is exactly as long as the container allows and matches what the DOM renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Measured effectBenchmarked this branch's Both implementations run in the same build and browser process, with arm order alternated — a Scale20 instances. That's the realistic ceiling, not a round number: list pages lazy-render ~15–20 rows, most use static truncation, and the tables that do use dynamic truncation carry at most one such string per row. An earlier run at 150 instances overstated the effect by ~7.5×. Per-instance mechanism (scale-independent)Exact count, via a patched
The binary search no longer touches layout, but the container-width read and the settle loop still do — hence 4, not 0. Counters — CDP
|
| scenario | metric | main |
branch | delta |
|---|---|---|---|---|
| mount | LayoutCount | 247 | 125 | −49% |
| mount | RecalcStyleCount | 249 | 166 | −33% |
| resize burst | LayoutCount | 976 | 524 | −46% |
| resize burst | RecalcStyleCount | 973 | 660 | −32% |
Durations — interleaved, single page load, CPU throttled 4×
12 phases per arm, 8 resizes per phase:
| metric | main |
branch | delta | main IQR |
branch IQR |
|---|---|---|---|---|---|
| LayoutDuration | 95.2ms | 52.3ms | −45% | 90.2–107.3 | 51.4–57.8 |
| RecalcStyleDuration | 845ms | 407ms | −52% | 747–1331 | 356–644 |
| TaskDuration | 1229ms | 780ms | −37% | 1169–1711 | 742–897 |
| ScriptDuration | 137ms | 146ms | +6% | 130–194 | 132–233 |
No IQR overlap on any row, so the deltas clear the noise. The +6% ScriptDuration is measureText doing the work layout used to do — a good trade, and total TaskDuration still drops 37%.
Estimated effect
Forced style+layout work per user-visible event, de-throttled:
- initial render of a table: ~46ms → ~32ms, saves ~14ms
- one resize: ~29ms → ~14ms, saves ~15ms
Roughly one dropped frame's worth of blocking work per page render and per resize at this scale. Real and consistent, but worth stating plainly: it's a frame, not a page-load transformation. The honest headline is the ratio — about half the forced style+layout work of the previous implementation, per truncated string, at any instance count.
Note that RecalcStyleDuration dominates LayoutDuration in both arms: a forced synchronous layout recalculates style first, and Chakra's generated stylesheet is large. So the cost being cut is forced style+layout, not layout alone — which is why the saving is bigger than "we removed some getBoundingClientRect calls" suggests.
Confidence and caveats
- A null test guards the harness. Replacing this branch's component with
main's makes both arms identical code; counters then collapse to +0.4% (mount) / +2.5% (resize) and forced reads to 7.00 vs 7.00. - That same null test invalidated a first attempt at timings. Comparing durations across separate Playwright tests drifted −24% on
LayoutDurationand −19% onTaskDurationwith identical code in both arms — pure cross-page-load machine drift, which at 20 instances swamps the signal. Hence the interleaved scenario (all phases in one page load, arms alternating ABBA, within-arm IQR reported as the noise floor). Mount durations still come from the weaker separate-run design, so the solid mount result is the count. - Absolutes are inflated. Playwright CT is a Vite dev build with unminified React. Production will be somewhat smaller; a low-end device considerably larger. Ratios and counters transfer; milliseconds should be read as order-of-magnitude.
Possible follow-up
Instances don't batch: each writes textContent then reads, so N instances still force ~N layouts minimum. Batching all instances into a read phase and then a write phase — plus trimming the settle loop — is where the remaining headroom is, and would take the 4 reads/instance closer to 1.
The harness (BenchGrid.tsx, TruncateMiddleBench.pw.tsx, aggregate.mjs, a verbatim copy of main's component, and a README covering the methodology) is not part of this PR — it lives outside the branch. Happy to commit it under src/toolkit/components/truncation/__bench__/ if we want the comparison reproducible, though its .pw.tsx would need excluding from the default Playwright projects.
Description
Resolves #3666
TruncateMiddlecomputed its middle-ellipsis cut point by appending a hidden<span>to the DOM and readinggetBoundingClientRect().widthinside a binary search — each read forcing a synchronous layout reflow, ~log₂(n) times per truncated string, multiplied across every truncated cell on a page (address/hash tables, etc.).The binary search now measures candidate strings with the Canvas 2D
measureText()API, which returns text width without touching layout — zero per-iteration reflow. The font handed to the canvas is read once per recalculation viagetComputedStyleon the rendered span, so it reflects the real resolved cascade +textStyle+ prop overrides (andletter-spacing, folded back in sincemeasureTextreturns advance width only). A single offscreen canvas is shared across all instances.Two deviations from the issue, both found while verifying "visually identical" output:
document.body. The issue asked to narrow it to the component's own parent, but the truncated span sits inside a shrink-to-fit container that doesn't grow when the viewport widens (only an outer block ancestor does), so a parent-scoped observer never fires on widen and the text stays stuck at its narrowest width.Environment variables
None
Minimum API version
None
Breaking or incompatible changes
None
Additional information
Truncation output is visually identical to the previous implementation across fonts, weights, and container widths, verified on the design-system page (
maxW-bounded address links) and the name-services directory table (shrink/widen resize).