Skip to content

Measure middle-truncated text width via Canvas instead of forcing reflow - #3667

Closed
tom2drum wants to merge 4 commits into
mainfrom
worktree-truncate-with-canvas
Closed

Measure middle-truncated text width via Canvas instead of forcing reflow#3667
tom2drum wants to merge 4 commits into
mainfrom
worktree-truncate-with-canvas

Conversation

@tom2drum

Copy link
Copy Markdown
Collaborator

Description

Resolves #3666

TruncateMiddle computed its middle-ellipsis cut point by appending a hidden <span> to the DOM and reading getBoundingClientRect().width inside 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 via getComputedStyle on the rendered span, so it reflects the real resolved cascade + textStyle + prop overrides (and letter-spacing, folded back in since measureText returns advance width only). A single offscreen canvas is shared across all instances.

Two deviations from the issue, both found while verifying "visually identical" output:

  • Container-width read still needs the full-length text momentarily. The old shadow span was doing double duty — appending full text also forced the surrounding shrink-to-fit flex container to expand to its available width before the container box was read. Without that, the parent shrink-wraps the already-truncated string and collapses to the minimum. The span's own text node is now briefly set to the full value for the single container-box read, then restored — no element is appended to the DOM.
  • ResizeObserver stays on 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).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@tom2drum tom2drum left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/toolkit/components/truncation/TruncateMiddle.tsx Outdated
Comment thread src/toolkit/components/truncation/TruncateMiddle.tsx Outdated
Comment thread src/toolkit/components/truncation/TruncateMiddle.tsx Outdated
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 tom2drum left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

tom2drum and others added 2 commits August 26, 2026 15:39
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>
@tom2drum tom2drum added the don't merge PR is in draft, don't merge it label Aug 26, 2026
@tom2drum

Copy link
Copy Markdown
Collaborator Author

Measured effect

Benchmarked this branch's TruncateMiddle against main's, using a Playwright CT harness that renders address-hash rows in the DOM shape AddressEntity produces (flex container minWidth: 0 → inline-flex link → shrink-to-fit overflow: hidden box → the truncated span), so the shrink-to-fit container read both implementations depend on is exercised.

Both implementations run in the same build and browser process, with arm order alternated — a main-vs-branch checkout comparison would fold build flavor and machine state into the delta.

Scale

20 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 Element.prototype.getBoundingClientRect:

layout-forcing reads / instance
main 7.00
this branch 4.00

The binary search no longer touches layout, but the container-width read and the settle loop still do — hence 4, not 0.

Counters — CDP Performance.getMetrics()

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 LayoutDuration and −19% on TaskDuration with 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.

@tom2drum tom2drum closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

don't merge PR is in draft, don't merge it performance refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor TruncateMiddle to measure text width via Canvas instead of forcing reflow

1 participant