Skip to content

EPBDS-16211: fields that mount or go later cost the form one pass in two more cases - #223

Merged
AlexSamBY merged 1 commit into
masterfrom
probe-validation
Oct 13, 2026
Merged

AlexSamBY merged 1 commit into
masterfrom
probe-validation

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What

Two more cases where fields that mount or go after the first render cost the form one pass instead of one per field. #220 left both open.

react-final-form's probe

useField probes each field in its first render: it registers the field silently and unregisters it at once, for the state the field first renders with. When the field has a validator and validation runs, final-form validates every field in the form for that probe.

A burst of n fields mounting beside m others therefore ran about n·m validators, in render, before any bracket opens. The case: showing a 50-row table beside 100 required fields ran 10502 validators, 10201 of them from the probes.

createBatchedForm now registers a probe without its validator while validation runs.

  • The field's first render. It has no error yet, as every field has on a document's first render, where react-final-form pauses validation.
  • Where the error comes from. The field gets its error when its node's bracket closes.

The condition this depends on. react-final-form takes the first notification after a field's registration from its effect as the state the probe gave it. That registration must therefore not validate, and inside a bracket it does not: it registers silently, and the error comes in a later notification. Every engine field's first registration comes inside its node's bracket.

  • What guards it. UIRender.form-flows.test.js has a tab-switch test: a field the user touched comes back with its error. With the brackets turned off and this change kept, that test fails ("Unable to find … Required"). With them on, it passes. The comment in batchedForm.ts names the test.

A popup that closes

A popup's fields go when it closes, and nothing mounts then whose node would bracket their going. Each field that went validated and told every field left.

Modal now brackets what opening and closing it registers and unregisters, in the document's form, which its content's fields register with. rules.tsx hands the form to Modal as form.

Measured

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

Each case uses a 50-row table of two fields per row. Field states are counted through each field's isEqual, validators by their calls.

action master: states / validators this branch
a switch to its tab 1212 / 202 1111 / 101
a showIf that shows it 1431 / 202 1330 / 101
a showIf that shows it beside 100 required fields 13431 / 10502 3230 / 301
a popup that holds it opens, beside 100 required fields 12815 / 10402 2212 / 201
that popup closes 60800 / 15150 600 / 100
  • On master's code, all four cases fail, receiving the numbers in the master column.
  • On React 16, the popup's close still costs 60800 and 15150. React 16 runs a removed component's effect cleanups during the commit, before the dialog's layout effect can open its bracket.

In Chromium

npm run test:perf --tarball on the packed master and on this branch: three interleaved rounds on an Apple M4, with two documents added for the measurement only, not committed. Each is 200 required fields beside a 200-row table: once shown by a showIf, once held in a popup.

document / action master this branch
a showIf shows the table 153 (159 / 150 / 153) 98 (99 / 91 / 98)
a showIf hides it 35 (35 / 39 / 28) 36 (36 / 29 / 36)
the popup that holds the table opens 159 (168 / 159 / 159) 105 (108 / 95 / 105)
that popup closes 447 (457 / 438 / 447) 27 (27 / 21 / 30)
the five committed documents' mounts and actions within the rounds' spread of master

Milliseconds of main-thread time. Each cell is the median of three interleaved rounds, with the rounds in parentheses; the load average was 2.9 to 6.6. A Select's pick in the form of 100 Selects varied the most: 17 to 28 ms on master, 19 to 33 here.

Behaviour

batchedForm.document.test.js checks a popup that holds an empty required field. When it closes, the form's listeners hear the form is valid.

batchedForm.test.js checks a probe registered while validation runs:

  • it validates nothing, and its state has no error;
  • the field's own registration then finds the error.

batchedForm.ts stays covered fully.

What this does not cover

  • A popup's fields on React 16 still go one by one.
  • An Expand that collapses: its content stayed mounted in jsdom after collapsing, so there was nothing to measure.
  • Removing a row unregisters that row's fields, each validating the rest: linear for one row.

Gates

All 22 gates and the manifest contract exit 0 on HEAD, on Node 24.14.0, and so does test:perf.

Tests.

🤖 Generated with Claude Code

…two more cases

react-final-form probes each field in its first render, registering it silently for the state it first renders
with. With a validator and validation running, final-form validated every field for that probe, in render,
before any bracket opens: showing a 50-row table beside 100 required fields ran 10502 validators, 10201 of them
from the probes. `createBatchedForm` registers a probe without its validator while validation runs, so the field
first renders without an error, as on a document's first render, and gets it when its node's bracket closes:
301. That depends on the field's first registration from its effect coming inside a bracket, since
react-final-form takes the first notification after it as the probe's state. The tab-switch test of
`UIRender.form-flows.test.js` fails without the brackets.

A popup's fields went one by one when it closed, each validating every field left, since nothing mounts then
whose node would bracket them. `Modal` brackets what opening and closing it registers and unregisters, in the
document's form, which `rules.tsx` now hands it: closing a popup that holds that table ran 15150 validators, and
runs 100. React 16 runs a removed component's cleanups before the dialog's layout effect, so there it still does.

In Chromium on an Apple M4, three interleaved rounds: showing a 200-row table beside 200 required fields takes 98
ms instead of 153, and closing a popup that holds it 27 ms instead of 447.
@AlexSamBY
AlexSamBY merged commit 218191b into master Oct 13, 2026
8 checks passed
@AlexSamBY
AlexSamBY deleted the probe-validation branch October 13, 2026 14:01
AlexSamBY added a commit that referenced this pull request Oct 14, 2026
…alidation paused (#224)

## What

A regression from #220, which no release carries. An engine node opens
its bracket (`registrationsOf`) 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 of what it shows again, and not the
passive ones, on React 18 and 19. The bracket opened then, and nothing
closed it: the form kept validation paused for good. A required field
emptied after that showed no error, and the form stayed valid. `Modal`'s
bracket, from #223, had the same shape.

A host meets this when its page renders a document inside a Suspense
boundary that suspends again after showing it: a lazy component beside
the document that loads later, for one.

**Found** while planning the brackets for an `Expand` that collapses,
which is next.

## The fix

`useBracket(bracket, key)` (`src/core/components/useBracket.ts`) opens
and closes a bracket around a commit's passive effects, on mount and
when `key` changes. `RenderComponent` and `Modal` use it in place of
their two effects.

- **What it records.** Its passive effect records the bracket and key it
closed a bracket for, and its cleanup clears that.
- **When it opens none.** Its layout effect opens no bracket for a
bracket and key recorded there: a Suspense boundary that shows the
component again.
- **What stays bracketed.** StrictMode's second mount in development
cleans the passive effect up and runs it again, and so does React 19's
`<Activity>` when it shows what it hid. Both are still bracketed.

React 16 and 17 run no effect when a legacy Suspense boundary hides
content or shows it again, so they never had the bug.

## Tests

- **`useBracket.test.js`, new, six cases.** The bracket opens before the
passive effects below it and closes after them. It opens again when its
key changes, and not on a render that changes nothing. It opens nothing
when a Suspense boundary hides it and shows it again. StrictMode's
second mount and React 19's `<Activity>` are bracketed again. Without a
bracket, it does nothing. The `<Activity>` case runs on the React 19 leg
only.
- **`batchedForm.document.test.js`, one new case.** A Suspense boundary
hides a document and shows it again. Then a required field is emptied,
and the form's listeners hear that it is not valid.
- **The Suspense cases fail without the fix.** Each fails on React 18
and 19: the hook's without its check, the document's on master's
`mapper.tsx` and `Modal.tsx`. On React 16 and 17 they pass either way.

The changelog is unchanged: the #220 entry stays true, and no release
had the bug.

## Gates

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

**Tests.**

- The suite has 2913 tests, 7 of them new. All pass on React 19.
- On React 18, 2912 pass and the `<Activity>` case is skipped.
- On React 16.14 and 17, 2908 pass and five are skipped: the
`<Activity>` case and #207's four transition tests, as before.
- `useBracket.ts` is covered fully.
- In the browser, the demo's 62 tests pass in Chromium on React 18 and
19, and so do the packed tarball's on React 16.14 to 19.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY added a commit that referenced this pull request Oct 14, 2026
## 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.
#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.**

- The suite has 2919 tests, 6 of them new. All pass on React 19.
- On React 18, 2918 pass, and #224's `<Activity>` case is skipped.
- On React 16.14 and 17, 2914 pass, and five are skipped, as on #224.
- `Expand.tsx` and `TableView.tsx` keep all their lines covered.
- In the browser, the demo's 62 tests pass in Chromium on React 18 and
19, and so do the packed tarball's on React 16.14 to 19.

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