Skip to content

feat(react): wrap estimated rows by measured segment widths (47/48 -> 48/48 line counts, 3.500px -> 3.083px) - #367

Merged
blove merged 9 commits into
mainfrom
blove/phase-b-segment-text
Aug 13, 2026
Merged

blove merged 9 commits into
mainfrom
blove/phase-b-segment-text

Conversation

@blove

@blove blove commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The gate: PASS

Both measured over the same 48 rows captured from a real Chromium session on the homepage hero, same font on both sides, the only variable being average-arithmetic versus accumulated measured widths.

average width measured segments
line counts 47/48 48/48
mean |estimate − measured| 3.500px 3.083px

Line counts >= 47/48 and mean error below 3.5px, so: PASS. The one row still mispredicted at 47/48 — Correlates 0.71 with NVDA. Combined AI-compute exposure 15.3% … at 404px, predicted 2 where Chromium drew 3 — is the digit-heavy string the average was always going to get wrong, and it is the one segment measurement fixed.

My recorded prediction, and whether it held

Recorded in 6017db9e before the wired instrument was run:

The bias will barely move and the sign will not change. […] Expected: no-floor relative extent error -12.2201% -> around -11.7%, still double-digit negative; directional split still ~42-43 under and 0 over; no-floor mean |error| down about 21/48 = 0.44px; the learned floor unchanged.

It held, to the pixel. No-floor extent went −12.2201% → −11.6536%, the split stayed 0 over / 43 under, no-floor mean error fell 9.4375 → 9.0000px (0.4375 = exactly 21/48), and the learned floor stayed at 68px. The total movement is 21px across all 48 rows — one line box, which is the one line count that changed.

The reasoning behind the prediction was that segment measurement can only move a height by moving a line count, because the per-line arithmetic (21px line height, 25px chrome) comes from HERO_ROW_BOX_METRICS and this phase does not touch it. That is what the numbers say happened.

The bias comparison, both paths, all three floor policies

Verbatim from row-height-bias.test.ts:

max floor (as shipped):
                                 average      measured
  floor px                       68.0000       68.0000
  Σestimate px                 3649.0000     3669.0000
  Σmeasured px                 3707.0000     3707.0000
  signed aggregate px           -58.0000      -38.0000
  relative extent %              -1.5646       -1.0251
  mean |error| per row px         3.5000        3.0833
  directional split             11/13/24      11/13/24   (over/under/exact of 48)
mean floor (counterfactual, computed in this test):
                                 average      measured
  floor px                       66.3333       66.3333
  Σestimate px                 3602.3333     3623.3333
  Σmeasured px                 3707.0000     3707.0000
  signed aggregate px          -104.6667      -83.6667
  relative extent %              -2.8235       -2.2570
  mean |error| per row px         3.7083        3.2708
  directional split              11/32/5       11/32/5   (over/under/exact of 48)
no calibration at all (reference):
                                 average      measured
  floor px                          none          none
  Σestimate px                 3254.0000     3275.0000
  Σmeasured px                 3707.0000     3707.0000
  signed aggregate px          -453.0000     -432.0000
  relative extent %             -12.2201      -11.6536
  mean |error| per row px         9.4375        9.0000
  directional split               0/43/5        0/43/5   (over/under/exact of 48)

The finding that matters more than the gate

The ~1px-per-line shortfall is not character-mix error. With no floor, 43 of 48 rows under-estimate and none over-estimates — before and after. Segment measurement is the strongest available fix for character-mix error, and it moved that split by zero rows and the aggregate by one line box out of 453px. The residual lives somewhere else: the per-line height arithmetic, or inline content the estimator is structurally blind to (the stance badge, the two-line dayPnl renderer). The next round should go there, not at text measurement.

The other thing this exposes: "keep the max floor" still rests on a cancellation. The max is biased high by construction and is offsetting a larger negative bias; Phase B removed 21px of that negative bias and the offset is still doing the work.

Closing two gaps first

The bias instrument was measuring the average path only (6017db9e). Nothing passed it a measurer, so the one question this phase exists to answer was invisible to the instrument built to see systematic error. Every policy is now reported twice, average against measured, with each floor re-learned per path — the floor admits rows predicted at ≤1 line, so the two paths are entitled to different ones. A second test keeps the right-hand column from going vacuous.

pretable-model.ts's wiring was untested (8f93c7d6). Deleting any of getAverageCharWidthPx, getRowBoxMetrics, getSegmentMeasurer or getLetterSpacingPx type-checked, linted, and broke no test in the repo — three phases of this series shipped through that gap.

It could not be tested through a rendered row, and the new file records why rather than leaving it to be rediscovered: the real getters return null without a canvas, and even mocked the effect is unobservable, because only rows inside the planned viewport are estimated at all, every row past it carries defaultRowHeight, and the few that are estimated are measured within a frame at zero and floored back to the theme height. Measured directly, all four configurations produce an identical 17600px scroll extent.

So it asserts one seam closer: createRowLayoutController is wrapped, the real controller still built and still driving the render, and the options object the model passed is captured and each getter invoked through it — by identity for the box and the measurer, since the estimate memo compares both by reference.

Mutation results, each line deleted in turn then restored:

deleted line result
getAverageCharWidthPx: () => getGridAverageCharWidth() 2 failed / 3 passed
getRowBoxMetrics: () => getGridRowBoxMetrics() 2 failed / 3 passed
getSegmentMeasurer: () => getGridSegmentMeasurer() 2 failed / 3 passed
getLetterSpacingPx: () => getGridLetterSpacingPx() 2 failed / 3 passed

The controller's half of the chain stays pinned in renderer-dom's indexed-renderer.test.ts.

Attribution

LICENSE gains an acknowledgements section crediting @chenglou/pretext (MIT, © Pretext contributors, https://github.com/chenglou/pretext) for the two-phase design: measure each segment once, cache by (segment, font), then decide line breaks with pure arithmetic over the cached widths.

Checked before claiming courtesy rather than obligation. Every file in text-core/src plus react/src/text-metrics.ts was read against pretext's measurement.ts, layout.ts, line-break.ts, line-text.ts and analysis.ts. Verdict: independent, no file needs pretext's copyright line carried into it. No copied code, no line-by-line translation, no shared constants or tables (grepped lineFitEpsilon, kinsoku, SegmentMetrics, tabStopAdvance, 1/64, 0.005 — all clean), and the algorithms differ in kind:

  • pretable wraps three token kinds (word/space/newline) with a flat accumulator; pretext walks a fifteen-member SegmentBreakKind union with a cursor over eight parallel arrays.
  • Over-wide words: pretable divides by token-level average density; pretext pre-measures real per-grapheme advances in three modes and steps them.
  • Letter spacing is an outright opposite model — pretable charges every grapheme including a line's last, pretext charges n − 1 and handles the ends separately.

One overlap is disclosed rather than left implicit. getMeasuringContext probes OffscreenCanvas then a detached <canvas>, in that order, as pretext's getMeasureContext does. That is the conventional (essentially the only) way to reach a measuring context, and the divergence is now noted in the comment: pretext throws where this returns null, so an unmeasurable host keeps the average width instead of losing its estimates.

Verification

Bench, no regression. Baseline runset 2026-08-13t04-27-03-476z against this branch's 2026-08-13t19-58-44-744z, row_height_error_p95_px:

scenario baseline this branch
S1 scroll 1 1
S2 scroll 4 4
S3 scroll 1 1
S7 scroll 4 4

Identical. Worth recording how: the first attempt was invalid and thrown away. playwright.config.ts sets reuseExistingServer: false, but a parallel session in another worktree already held port 4173, so vite preview failed to bind, Playwright found the port answering anyway, and the run measured that worktree's build. The reported run used PRETABLE_BENCH_EXTERNAL_SERVER=1 against this worktree's own preview on 4187. Machine load was 8-9 throughout, which can only inflate these numbers, and they did not move.

Hero symptom check. Production build, served locally, data-pretable-row-height sampled once a second for 8s in Chromium at 1440x900:

t+1s  distinct=[63, 68]      t+5s  distinct=[63, 89]
t+2s  distinct=[63, 68]      t+6s  distinct=[63, 68, 89]
t+3s  distinct=[63, 89]      t+7s  distinct=[63, 89]
t+4s  distinct=[63, 89]      t+8s  distinct=[63, 89]

Three distinct heights, tracking the streamed commentary as it wraps. No first-paint shrink. The probe was a throwaway outside the repo and is deleted; the server is stopped.

apps/website e2e/smoke.spec.ts: 26 passed (chromium + webkit), against the local production build.

Suites: text-core 41/41, renderer-dom 85/85, react 993/993, app-bench 128/128.

Changeset (@pretable/react, patch), then pnpm typecheck && pnpm lint && pnpm format clean — the one lint warning is the pre-existing TanStack Virtual notice in the bench adapter — then pnpm build && pnpm api:check in that order, both clean. No public API change.

main did not move: origin/main is still 5f6119cb, and it is an ancestor of this branch.

Not done

Not merged, per the task.

🤖 Generated with Claude Code

blove and others added 9 commits August 13, 2026 12:06
prepareText counted characters with Array.from — code points. A ZWJ emoji
sequence counted 7 and a combining-mark cluster 2, and those counts feed
graphemeCount, the breakpoint scan and token lengths, which is what the
wrap arithmetic divides by. Non-ASCII text therefore predicted too many
lines.

Segment with Intl.Segmenter at granularity "grapheme", memoized at module
scope (constructing one per call is expensive and this is on the estimate
path), falling back to Array.from where Segmenter is unavailable.

ASCII is unaffected: the estimator accuracy instrument stays at 47/48 line
counts and 3.5px mean error, and text-core's existing tests pass unedited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…upplied

`PrepareTextInput` gains an optional `measureSegment(segment) => px`. The
caller owns the font; text-core still never sees one, so the package stays
DOM-free and unit-testable.

Given a measurer, `prepareText` measures each *distinct* token once and
attaches `tokenWidthsPx` to the prepared record, index-aligned with `tokens`.
Its presence is what switches `layoutPreparedText` onto a pixel path that
accumulates advance widths against the available width; without it, both
functions behave exactly as before and the record does not even carry the key.

The pixel path is a twin of the character path, and the bridge test pins that:
a measurer returning `averageCharWidth × graphemeCount` produces identical line
counts across 8 texts × 16 widths. Making that hold required two things worth
naming — an over-wide word is split by its own average density, since one
token-level measurement says nothing about where its graphemes fall, and the
fit comparison carries a sub-subpixel epsilon so float accumulation cannot wrap
a line that fits exactly.

Zero and negative widths clamp to zero rather than being divided by, matching
the character path's `Math.max(1, ...)`.

The estimator accuracy instrument is unchanged at 47/48 line counts and 3.5px
mean error — nothing supplies a measurer yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PrepareTextInput` gains `letterSpacingPx`. It folds into
`averageCharWidth` on the average path and into each `tokenWidthsPx`
entry on the measured path, so the two paths keep agreeing.

CSS charges the spacing to every grapheme, a line's last included. That
was measured, not assumed: a Playwright probe at `20px monospace` with
`letter-spacing: 10px` and the 11-grapheme string "aaaaa aaaaa" gave an
inline width exactly 110px wider than unspaced in Chromium, WebKit and
Firefox, and all three needed ~242px (11 x 22) to keep it on one line —
at ~232px, the trailing-trimmed prediction, it wrapped. No divergence.

The bridge test now runs every case at four letter spacings, including a
negative one; trimming the trailing spacing on the pixel path fails it.
`undefined` and `0` are asserted identical to today on both paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Left unformatted by an earlier commit on this branch; the repo's format check
runs prettier over everything, so it would have failed CI at the phase gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`layoutPreparedText` gains `wrapMode: "pre-wrap"` alongside `wrap` and
`nowrap`. Preserved whitespace is charged to the line it starts on: a
space run is never dropped at the start of a line, never moves down to
the next one, and never breaks the line by itself — it hangs past the
edge and the word that follows makes the break decision.

That rule was measured, not assumed. A Playwright probe at
`font: 20px monospace` in Chromium 151.0.7922.34, WebKit 26.5 and
Firefox 153.0 read line boxes back per grapheme: `"aa      aa"` is two
lines at every container from 2 to 9 advances and one at 10, with the
run hanging 72px past a 24px box. All three engines agreed on every
case; the one apparent divergence ("  aa aa" at exactly 4 advances,
Chromium 3 lines against 2) is Chromium measuring a space ~0.015px
wider than a letter, and it goes away at 4.5 advances.

Trailing whitespace is charged toward `measuredWidth`, matching what
`wrap` already does, with the existing clamp to the available width
standing in for the browser's content box.

The bridge test now runs `pre-wrap` as well as `wrap` — 8 texts × 4
letter spacings × 16 widths × 2 modes = 1024 comparisons, pinned as a
literal. `wrap` and `nowrap` are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`text-core` gained an optional `measureSegment` in Tasks 1-4 and nothing
supplied one, so the estimator still modelled a font as a single average
advance. This supplies it: `@pretable/react` measures each token on the
SAME canvas context `measureAverageCharWidth` already uses, caches by
`(segment, font)`, and hands the estimator a measurer bound to the font
the grid is actually drawing in. `letter-spacing` comes off the same
computed style and is charged per grapheme on both paths.

On the hero accuracy fixture — 48 rows measured in a real browser, both
columns reading the SAME font — line counts go 47/48 -> 48/48 and mean
height error 3.500px -> 3.083px. Reported, not tuned; the gate verdict
is Task 6's.

One context and one observer, as required:

- The DOM read is now shared. Font, letter spacing and sample text come
  off one `querySelector` + `getComputedStyle` per theme change, for all
  three consumers; `density.test.ts` pins it at two reads per swap (one
  box, one text style) for four getters called 50 times each.
- Invalidation runs through `density.ts`'s existing MutationObserver.
  `invalidateGridAverageCharWidth` is renamed `invalidateGridTextMetrics`
  since it now covers more than the width. Per-segment widths are NOT
  dropped on a swap: they are keyed by font, so a font change measures
  under a new key and a swap that keeps the font would re-measure
  identical numbers.

The cache is bounded two ways, because grid text is unbounded in
principle and this sits on the per-estimate path: 4096 segments per font
and 8 fonts, both evicted in insertion order rather than by LRU —
keeping recency would mean a delete plus a set on every cache HIT, which
is the path the cache exists to make cheap.

The measurer and `letterSpacingPx` join the estimate memo key, the
former by identity (one function per font) and the latter by value.
Mutation-checked: dropping either from EITHER cache-hit branch fails a
test, as does dropping either from the `prepareText` input.

Performance, which is where the previous regression in this series
surfaced: repeated estimates over the same content do not grow the
`measureText` call count (react) or the measurer call count
(renderer-dom). app-bench stays at 128/128.

The Node-side accuracy test cannot measure — no canvas — so the
fixture now carries captured widths for all 102 tokens its samples
tokenize into, taken in headless Chromium with the analyst cell's
computed font shorthand. Provenance cross-check: the 762-grapheme corpus
`HERO_AVERAGE_CHAR_WIDTH_PX` was averaged over measures
4957.4423828125px in that session against 4956.8955078125px recorded in
the original one — 0.011 per cent apart, recorded rather than smoothed
over. A missing token throws rather than measuring zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bias instrument reported signed scroll-extent error for three floor
policies, and every number it printed was the average-character-width path.
Nothing passed it a measurer, so the one question Phase B exists to answer —
does measuring segments move the systematic per-line shortfall? — was invisible
to the instrument built to see systematic error.

Every policy is now reported twice, average path against measured path, over
the same font: `HERO_AVERAGE_CHAR_WIDTH_PX` on the left, `measureHeroSegment`
on the right. The average column is unchanged; the comparison is the point.
Each floor is re-learned per path, because the floor admits rows the estimator
predicts at <= 1 line and the two paths are entitled to different ones.

A second test keeps the right-hand column from going vacuous: a dropped
argument anywhere on the way to `prepareText` would print two identical columns
and read as "segment measurement changed nothing", which is one of the
conclusions this file is used to draw.

PREDICTION, recorded before running it
--------------------------------------

The bias will barely move and the sign will not change. Segment measurement
changes only WHERE a line breaks; the per-line height arithmetic (21px line
height, 25px chrome) comes from HERO_ROW_BOX_METRICS and this phase does not
touch it. So the only way it can move a height is by moving a line count, and
it moves exactly one (47/48 -> 48/48). One 21px line over a ~3700px extent is
about 4.6% of the no-floor shortfall.

Expected: no-floor relative extent error -12.2201% -> around -11.7%, still
double-digit negative; directional split still ~42-43 under and 0 over; no-floor
mean |error| down about 21/48 = 0.44px; the learned floor unchanged, because the
row that flips is a 2->3 row, well clear of the floor's <=1 admission threshold.

Expected finding: that the ~1px-per-line shortfall is NOT character-mix error,
which localises it to the per-line height arithmetic or to unmodellable inline
content, and redirects the next round away from text measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pretable-model.ts` builds the row layout controller with
`getAverageCharWidthPx`, `getRowBoxMetrics`, `getSegmentMeasurer` and
`getLetterSpacingPx`. All four options are optional on the controller, so
deleting any of them type-checked, linted, and broke no test: the estimator
reverted to its pre-measurement constants and every suite stayed green. Three
phases of this series shipped through that gap.

The obvious test cannot be written in jsdom, and the file records why rather
than leaving it to be rediscovered. The real getters return null without a
canvas; and even mocked, the effect is unobservable — only rows inside the
planned viewport are estimated at all, every row past it carries
`defaultRowHeight`, and the few that are estimated are measured within a frame
at zero and floored back to the theme height. Measured directly, all four
configurations produce an identical 17600px scroll extent.

So it asserts one seam closer: `createRowLayoutController` is wrapped, the real
controller still built and still driving the render, and the options object the
model passed is captured and each getter invoked through it. Identity rather
than equality for the box and the measurer, because the estimate memo compares
both by reference.

Mutation-checked: each of the four lines deleted in turn, each turning its own
test plus the absent-safe test red, then restored.

The other half of the chain — that the controller consults these options per
estimate — stays pinned in renderer-dom's `indexed-renderer.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`@chenglou/pretext` (MIT, © Pretext contributors) is where the two-phase design
this phase implements comes from: measure each segment once, cache by
(segment, font), then decide line breaks with pure arithmetic over the cached
widths.

Checked before claiming courtesy rather than obligation. Every file in
`text-core/src` and `react/src/text-metrics.ts` was compared against pretext's
`measurement.ts`, `layout.ts`, `line-break.ts`, `line-text.ts` and
`analysis.ts`: no copied code, no line-by-line translation, no shared constants
or tables, and the algorithms differ in kind — pretable wraps three token kinds
with a flat accumulator, pretext walks fifteen segment-break kinds with a
cursor; pretable charges letter-spacing to every grapheme, pretext charges
`n - 1` and handles the ends separately. Nothing needs pretext's copyright line
carried into it.

One overlap is disclosed rather than left implicit: `getMeasuringContext`
probes `OffscreenCanvas` then a detached `<canvas>`, in that order, as pretext
does. That is the conventional way to reach a measuring context, and the
divergence is recorded in the comment — pretext throws where this returns null.

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

vercel Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pretable Ready Ready Preview Aug 13, 2026 8:01pm

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-akze7j4tg-cacheplane.vercel.app
Commit: 920de385527edc0bade524f891b222361c35657a

Updated automatically by the deploy-preview job.

@blove
blove merged commit 2c30944 into main Aug 13, 2026
17 checks passed
@blove
blove deleted the blove/phase-b-segment-text branch August 13, 2026 20:13
blove added a commit that referenced this pull request Aug 13, 2026
…per-line arithmetic) (#370)

`row-height-bias.test.ts` reports 43/48 rows under-estimating for -432px of
signed extent with the floor disabled, and #367 moved that split by zero rows.
This decomposes it. Diagnostic only: no production code changes, and no cause
is asserted — the console output is the deliverable.

Of the -432px:

  -236px (55%, 12 rows)  the analyst cell's `render` appends an inline-flex
                         stance badge. It takes ~59-65px of horizontal space
                         and pushes the text onto a line the estimator, which
                         wraps the raw value only, cannot predict.
  -165px (38%, 11 rows)  rows measuring 63px, decided by a different cell
                         entirely. That is the learned floor's job, and this
                         experiment disables the floor.
   -31px  (7%, 31 rows)  the arithmetic, with the rendered line count correct:
                         exactly -1 on every 2- and 3-line row, exactly 0 on
                         every 4-line row.

The last term fits neither "constant chrome" nor "per-line shortfall". The
measured heights bound the real line advance at 20 < a < 21 (strictly under the
21px the estimator reads from the CELL), which is what `.analyst`'s
`line-height: 1.45` on a 14px font gives: 20.3px. The cell is `display: flex`,
so that span is a flex item and its own line-height governs the line boxes.

Two cross-checks that could each have failed: the rendered line count inverted
out of the measured height lands in {predicted, predicted+1} for 48/48 rows,
and a single badge advance derived from horizontal slack alone reproduces the
same assignment over a non-empty interval, (58.6133, 64.8164]px.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
blove added a commit that referenced this pull request Aug 15, 2026
`prepareText` segmented the whole string into graphemes, then `tokenizeText`
re-segmented every token via `countGraphemes` purely to fill each token's
`length`. Every character was segmented twice. A CPU profile of an S2 wrapped
scroll put `segmentGraphemes` at 193ms of self time in a 761ms window — 25.4%,
roughly 3x TanStack's entire library-side JS cost for the same scroll.

Token lengths now come from the grapheme array `prepareText` already built.

The mapping is not the obvious one, and the obvious one is wrong. A grapheme
cluster can straddle a token boundary: in "a ␣́b" the space and the following
combining acute are ONE cluster, while the tokenizer's regex splits inside it.
The old code called `countGraphemes` per token and so charged that cluster to
BOTH sides. Mapping each grapheme to the token containing its start offset
gives different answers and would have silently changed wrapping for combining
marks. A grapheme is therefore counted against every token its span overlaps,
with a rewind when a cluster runs past a token's end.

This is behaviour-preserving on purpose. The safety net asserts, for a 31-entry
corpus of ZWJ families, regional-indicator runs where pair parity matters,
skin-tone modifiers, combining marks, straddles, CRLF and a lone CR, that every
token's new `length` equals what `countGraphemes(token.value)` returned before.
Counting code units instead reddens 10 tests including two pre-existing ones;
dropping the straddle rewind reddens exactly the straddle cases. A
corpus-fitness test fails if the corpus stops exercising the traps, and a perf
pin stubs `Intl.Segmenter` and asserts one `segment()` call for a 15-token
input, where there were 16.

Measured, 3 repeats each side, same session:

  segmentGraphemes self   185ms -> 37ms   (4 call paths -> 2)
  sampled scroll window   758ms -> 496ms
  scroll_frame_p95_ms      33.3 -> 17.3
  rendered_rows_peak         11 -> 12

Nothing about what gets drawn moved: rendered cells, blank gap frames, long
tasks and the 4px row-height error are identical either side, which is the
check that this removed waste rather than work.

The recovered row is worth naming. #388 recorded #367 dropping
`rendered_rows_peak` 12 -> 11 and asked whether that frame was a fair price for
48/48 line counts. The price was not the segment measurement; it was the
duplicated segmentation alongside it. The row comes back without giving up the
accuracy.

Against TanStack on S2 scroll, 5 repeats: 17.3ms vs 17.3ms, a 0.0ms gap against
0.7ms of noise — statistically tied, from a 16.1ms deficit. pretable's own
spread fell from 7.4ms to 0.3ms; the variance WAS this work landing unevenly
across frames.

1133 react tests, 117 renderer-dom, 69 grid-core, 60 text-core, typecheck, lint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove added a commit that referenced this pull request Aug 15, 2026
…able (#415)

* fix(bench): make S2 measure wrapped text in every grid, not just pretable

S2 is the project's self-described primary wedge benchmark — `wrapped_columns:
3`, `row_height_mode: "variable"`. Only pretable was wrapping. The three
comparator adapters imported scenario TYPES only and never read `column.wrap`,
so their code path on S1 and S2 was byte-identical: fixed 48px rows,
`nowrap`, `overflow: hidden`. Every S2 comparison has measured pretable doing
variable-height text layout against three grids not doing it. Closes #400.

The flag was already on the wire. It was simply unread.

- ag-grid: `wrapText` + `autoHeight` per column. Both are needed and neither
  implies the other — `autoHeight` alone gives tall single-line rows,
  `wrapText` alone gives clipped wrapped text. `AllCommunityModule` already
  pulls in `RowAutoHeightModule`; `rowHeight` becomes a floor via
  `Math.max(cellHeight, rowHeight)`.
- mui: `getRowHeight: () => "auto"`. Sufficient on v9 without an `sx` override —
  `row--dynamicHeight > .cell` sets `whiteSpace: initial` with two class
  selectors, outranking the single-class `nowrap` default. `rowHeight` is KEPT:
  it is not inert, it is the base estimate for unmeasured rows.
- tanstack: `measureElement` + `indexAttribute: "data-row-index"`, and the
  wrapped columns get pretable's own text model (`anywhere`/`pre-wrap`) so the
  two lay the same string out under the same rules.

Every change is gated on `column.wrap`, and every test asserts BOTH directions.
That is not ceremony: enabling wrapping unconditionally passes a positive-only
test while silently moving every other scenario. Each was proven by mutation —
including the negative arm, which is the one that catches it.

Two things found while doing this that the issue did not have:

`indexAttribute` was a trap. TanStack's `measureElement` defaults to
`data-index`; this adapter emits `data-row-index`. A missing attribute yields
`-1`, `resizeItem` early-returns, and the row silently keeps its estimate —
console warning only. Measurement would have looked enabled and done nothing.

The scroll script sampled `scrollHeight` ONCE and derived all 36 targets from
it. AG Grid does not estimate auto-height rows, so content grows underneath the
pass as cells are measured: the run would have aimed at a mostly-unmeasured
model and covered a shrinking fraction of the dataset, while a grid that sizes
rows up front covered all of it. A like-for-like break introduced BY this
change, in exactly the scenario it exists to measure. Targets are now fractions
resolved against the live extent — arithmetically identical for a fixed-height
grid, so it changes only the case the old form got wrong.

Expect S2 numbers to move, and expect pretable's apparent lead to shrink.
`dom_nodes_peak` is `querySelectorAll("*").length`, proportional to rendered row
count, so most of the reported 2.4-3.4x DOM advantage was the row-count gap the
asymmetry created. Per rendered row it is 1.13x over MUI and 1.40x over AG Grid.
S2 figures from before this commit are not comparable to figures after it.

138 bench tests, typecheck, lint, format.

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

* fix(bench): stop AG Grid wrapping at 2.79x leading, and prove wrapping in pixels

Two findings from the first like-for-like S2 run.

**AG Grid's `autoHeight` was working.** At rest its rows measure 236/236/119/314
px with a 2px error. The 264px error the benchmark reported is the UNMEASURED
state: AG Grid's auto height is a post-paint correction, not a layout mode. The
row carries an explicit `style.height` from the row model, each cell measures
itself on mount, and the apply pass is debounced behind `_debounce(..., 1)` ->
`setRowHeight` -> redraw. A newly rendered row therefore always paints at the
48px fallback and is corrected at least a frame later, and the measurement is
DELETED when the cell is destroyed, so a row that scrolls out and back pays the
two-pass cost again. Reproduced independently: the harness's 36-jump loop
replicated standalone gives p95 264, peak rows 27, rows sampled at 48px. Four
and ten settle frames only reach 261 and 217; it needs ~500ms per jump to
converge. That is not fixable from the adapter and is now recorded in-source so
nobody re-chases it. It is also the honest form of the wedge claim: not "AG Grid
is slow" but "its auto height is a two-pass correction, so it paints rows at the
wrong height during scroll".

**Half the error was ours.** AG Grid's CSS derives cell `line-height` from ROW
height — correct for a single-line cell, a category error for a wrapped one,
giving every line of a paragraph the full row height as leading. AG Grid was
wrapping at 2.79x leading against pretable's and TanStack's 1.50 and MUI's 1.43,
so S2 was still not comparing the same layout. A theme parameter cannot fix it:
`--ag-line-height` is combined under `min()`, so it can only make leading
smaller than the row height, never release it. An explicit `lineHeight: 1.5`
does. Error drops 264 -> 120.

That line height is a per-adapter parity choice, not a neutral default, and it
must be disclosed wherever these numbers are published.

**The pixel proof.** The jsdom test asserted `ag-cell-wrap-text` and
`ag-cell-auto-height` were present. Those classes are toggled straight off the
colDef, so it passed whether or not a pixel moved — and it cannot do better,
because jsdom has no layout engine. Wrapping is a layout fact, so the proof now
runs in real Chromium: rows taller than the 48px floor, heights that vary, each
row agreeing with its tallest cell, and text genuinely on more than one line so
the leading ratio cannot pass vacuously. Three mutations each redden it
(`autoHeight` removed -> 48; `cellStyle` removed -> ratio 2.79; flags applied
unconditionally -> S1 rows move). The jsdom test stays as a cheap colDef guard,
retitled to say what it cannot see.

`rendered_rows_peak` staying at 27 is the expected result, not a miss: it is a
320px viewport over a 48px fallback plus AG Grid's default 10-row buffer each
side — the peak frame IS the unmeasured state.

138 bench tests, typecheck, lint, format.

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

* perf(text-core): segment each string once, not once per token as well

`prepareText` segmented the whole string into graphemes, then `tokenizeText`
re-segmented every token via `countGraphemes` purely to fill each token's
`length`. Every character was segmented twice. A CPU profile of an S2 wrapped
scroll put `segmentGraphemes` at 193ms of self time in a 761ms window — 25.4%,
roughly 3x TanStack's entire library-side JS cost for the same scroll.

Token lengths now come from the grapheme array `prepareText` already built.

The mapping is not the obvious one, and the obvious one is wrong. A grapheme
cluster can straddle a token boundary: in "a ␣́b" the space and the following
combining acute are ONE cluster, while the tokenizer's regex splits inside it.
The old code called `countGraphemes` per token and so charged that cluster to
BOTH sides. Mapping each grapheme to the token containing its start offset
gives different answers and would have silently changed wrapping for combining
marks. A grapheme is therefore counted against every token its span overlaps,
with a rewind when a cluster runs past a token's end.

This is behaviour-preserving on purpose. The safety net asserts, for a 31-entry
corpus of ZWJ families, regional-indicator runs where pair parity matters,
skin-tone modifiers, combining marks, straddles, CRLF and a lone CR, that every
token's new `length` equals what `countGraphemes(token.value)` returned before.
Counting code units instead reddens 10 tests including two pre-existing ones;
dropping the straddle rewind reddens exactly the straddle cases. A
corpus-fitness test fails if the corpus stops exercising the traps, and a perf
pin stubs `Intl.Segmenter` and asserts one `segment()` call for a 15-token
input, where there were 16.

Measured, 3 repeats each side, same session:

  segmentGraphemes self   185ms -> 37ms   (4 call paths -> 2)
  sampled scroll window   758ms -> 496ms
  scroll_frame_p95_ms      33.3 -> 17.3
  rendered_rows_peak         11 -> 12

Nothing about what gets drawn moved: rendered cells, blank gap frames, long
tasks and the 4px row-height error are identical either side, which is the
check that this removed waste rather than work.

The recovered row is worth naming. #388 recorded #367 dropping
`rendered_rows_peak` 12 -> 11 and asked whether that frame was a fair price for
48/48 line counts. The price was not the segment measurement; it was the
duplicated segmentation alongside it. The row comes back without giving up the
accuracy.

Against TanStack on S2 scroll, 5 repeats: 17.3ms vs 17.3ms, a 0.0ms gap against
0.7ms of noise — statistically tied, from a 16.1ms deficit. pretable's own
spread fell from 7.4ms to 0.3ms; the variance WAS this work landing unevenly
across frames.

1133 react tests, 117 renderer-dom, 69 grid-core, 60 text-core, typecheck, lint.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant