Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ jobs:
retention-days: 7

# What the published package's documents cost in Chromium: the main thread's busy time for a mount and for each
# action of four documents a host could render, a 200-row period table among them (e2e/perf/scenarios.perf.js,
# action of five documents a host could render, a 200-row period table among them (e2e/perf/scenarios.perf.js,
# `npm run test:perf`). ADVISORY: a runner's speed varies from run to run, so this reports and never blocks. Its
# ceilings sit about three times above what the code costs on a developer's machine, and at least 100 ms for an
# action: they flag a cascade the size of the one the period table had before #209, not a small one, which the
Expand Down
27 changes: 19 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ The modernization roadmap (React 17/18 upgrade, `semantic-ui-react` exit, projec
- `npm run test:pack` — `build-lib`, then the packaging budgets (`test:pack:budget`, `scripts/check-package-budget.js`) and a server-render smoke of the packed tarball (`test:pack:consumer`). `npm run test:pack:peers` repeats the smoke on React 16.14, 17.0.2 and 19.3.0, from the same fixtures as the jest legs. `npm run test:pack:browser` (after `build-lib`) loads the packed tarball in Chromium: a throwaway host (`scripts/packed-workspace.js`, shared with the server smoke) bundles it with its own React beside the published `static/all.css`, and `e2e/packed/tarball.packed.js` (`playwright.packed.config.js`) checks that it renders, responds and styles only its own wrapper; CI runs it in the `browser` job. `npm run test:pack:browser:peers` runs the same page on React 16.14, 17 and 19 from the jest legs' fixtures, mounting through `ReactDOM.render` on 16 and 17 as 0.34.x hosts do; CI runs it there too
- `npm run test:perf` (after `build-lib`) — what the packed tarball's documents cost in Chromium.
- **What it measures:** the main thread's busy time (the DevTools protocol's `TaskDuration`) for a mount, and the
median of five runs of each action (an edit, a blur, a pick), in four documents of
`scripts/fixtures/packed-perf-entry.js`. The documents include a 200-row period table and a 200-row table with a
Select column.
median of five runs of each action (an edit, a blur, a pick, a tab switch), in five documents of
`scripts/fixtures/packed-perf-entry.js`. The documents include a 200-row period table, a 200-row table with a
Select column, and two tabs whose second holds a 200-row table.
- **Where it runs:** in the same throwaway host as `test:pack:browser`, under `playwright.perf.config.js`, with
`e2e/perf/scenarios.perf.js`. It prints the medians and writes them to `test-results-perf/perf-results.json`.
- **Comparing versions:** `--tarball=<file>` measures a tarball packed elsewhere, such as another commit's, so two
Expand Down Expand Up @@ -121,11 +121,22 @@ A new wrapper, mapping or copy made for a field's prop must be kept the same way
`src/library/__tests__/*-budget.test.js` contracts count the renders that a mount, an edit and a blur cost. A number
that falls is progress; one that rises is a regression.

A mount has a cost besides renders: every field registers with final-form. The form a document renders comes from
`createBatchedForm` (`modules/form/batchedForm.ts`), which `<Form>` is handed as `form`. While react-final-form holds
validation back for the first render, a field registers silently, and every field hears once when validation resumes.
Until 2026-10-13 each registration published the state of every field before it, so a document of n fields computed
about n²/2 field states as it mounted. `form-mount-budget.test.js` counts them.
A mount has a cost besides renders: every field registers with final-form. Until 2026-10-13, each registration
published the state of every field before it and, once validation ran, validated them all, and each unregistration
did the same. A burst of n fields cost about n²/2 of each, on the first render, a tab switch, a `showIf` that flips
or a popup.

The form a document renders now comes from `createBatchedForm` (`modules/form/batchedForm.ts`), which `<Form>` is
handed as `form`. While validation is paused, a field registers silently, and every field hears once when validation
resumes. Two things pause it:
- react-final-form, for the first render;
- the brackets an engine node opens in a layout effect and closes in a passive effect, on mount and when its
`showIf` flips (`registrationsOf`, used by `RenderComponent` in `mapper.tsx`). Fields register in passive effects,
in between.

A field that goes while validation is paused tells nothing until it resumes. React 16 runs a removed component's
effect cleanups before any layout effect, so there a field that goes is not bracketed. `form-mount-budget.test.js`
and `later-mount-budget.test.js` count what the bursts cost.

To find what a cost comes from, count before naming the cause: wrap the suspected function or component with
`jest.mock` and count calls per action. Comparing timings with and without a feature does not name a cause.
Expand Down
3 changes: 2 additions & 1 deletion docs/UPGRADE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2289,7 +2289,8 @@ Postponed 2026-09-30 (§9.7-F4), and no longer a React 19 prerequisite. **Postpo
- **Also to check on the bump, since 2026-10-13:** `createBatchedForm` (`modules/form/batchedForm.ts`) leans on final-form's
registration. A `silent` registration publishes only its own field's state, its unregistering is silent too, and
`pauseValidation()` holds back the form's listeners. final-form 5.0.1's source keeps all three, read that day; run
`batchedForm.test.js`, `batchedForm.document.test.js` and `form-mount-budget.test.js` on the bump. Checked the same
`batchedForm.test.js`, `batchedForm.document.test.js`, `form-mount-budget.test.js` and `later-mount-budget.test.js` on the
bump. Checked the same
day: npm's `latest` for `react-final-form` is still 7.0.1, and #1096 is still open.

### TS migration (§9.6)
Expand Down
55 changes: 43 additions & 12 deletions e2e/perf/scenarios.perf.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@
* thread's busy time, the DevTools protocol's `TaskDuration`, over the mount and over each action, an action being
* what a user does and everything it sets off until the page is idle again (the 300 ms typing debounce included).
* Each action runs five times, and the median is what counts. After its runs, an edit or a pick checks that its field
* shows the last run's value, so that an action that stops changing anything fails instead of measuring nothing. Each
* test writes its document's numbers to PERF_OUT before it checks them, so that a document over its ceilings still
* reports, and so do the ones after it. scripts/test-packed-perf.js runs it (`npm run test:perf`) and prints them; CI
* runs it in the advisory `browser-perf` job, which reports and never blocks.
* shows the last run's value, so that an action that stops changing anything fails instead of measuring nothing. A tab
* switch waits for the table's field to be there, and a switch back for it to be gone. Each test writes its
* document's numbers to PERF_OUT before it checks them, so that a document over its ceilings still reports, and so do
* the ones after it. scripts/test-packed-perf.js runs it (`npm run test:perf`) and prints them; CI runs it in the
* advisory `browser-perf` job, which reports and never blocks.
*
* Each ceiling below is about three times what the current code costs on an Apple M4, and at least 100 ms for an
* action, so that a slower runner passes and so does an action's own noise. That flags a cascade the size of the one
* #209 to #213 removed from the period table: an edit there cost 374 ms before them and 67 ms after, and a first blur
* 187 ms and 41 ms. It does not flag a small one. Elsewhere in these documents the same changes moved an action's cost
* by less than 15 ms, and the jsdom contracts, which count renders, guard those. Lower a ceiling when a number falls
* for good.
* Each ceiling below is two and a half to three times what GitHub's runner measured for the code it was set with, and
* at least 100 ms for an action, so that a slower run passes and so does an action's own noise. The runner took about
* three times as long as an Apple M4 to mount a document (2026-10-13), and the M4 measured one build about twice as
* long one day as the next, so the ceilings follow the runner's numbers, which the `browser-perf` job prints. The tab
* switch's were set from the M4's at that ratio, before the runner had measured them.
*
* A ceiling flags a cascade the size of the one #209 to #213 removed from the period table, whose edit cost five
* times what it costs now. It does not flag a smaller one: the jsdom contracts, which count renders and field states,
* guard those. Lower a ceiling when a number falls for good.
*/
const fs = require('fs')
const { test, expect } = require('@playwright/test')
Expand All @@ -29,9 +33,10 @@ const results = {}
/** Milliseconds of main-thread work, per action, that no current scenario should come near. */
const CEILINGS = {
periods: { mount: 2000, edit: 210, blur: 130 },
selectTable: { mount: 1400, edit: 100, blur: 100 },
selectTable: { mount: 820, edit: 100, blur: 100 },
selectForm: { mount: 340, pick: 100, edit: 100 },
numberTable: { mount: 1100, edit: 100 },
numberTable: { mount: 780, edit: 100 },
tabs: { mount: 330, switch: 720, back: 360 },
}

const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
Expand Down Expand Up @@ -135,6 +140,32 @@ test('a form of 100 named Selects: a pick, and an edit of its text field', async
check('selectForm')
})

test('two tabs, the second a 200-row table: a switch to it, and back', async ({ page }) => {
const { mount, measure } = await open(page, 'tabs')
results.tabs = { mount: { median: mount, runs: [mount] } }
const rows = page.getByRole('tab', { name: 'Rows', exact: true })
const summary = page.getByRole('tab', { name: 'Summary', exact: true })
const cell = page.locator('input[name="rows[1].note"]')
// A switch waits for the table's field to be there, and a switch back for it to be gone.
const switches = []
const backs = []
for (let i = 0; i < REPEAT; i++) {
switches.push(await measure(async () => {
await rows.click()
await cell.waitFor()
}))
backs.push(await measure(async () => {
await summary.click()
await cell.waitFor({ state: 'detached' })
}))
}
results.tabs.switch = { median: median(switches), runs: switches }
results.tabs.back = { median: median(backs), runs: backs }
await rows.click()
await expect(cell).toHaveValue('note 1')
check('tabs')
})

test('a 200-row table of bounded number fields: an edit of a number', async ({ page }) => {
const { mount, measure } = await open(page, 'numberTable')
results.numberTable = { mount: { median: mount, runs: [mount] } }
Expand Down
22 changes: 22 additions & 0 deletions scripts/fixtures/packed-perf-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ const SCENARIOS = {
},
data: { other: 'text', ...Object.fromEntries(range(100).map(i => [`choice${i}`, 'a'])) },
}),
// Two tabs, the second a 200-row table of a required text column and a bounded number column, which mounts and
// unmounts with its tab.
tabs: () => ({
meta: {
view: 'Tabs',
items: [
{ tab: 'Summary', content: { view: 'Text', children: 'Nothing to edit here' } },
{
tab: 'Rows',
content: {
view: 'Table',
name: 'rows',
headers: [
{ id: 'note', label: 'Note', renderCell: { view: 'Input', name: 'note', validate: 'required' } },
{ id: 'qty', label: 'Qty', renderCell: { view: 'Input', type: 'number', name: 'qty', min: 0, max: 100000 } },
],
},
},
],
},
data: { rows: range(ROWS).map(i => ({ note: `note ${i}`, qty: i })) },
}),
// A column of number fields with bounds, beside a text column.
numberTable: () => ({
meta: {
Expand Down
17 changes: 15 additions & 2 deletions src/core/engine/mapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { memo, useContext } from 'react'
import React, { memo, useContext, useEffect, useLayoutEffect } from 'react'
import { registrationsOf } from '../modules/form/batchedForm'
import AutoSave from '../modules/form/views/AutoSave'
import { FIELD } from '../modules/variables'
import { cn } from '../components'
Expand Down Expand Up @@ -38,6 +39,10 @@ import Data from './Data'
import { AppContext, TableColumnsContext } from '../contexts'
import type { RenderErrorReport } from './Render'

// A layout effect in a browser. On the server, where no effect runs, an effect: React 16 and 17 warn about a layout
// effect there.
const useBeforePaintEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect

/**
* A meta node's attributes, as the mapper receives them from `Render`. `any`, deliberately: they are
* open JSON, which `validateMeta` checks at runtime and the published contract types describe, and
Expand Down Expand Up @@ -236,7 +241,15 @@ const RenderComponent = ({
// components that read `Active.translate` as a prop default).
const translate = (instance && instance.translate) || Active.translate
/* General showIf logic — the whole decision lives in `showIf.ts`, testable on its own. */
if (!shouldRender({ showIf, data, _data, relativeData, relativePath, relativeIndex, instance })) return null
const shown = shouldRender({ showIf, data, _data, relativeData, relativePath, relativeIndex, instance })
// The fields that mount below this node, or that go when it hides, register and unregister as one burst, which
// the form tells every field of once (`registrationsOf`, `modules/form/batchedForm.ts`). Its layout effect runs
// before any field below registers, in the passive effects, and its passive effect after they all have. When a
// tab, a step or a page changes, the new content's nodes bracket the old content's fields going as well: those
// go in the same commit's passive effects, which come between.
useBeforePaintEffect(() => { registrationsOf(form)?.begin() }, [form, shown])
useEffect(() => { registrationsOf(form)?.end() }, [form, shown])
if (!shown) return null

switch (view) {
case FIELD.TYPE.DATA:
Expand Down
51 changes: 48 additions & 3 deletions src/core/modules/form/__tests__/batchedForm.document.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* A document's fields register silently while its first render mounts (`createBatchedForm`, 2026-10-13). A field
* that goes later must still tell the form's listeners what final-form tells them when a field that is not silent
* goes: that its validator went with it, and with it the field's error.
* A document's fields register silently while its first render mounts, and so do the fields that mount later
* between the brackets of an engine node (`createBatchedForm`, `registrationsOf`, 2026-10-13). The form's listeners
* must still hear what final-form would have told them: that a field that came is not valid, and that one that
* went took its validator, and with it its error.
*
* A tab switch is the case where nothing else does. It changes no value, so the field going is the only thing that
* happens to the form, and a field unregistered silently would leave the form's listeners with a form that is not
Expand All @@ -22,6 +23,50 @@ function formHolding (key) {
}

describe('a document whose fields registered silently as it mounted', () => {
it('tells the form\'s listeners it is not valid when a tab switch brings a required field that is empty', async () => {
const meta = {
view: 'Tabs',
items: [
{ tab: 'A', content: { view: 'Text', children: 'Nothing here' } },
{ tab: 'B', content: { view: 'Input', name: 'b', label: 'B field', validate: 'required' } },
],
}
render(<UIRender meta={meta} data={{ b: '' }} initialValues={{ b: '' }} onSubmit={() => {}} />)
const { form, heard } = formHolding('b')
expect(heard).toEqual([true])

fireEvent.click(screen.getByRole('tab', { name: 'B' }))
await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)) })

expect(screen.getByLabelText('B field')).toBeTruthy()
expect(form.getState().valid).toBe(false)
expect(heard[heard.length - 1]).toBe(false)
})

it('tells them so when a `showIf` shows a required field that is empty, and hides it again', () => {
const meta = {
view: 'Col',
items: [
{ view: 'Input', name: 'show', label: 'Show' },
{ view: 'Input', name: 'a', label: 'A field', validate: 'required', showIf: { name: 'show', equal: 'yes' } },
],
}
render(<UIRender meta={meta} data={{ show: 'no', a: '' }} initialValues={{ show: 'no', a: '' }} onSubmit={() => {}} />)
const { form, heard } = formHolding('show')
expect(screen.queryByLabelText('A field')).toBeNull()
expect(heard).toEqual([true])

act(() => { form.change('show', 'yes') })
expect(screen.getByLabelText('A field')).toBeTruthy()
expect(form.getState().valid).toBe(false)
expect(heard[heard.length - 1]).toBe(false)

act(() => { form.change('show', 'no') })
expect(screen.queryByLabelText('A field')).toBeNull()
expect(form.getState().valid).toBe(true)
expect(heard[heard.length - 1]).toBe(true)
})

it('tells the form\'s listeners it is valid when a tab switch takes its only invalid field away', async () => {
const meta = {
view: 'Tabs',
Expand Down
Loading
Loading