Skip to content

EPBDS-16211: fields that mount after the first render no longer tell each other of themselves one by one - #220

Merged
AlexSamBY merged 1 commit into
masterfrom
later-mounts
Oct 13, 2026
Merged

AlexSamBY merged 1 commit into
masterfrom
later-mounts

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What

#219 made the first render's fields register in one pass, while react-final-form holds validation back. Fields that mount later still registered one at a time while validation ran: a tab switch, a showIf that flips, a popup that opens, a row that expands, a table page. Each registration published every field's state and, with a validator, validated every field. Each field that went did the same. A burst of n fields therefore cost about n²/2 of each, both ways.

registrationsOf(form) (src/core/modules/form/batchedForm.ts) gives an engine node brackets around such a burst:

  1. RenderComponent (src/core/engine/mapper.tsx) opens them in a layout effect, and closes them in a passive effect.
  2. It does so on mount, and when its showIf flips.
  3. Fields register in passive effects, between the two, so they register while validation is paused:
    • silently, as on the first render;
    • and a field that goes holds back what it would tell.
  4. The outermost bracket pauses validation and resumes it once, which validates and tells every field once.

Brackets nest, and the outermost one pauses only when nothing else has: on the first render, react-final-form's own pause does the job, as before.

Why every node, and why effects. A field joins the form wherever a swap point puts it: Tabs, ProgressSteps, Expand, a table's row expansion or page, Modal, a showIf, or rows from data. Each swap point lives in its own component, with its own state. But every engine node on either side of a swap is a RenderComponent.

  • A new subtree's root node opens its bracket in the layout phase. That is after the commit's DOM changes, and before any passive effect runs.
  • It closes the bracket in its passive effect. That comes after its children's registrations.
  • On React 17 and later, it also comes after the old subtree's passive cleanups, which run first. So a tab switch brackets both the new content's fields and the old content's.

The bracket closes synchronously, in the same flush as the registrations. A microtask would have closed it after act returned, and its notifications would then reach React outside act.

A submit inside a bracket, between a commit and its passive effects, validates what has registered so far before it submits.

Measured

In Chromium

npm run test:perf --tarball on the packed master and on this branch. Eight interleaved rounds on an Apple M4, load average 2.1 to 4.6. The median of the eight rounds' medians, with their range:

document / action master this branch
two tabs: a switch to the 200-row table 241 [225–247] 78 [70–82]
two tabs: a switch back 232 [228–238] 39 [36–41]
200-row period table: mount 234 [227–262] 240 [232–258]
200-row period table: first blur 38 [36–42] 42 [39–46]
every other mount and action within 3 ms of master

The period table's mount may cost a few milliseconds more: it has the most nodes, and each now runs two effects as it mounts. Its ranges overlap.

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

The test uses a 50-row table of two fields per row: 101 fields. It counts the field states final-form computes, and the validators it runs, on every React leg.

action master: states / validators this branch
a switch to its tab 21008 / 5252 1212 / 202
a switch away 20200 / 5050 0 / 0
a showIf that shows it 21524 / 5252 1431 / 202
a showIf that hides it 21322 / 5151 822 / 101

Notes on the numbers:

  • Hiding validates each field once. That comes from the form.change that hides it: a field changed with no validateFields validates them all.
  • Registering later validates each field twice. One pass is the bracket's. The other is react-final-form's probe in each field's first render, before any bracket opens. It validates once per field here, since nothing else is registered.
  • On master's code, the test fails.
  • On React 16, the two removals cost what they did on master. React 16 runs a removed component's effect cleanups during the commit, before any layout effect, so no bracket is open. Fields that mount are bracketed there too.

Behaviour

batchedForm.document.test.js gains two cases, both on what the form's listeners hear:

  • a tab switch that brings an empty required field: they hear the form is not valid;
  • a showIf that shows such a field and hides it again: they hear not valid, then valid.

batchedForm.test.js gains the brackets' own cases:

  • what registers between them is silent, and validated once;
  • what goes between them is told once;
  • they nest, and the outermost one closes;
  • they leave a pause something else made to whatever made it;
  • an extra close is ignored;
  • a submit between them validates first;
  • registrationsOf returns the same brackets for the copies of a form that react-final-form hands out.

batchedForm.ts is covered fully.

What this does not cover

  • Fields that go on their own still go one by one: nothing mounts with them, and no showIf flips. That is a closed popup, an Expand collapsed, a row removed. On React 16, every field that goes does.
  • react-final-form's first-render probe validates the whole form once per new field with a validator. It runs in render, before any bracket can open. In a form that already holds m fields, a later burst of n fields still runs about n × m validators through it. Pausing validation in render is not safe: a render React throws away would leave the form paused.

The perf check

  • A new document: tabs, two tabs whose second holds the 200-row table, with a switch to it and back, five runs each. A switch waits for the table's field to be there, and a switch back for it to be gone.
  • Ceilings follow the runner now. GitHub's runner took about three times as long as an Apple M4 to mount a document, and the M4 measured one build about twice as long on 2026-10-12 as on 2026-10-13. The ceilings are now two and a half to three times what the runner measured in EPBDS-16211: a document's fields no longer tell each other of themselves one by one as it mounts #219's run, and at least 100 ms for an action.
  • Lowered: the Select-column and number tables' mount ceilings, to 820 and 780 ms, against 271 and 258 ms on the runner. They were set above 4 times.
  • The tabs document's ceilings (330, 720 and 360 ms) are estimated from the M4's numbers at that ratio. This PR's own browser-perf run is the runner's first measure of it.

The plan's F4 item adds later-mount-budget.test.js to what the form-stack bump must run.

Gates

All 22 gates and the manifest contract exit 0 on HEAD, on Node 24.14.0, and so does test:perf. HEAD's own build measured a switch to the table's tab at 71 ms, and one back at 36 ms.

test:react16 exited 1 on its first run, because two jest workers died of SIGSEGV: the Node 24 crash of nodejs/node#62393. Every test that ran passed. Its rerun exits 0.

Tests.

🤖 Generated with Claude Code

…each other of themselves one by one

A tab switch, a `showIf` that flips, a popup that opens or a row that expands registered its fields one at a
time while validation ran. Each published every field's state and validated them all, and each field that went
did the same. In jsdom, a switch to a tab holding a 50-row table of two fields per row computed 21008 field
states and ran 5252 validators, and a switch away 20200 and 5050.

`registrationsOf(form)` (modules/form/batchedForm.ts) gives an engine node brackets around such a burst.
`RenderComponent` opens them in a layout effect and closes them in a passive effect, on mount and when its
`showIf` flips. Fields register in passive effects, between the two: they register silently, and those that go
hold back what they would tell. The outermost bracket pauses validation and resumes it once, and a submit that
comes inside a bracket validates first. The same switches now cost 1212 and 202, and nothing.

In Chromium on an Apple M4, eight interleaved rounds: a switch to a tab holding a 200-row table of two fields per
row takes 78 ms instead of 241, and a switch away 39 ms instead of 232. React 16 runs a removed component's
cleanups before any layout effect, so there the fields that go are not bracketed.

`test:perf` gains that tab document. Its ceilings now follow the CI runner's numbers, which are about three times
the M4's: the Select-column and number tables' mounts are lowered to 820 and 780 ms.
@AlexSamBY
AlexSamBY merged commit 11a084b into master Oct 13, 2026
8 checks passed
@AlexSamBY
AlexSamBY deleted the later-mounts branch October 13, 2026 12:04
AlexSamBY added a commit that referenced this pull request Oct 13, 2026
… made once (#221)

## What

`renderFloat` (`src/core/components/renders.tsx`) shows a number with a
faded fraction. The `Currency`, `Float`, `Double5` and `Percent`
renderers show numbers through it, and so does `Counter`. With decimals,
it formatted the fraction like this:

```js
Number('0.' + fraction).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
```

ECMA-402 specifies that call as a new `Intl.NumberFormat` each time. It
now keeps one formatter for each count of decimals (`fractionFormat`),
in the runtime's locale, as `toLocaleString(undefined, …)` used.

- **Dead code removed.** The options' `decimals == null ? undefined :`
branch is gone: inside `showFraction`, `decimals` is always above 0.
- **Not changed:** the integer part's `toLocaleString()` takes no
options, and costs 0.16 µs a call as it is.

## Measured

**Per value**, in Node 24 (V8), 20 000 calls each:

| call | µs a call |
|---|---|
| `toLocaleString(undefined, {minimumFractionDigits: 2,
maximumFractionDigits: 2})` | 9.65 |
| one `Intl.NumberFormat(undefined, …)`, kept, `.format(x)` | 0.24 |

**Output, byte for byte the same.** I rendered `renderFloat` in 189
cases, on master and on this branch, and compared the HTML: 7 counts of
decimals (none, 0, 1, 2, 3, 4, 6), 9 values (0, 0.5, 1.005, 12.3456,
1234.1, -7.25, 99.999, '3.14159', 10 000 000.123), and 3 sets of options
(none, `truncated`, `faded: false`).

**A whole document: no change, and why.** I proposed this after a CPU
profile of the `ratingDetails` demo example put 4.8 ms of its mount in
`renderFloat`. That mount does not change:

- **Chromium, five runs each:** 72 ms of main-thread time before and
after. `renderFloat`'s self time stayed at 4.8–5.0 ms.
- **Counted in jsdom:** the mount calls `renderFloat` 32 times, 12 of
them with decimals. That saves about 0.1 ms.
- **What the profile showed was not the per-call cost:** 12 calls at
9.65 µs make 0.1 ms. The rest stayed with the formatter made once. That
points to a cost paid once, by the first formatting, though I did not
isolate it.

The gain is per value shown with decimals: about 9 µs each, every time
the value renders. For example, a 200-row table with a `Currency` column
would spend about 1.9 ms less each time that column renders. That is
computed from the per-call numbers above, not measured in a document.

## Tests

A new case in `renders.edge-cases.test.js` renders five values with four
decimals. It checks that `Intl.NumberFormat` is made once, and what each
shows. The check of the count fails on master, where nothing calls the
constructor.

## Gates

All 22 gates and the manifest contract exit 0 on the change, on Node
24.14.0, as committed on master before #220.

- On React 18 and 19, all 2889 tests pass.
- On React 16.14 and 17, 2885 pass and #207's 4 transition tests are
skipped, as before.
- The browser legs pass: the demo's 62 tests in Chromium on React 18 and
19, and the packed tarball's on React 16.14 to 19.

**After merging master.** #220 then merged, and master was merged into
this branch. The only conflict was the changelog, where both add an
entry at the top of the fixes; both are kept. The PR's diff is the
change as gated.

On the merge, `lint:js`, `lint:css`, `typecheck`, `test:coverage`,
`build`, `test:react16` and `test:react17` exit 0 again. That run has
2902 tests: all pass on React 18; on 16.14 and 17, 2898 pass and the
same 4 are skipped.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY added a commit that referenced this pull request Oct 13, 2026
…measured (#222)

## What

The ceilings of the tab document in `e2e/perf/scenarios.perf.js`, which
#220 added, now follow what GitHub's runner measured.

| tabs | ceiling in #220 | runner, #220's own run | ceiling now |
|---|---|---|---|
| mount | 330 | 84 | 210 |
| switch to the 200-row table | 720 | 143 | 360 |
| switch back | 360 | 39 | 100 |

**Why #220's were off.** It set them from the Apple M4's numbers, at the
three-times ratio the runner shows for a mount. An action took the
runner up to twice as long as the M4, and the switch back as long as it,
not three times. At 720 ms, a switch three times as costly as now, as it
was before #220 (241 ms against 78 on the M4), would have passed.

**What they are now:** two and a half times the runner's numbers, and at
least 100 ms for an action. The spec's note now says the runner takes
about three times as long as the M4 for a mount, and up to twice as long
for an action. It also says the ceilings are about two to three times
the runner's numbers. Against #220's run they range from 2.2 times (the
period table's blur) to 3.2 (the number table's mount).

## Gates

On Node 24.14.0, these exit 0:

- `lint:js`, `lint:css`, `typecheck` and `build`;
- `test:coverage`, `test:react16` and `test:react17`;
- `test:pack`, and `test:perf` with the new ceilings.

`test:perf` measured the tab document at 34, 66 and 36 ms on the M4.
Only a comment's line breaks changed after that run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY added a commit that referenced this pull request Oct 13, 2026
…two more cases (#223)

## 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.**

- The suite has 2906 tests, 4 of them new. All pass on React 18 and 19.
- On React 16.14 and 17, 2902 pass and #207's 4 transition tests are
skipped, as before.
- 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
…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
…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