fix: reserve the group rows monotonically, and bulk the ancestry compare

Copilot's fifth pass; both correct.

**Inline group rows are back in `frameCost`.** Omitting them was the same
failure I had just fixed for the header block: they take real table height,
so any viewport containing one asked for more data rows than `#grid` could
show, and the DOM takes its own scroll position while the client's cursor
offsets come from the larger window. Copilot's own suggestion answers the
oscillation objection — the reservation is a **high-water mark**, which
cannot oscillate because it only rises, and it rises in the safe direction:
over-reserving leaves blank space, under-reserving is the bug. It resets on
a view change so one dense document does not make every later view
pessimistic.

**`same_prefix` is replaced by `AxisFrame::shares_ancestry`**, the bulk
form, for the same reason `header_labels` and `groups` are bulk: called per
(column, level) it scanned the frame twice apiece, quadratic in the columns
drawn and growing with the horizontal offset, where the pre-anr9 widget's
materialized lookup was O(1).

`same_prefix` is **deleted** rather than kept beside it — two answers to one
question is the thing this PR exists to remove, and keeping a per-position
form that nothing calls would be an invitation. `shared_ancestry_widens_as_
the_level_rises` checks both arms, because a version comparing whole tuples
passes the leaf arm and a version comparing only the outermost label passes
the outer one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 23:29:48 -07:00
parent 9aef855d5f
commit ff351ee014
3 changed files with 79 additions and 28 deletions
+25 -12
View File
@@ -116,8 +116,9 @@ function setShellStatus(text) {
//
// It is measured off the painted model rather than predicted, and defaults to
// the old one-and-one before the first paint, which is the only moment there
// is nothing to measure.
let frameCost = { rows: 1, cols: 1 };
// is nothing to measure. `groups` is kept as its own high-water mark — see
// the tail of `repaint`.
let frameCost = { view: null, rows: 1, cols: 1, groups: 0 };
/** The extent this window can show, in whole DATA cells. */
function extent() {
@@ -258,18 +259,30 @@ function repaint() {
el.categories.replaceChildren(categoryList(model));
// What the frame costs, so the NEXT viewport announcement is honest: the
// header lines, plus a group line if there is one, plus the row header's
// width in columns.
// header lines, a group line if there is one, the row header's width in
// columns, and every inline group row.
//
// Inline group ROWS are deliberately not counted, even though they take
// space. They depend on how many rows the window asked for, so counting
// them would make this a feedback loop — a smaller window fetches fewer
// rows, which crosses fewer groups, which asks for a larger window. The
// three facts below depend only on the axes, so a repaint cannot chase
// its own tail. The cost is a little over-fetch, which is the direction
// ROW_PX already errs in.
// The group rows are the awkward one, because they depend on how many
// rows the window asked for: counted directly they are a feedback loop —
// a smaller window crosses fewer groups and asks for a larger one, which
// crosses more. So the reservation is a HIGH-WATER MARK, which cannot
// oscillate because it only ever rises, and it rises in the safe
// direction: over-reserving asks for fewer rows than fit and leaves
// blank space, where under-reserving asks for more rows than the table
// can show and is the off-screen-selection bug itself. It resets when
// the view changes, so a dense document does not make every later view
// pessimistic.
if (model.view_name !== frameCost.view) {
frameCost = { view: model.view_name, rows: 1, cols: 1, groups: 0 };
}
const groups = Math.max(
frameCost.groups,
model.rows.reduce((n, row) => n + (row.kind === "group" ? 1 : 0), 0),
);
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
view: model.view_name,
groups,
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0) + groups,
cols: model.row_levels,
};
if (next.rows !== frameCost.rows || next.cols !== frameCost.cols) {