Skip to content

EPBDS-16211: fields that go on their own cost the form one pass - #225

Merged
AlexSamBY merged 3 commits into
masterfrom
expand-bracket
Oct 14, 2026
Merged

AlexSamBY merged 3 commits into
masterfrom
expand-bracket

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What

Stacked on #224, which adds useBracket: review and merge that one first.

Three more ways fields go when nothing mounts and no showIf flips. In each, every field that went validated every field left and told them all, as a closed popup's did before #223. The component that removes the fields now brackets them (useBracket), and the form tells every field once.

  • An Expand that collapses. Its content goes when the collapse's animation ends, 500 ms after the click, from the Expand's own timer. EPBDS-16211: fields that mount or go later cost the form one pass in two more cases #223 left this out: my test waited 100 ms, saw the content still there, and its description said so wrongly.
    • The fix. Expand takes a bracket and opens it around its content mounting and going. The engine hands it the document form's brackets, as it hands ExpandList, which passes it to every Expand.
  • Rows that collapse. Which rows of a table are expanded is TableView's own state: one row's toggle, or "expand all".
  • Rows that go with the items. A host renders the document with fewer rows. Rows are keyed by their index, so rows go only when there are fewer.
    • The fix for both. TableView brackets a change of which rows are expanded, and of how many items it has. A page change needs nothing: the new page's rows mount, and their nodes bracket the old page's going.

On React 16 these fields still go one by one, as a popup's do there: it runs a removed component's effect cleanups during the commit, before any layout effect can open a bracket.

Measured

In jsdom: src/library/__tests__/later-mount-budget.test.js

Each case is beside 100 other required fields. Field states are counted through each field's isEqual, validators by their calls.

action #224: states / validators this branch
an Expand that holds a 50-row table of two fields per row collapses 60800 / 15150 600 / 100
a table's 50 expanded rows, one required field each, collapse 25302 / 6275 606 / 101
the host's data has 5 of the table's 50 rows 57450 / 14307 1755 / 333
  • What stays the same. Opening the Expand costs 2011 / 201 either way, and expanding the rows 1459 / 151.
  • React 18 and the host's data. React 18 computes the form's state once more, 2 field states for each field left: 57672 before, 1977 after. That happens with the bracket and without it, measured on 18.3.1 against 17.0.2 and 19.3.0.
  • React 16 keeps what these cost before: the first column's numbers for the Expand and the rows, and 57201 / 14217 for the data.
  • Without this branch's code, the three cases fail.

In Chromium

npm run test:perf --tarball on the packed #224 and on this branch: three interleaved rounds on an Apple M4, load average 2.9 to 4.2.

The rounds included three documents that the next PR commits. Each puts the 200-row table of the tabs document beside 200 required fields, shown by a showIf, in a popup, or in an Expand.

document / action #224 this branch
an Expand that holds the table: collapsing it 535 (535 / 538 / 528) 124 (124 / 116 / 126)
the same Expand: expanding it 170 (176 / 170 / 166) 172 (172 / 173 / 172)
every other mount and action of the eight documents within the rounds' spread

Milliseconds of main-thread time. Each cell is the median of three rounds' medians, with the rounds in parentheses.

What a collapse still costs is its animation. I measured five collapses on an unminified build of the branch. Of their 116 to 132 ms:

  • 8 to 11 ms was script;
  • 35 to 41 ms was layout, over 33 to 37 layouts: about one a frame, while the content's height animates for 500 ms;
  • the protocol's metrics do not split the rest.

Tests

  • later-mount-budget.test.js, three new cases: the table above.
  • batchedForm.document.test.js, two new cases. An Expand that holds an empty required field collapses. A table's rows collapse, one of them with an empty required field. In both, the form's listeners hear that it is valid once the fields have gone.
  • Expand.lifecycle.test.js, one new case. The bracket an Expand is given opens around its content mounting, and around its content going when the collapse's animation is over. On React 16, the content goes before it opens.

What this does not cover

  • React 16, as above.
  • Other views with rows from data. Their rows still go one by one when a host's data has fewer: a List is one. The table is the view that holds fields in rows in the examples.

Gates

All 22 gates and the manifest contract exit 0 on HEAD, on Node 24.14.0.

Tests.

🤖 Generated with Claude Code

…alidation paused

An engine node opens its bracket in a layout effect and closes it in a passive
effect. A Suspense boundary that hides the document and shows it again runs the
layout effects again, and not the passive ones, on React 18 and 19: the bracket
opened then never closed, and the form kept validation paused, so a required
field emptied after that showed no error and left the form valid.

`useBracket` (components/useBracket.ts) opens a bracket only for a bracket and
key that no passive effect has closed one for since it last ran. RenderComponent
and Modal use it.
An Expand's content goes when its collapse's animation ends, from its own
timer, and a table's rows go when they collapse, the table's own state, or
when a host's data has fewer of them. Nothing mounts then whose node would
bracket the fields that go, so each one validated every field left and told
them all, as a closed popup's did before.

`Expand` takes a `bracket`, which the engine hands it, and ExpandList through
it, and opens it around its content mounting and going. `TableView` brackets a
change of which rows are expanded, and of how many items it has. Both go
through `useBracket`.

Beside 100 other required fields, in jsdom: an Expand's collapse 60800 field
states and 15150 validators before, 600 and 100 now; a table's 50 rows
collapsing 25302 and 6275, now 606 and 101; data with 5 of 50 rows 57450 and
14307, now 1755 and 333. In Chromium, collapsing an Expand that holds a 200-row
table beside 200 required fields took 535 ms, and takes 124.
Base automatically changed from bracket-reveal to master October 14, 2026 08:21
@AlexSamBY
AlexSamBY merged commit e1d4873 into master Oct 14, 2026
8 checks passed
@AlexSamBY
AlexSamBY deleted the expand-bracket branch October 14, 2026 08:21
AlexSamBY added a commit that referenced this pull request Oct 14, 2026
AlexSamBY added a commit that referenced this pull request Oct 14, 2026
…de 200 fields (#226)

## What

The browser check (`npm run test:perf`, the advisory `browser-perf` job)
gains three documents. Until now, #220, #223 and #225 were held only by
the jsdom contract (`later-mount-budget.test.js`), which counts field
states and validators, not time. Each document is the 200-row table of
the `tabs` document, a required text column and a bounded number column,
beside 200 required fields:

| document | the table is | actions, five runs each |
|---|---|---|
| `reveal` | shown by a `showIf` | showing it, and hiding it |
| `popup` | in a popup | opening it, and closing it |
| `expand` | in an Expand | expanding it, and collapsing it |

The 200 fields beside the table are what made these actions cost: each
field that came or went validated every one of them.

- **How each action is timed.** It waits for a field of the table to be
there, or to be gone. For a collapse, that is when the Expand's
animation ends.
- **Shared code.** The `tabs` document and these three share the table's
declaration. The `tabs` test and these three share `alternate`, which
times an action that brings the table and the one that takes it away, in
turn.

## Ceilings

They follow the header's rule: two and a half times what GitHub's runner
measured, and at least 100 ms for an action.

- **Where the numbers come from.** The runner's first measure of these
documents, in this PR's own `browser-perf` run.
- **A first version was wrong.** It estimated the ceilings from an Apple
M4 at the ratios #222 found, and they came out 3.6 to 5.4 times what the
runner then measured. The runner was quicker than those ratios: it took
0.95 to 1.4 times as long as the M4 for these actions, and 1.7 times as
long for the mounts. A second commit sets the ceilings from the runner's
numbers.

| document / action | runner | ceiling |
|---|---|---|
| reveal: mount | 107 | 270 |
| reveal: show | 109 | 270 |
| reveal: hide | 39 | 100 |
| popup: mount | 104 | 260 |
| popup: open | 136 | 340 |
| popup: close | 44 | 110 |
| expand: mount | 112 | 280 |
| expand: expand | 240 | 600 |
| expand: collapse | 158 | 400 |

**What they catch.** These ceilings flag a regression of the size #223
and #225 removed. On the M4, a popup's close took 447 ms before #223,
and an Expand's collapse 535 before #225. A smaller one is the jsdom
contract's to catch, as for every document here. #223's probe fix is
one: it took the show from 153 ms to 98.

## Gates

The PR changes the browser check, its documents and `CLAUDE.md` only.

- **The first commit.** `lint:js`, `lint:css`, `typecheck`,
`test:coverage`, `build`, `test:react16` and `test:react17` exit 0 on
it, on Node 24.14.0, and so did all eight CI jobs.
- **The ceilings' commit.** `lint:js` and `npm run test:perf` exit 0 on
it. All eight documents are under their ceilings.
- **What that run measured, on the M4,** today, which ran slower than
yesterday, as it has before: `reveal` 111 / 148 / 48, `popup` 114 / 153
/ 52, `expand` 148 / 241 / 181 (mount / bring / take away).

#224 and #225 are merged. This branch merged master with `-s ours`,
since master's tree is the tree it was stacked on.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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