From 311f05dfa7c2f0dc19099e105046e83a6903ef1a Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Tue, 13 Oct 2026 16:32:51 +0200 Subject: [PATCH 1/2] EPBDS-16211: a bracket a Suspense boundary reopens no longer leaves validation 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. --- CLAUDE.md | 4 +- .../components/__tests__/useBracket.test.js | 109 ++++++++++++++++++ src/core/components/useBracket.ts | 37 ++++++ src/core/engine/components/Modal.tsx | 10 +- src/core/engine/mapper.tsx | 18 ++- .../__tests__/batchedForm.document.test.js | 38 +++++- src/core/modules/form/batchedForm.ts | 5 +- 7 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 src/core/components/__tests__/useBracket.test.js create mode 100644 src/core/components/useBracket.ts diff --git a/CLAUDE.md b/CLAUDE.md index 067acde7..87419062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,9 @@ resumes. Two things pause it: - 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. `Modal` brackets its popup's fields the same way as it opens and closes, since nothing mounts when a - popup closes. + popup closes. Both open and close them through `useBracket` (`components/useBracket.ts`), which opens none when a + Suspense boundary shows a component again: React 18 and 19 run its layout effects then, and not its passive ones, + so nothing would close it, and the form would stay paused. 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. diff --git a/src/core/components/__tests__/useBracket.test.js b/src/core/components/__tests__/useBracket.test.js new file mode 100644 index 00000000..f843a0ec --- /dev/null +++ b/src/core/components/__tests__/useBracket.test.js @@ -0,0 +1,109 @@ +/** + * WHAT A COMPONENT'S BRACKET COVERS, AND THAT IT ALWAYS CLOSES (`useBracket`, 2026-10-14). + * ============================================================================================= + * + * The bracket opens in a layout effect and closes in a passive effect, so the passive effects of the components + * below it, where fields register with their form, run while it is open. A Suspense boundary that hides the + * component and shows it again runs only its layout effects again, on React 18 and 19: a bracket opened then never + * closed, and the form it paused never validated again. React 16 and 17 run no effect then, and StrictMode runs + * their effects once, so their legs pass those two cases either way. + */ +import React, { StrictMode, Suspense, useEffect } from 'react' +import { act, render } from '@testing-library/react' +import { useBracket } from '../useBracket' + +const MAJOR = Number(React.version.split('.')[0]) + +/** A bracket that records its opening and closing, and what the component below it did, at which depth. */ +function recorded () { + const record = { log: [], depth: 0 } + record.bracket = { + begin: () => { record.depth += 1; record.log.push('begin') }, + end: () => { record.depth -= 1; record.log.push('end') }, + } + return record +} + +function Below ({ record, k }) { + useEffect(() => { record.log.push(`below ${k} at ${record.depth}`) }, [record, k]) + return null +} + +function Bracketed ({ record, bracket, k }) { + useBracket(bracket, k) + return +} + +/** What suspends while `pending` holds a promise. */ +let pending = null +function Later () { + if (pending) throw pending.promise + return null +} + +describe('useBracket', () => { + it('opens before the passive effects below it, and closes after them', () => { + const record = recorded() + render() + expect(record.log).toEqual(['begin', 'below a at 1', 'end']) + }) + + it('opens and closes again when its key changes, and not when it only renders again', () => { + const record = recorded() + const { rerender } = render() + rerender() + expect(record.log).toEqual(['begin', 'below a at 1', 'end']) + + rerender() + expect(record.log).toEqual(['begin', 'below a at 1', 'end', 'begin', 'below b at 1', 'end']) + }) + + it('opens nothing when a Suspense boundary hides it and shows it again', async () => { + const record = recorded() + const page = () => ( + Loading

}> + + +
+ ) + const { container, rerender } = render(page()) + let release + pending = { promise: new Promise(resolve => { release = resolve }) } + act(() => { rerender(page()) }) + expect(container.textContent).toBe('Loading') + + pending = null + // React 16 and 17 retry the boundary from the scheduler, which act does not drive there. + await act(async () => { + release() + await new Promise(resolve => setTimeout(resolve, 50)) + }) + expect(container.textContent).toBe('') + expect(record.log).toEqual(['begin', 'below a at 1', 'end']) + expect(record.depth).toBe(0) + }) + + it('opens and closes again for StrictMode\'s second mount, which runs the effects below it again', () => { + const record = recorded() + render() + const once = ['begin', 'below a at 1', 'end'] + expect(record.log).toEqual(MAJOR >= 18 ? [...once, ...once] : once) + }) + + // React 19's `` cleans up both kinds of effect of what it hides, and runs both again when it shows it. + ;(React.Activity ? it : it.skip)('opens and closes again when an shows it again', () => { + const { Activity } = React + const record = recorded() + const page = mode => + const { rerender } = render(page('visible')) + rerender(page('hidden')) + rerender(page('visible')) + expect(record.log).toEqual(['begin', 'below a at 1', 'end', 'begin', 'below a at 1', 'end']) + }) + + it('does nothing without a bracket', () => { + const record = recorded() + render() + expect(record.log).toEqual(['below a at 0']) + }) +}) diff --git a/src/core/components/useBracket.ts b/src/core/components/useBracket.ts new file mode 100644 index 00000000..7209385f --- /dev/null +++ b/src/core/components/useBracket.ts @@ -0,0 +1,37 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' + +// 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 + +/** Brackets around what a commit mounts and unmounts in its passive effects. They nest. */ +export type Bracket = { begin: () => void, end: () => void } + +/** + * Opens `bracket` in a layout effect and closes it in a passive effect, on mount and when `bracket` or `key` + * changes. A commit runs its passive effects after its layout effects, so what mounts or goes in them comes between, + * as fields that register with a form and unregister do (`registrationsOf`, `modules/form/batchedForm.ts`). + * + * A Suspense boundary that hid the component and shows it again runs its layout effects again, and not its passive + * ones, on React 18 and 19. Until 2026-10-14 a bracket opened then all the same, and nothing closed it: the form kept + * validation paused for good, and a required field emptied after that showed no error. So the layout effect opens a + * bracket only for a `bracket` and `key` that no passive effect has closed one for since it last ran. + * StrictMode's second mount in development cleans the passive effect up and runs it again, so it is bracketed. + * + * @param bracket - what to open and close; nothing, for a component outside any form + * @param key - what, when it changes, mounts or removes what the bracket is for: whether a node is shown, or a popup open + */ +export function useBracket (bracket: Bracket | undefined, key: unknown): void { + // The `bracket` and `key` the passive effect closed a bracket for, until that effect is cleaned up. + const closed = useRef<{ bracket: Bracket | undefined, key: unknown } | null>(null) + useBeforePaintEffect(() => { + const last = closed.current + if (last !== null && last.bracket === bracket && Object.is(last.key, key)) return + bracket?.begin() + }, [bracket, key]) + useEffect(() => { + bracket?.end() + closed.current = { bracket, key } + return () => { closed.current = null } + }, [bracket, key]) +} diff --git a/src/core/engine/components/Modal.tsx b/src/core/engine/components/Modal.tsx index 2da6ae38..858eb735 100644 --- a/src/core/engine/components/Modal.tsx +++ b/src/core/engine/components/Modal.tsx @@ -1,18 +1,15 @@ -import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { registrationsOf } from '../../modules/form/batchedForm' import Button from '../../components/Button' import Text from '../../components/Text' +import { useBracket } from '../../components/useBracket' import View from '../../components/View' import { l, localiseTranslation } from '../../utils' import { _ } from '../../utils/translations' import { AppContext } from '../../contexts' import type { Translate } from '../../utils/_envs' -// 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 - localiseTranslation({ CANCEL: { [l.ENGLISH]: 'Cancel', @@ -103,8 +100,7 @@ const Modal = ({ translate, form }: { translate?: Translate, form?: unknown }) = // A popup's fields go when it closes, and nothing mounts then whose node would bracket their going: the // dialog brackets what opening and closing it registers and unregisters, in `form`, the document's, which // its content's fields register with (`registrationsOf`, `modules/form/batchedForm.ts`). - useBeforePaintEffect(() => { registrationsOf(form)?.begin() }, [form, isOpen]) - useEffect(() => { registrationsOf(form)?.end() }, [form, isOpen]) + useBracket(registrationsOf(form), isOpen) const onKeyDown = (event: React.KeyboardEvent) => { // A control inside that used the key claims it: an open listbox closes on Escape and says so. diff --git a/src/core/engine/mapper.tsx b/src/core/engine/mapper.tsx index 2beaae4c..2f9426d0 100644 --- a/src/core/engine/mapper.tsx +++ b/src/core/engine/mapper.tsx @@ -1,4 +1,4 @@ -import React, { memo, useContext, useEffect, useLayoutEffect } from 'react' +import React, { memo, useContext } from 'react' import { registrationsOf } from '../modules/form/batchedForm' import AutoSave from '../modules/form/views/AutoSave' import { FIELD } from '../modules/variables' @@ -23,6 +23,7 @@ import Space from '../components/Space' import Table from '../components/Table' import Text from '../components/Text' import Tooltip from '../components/Tooltip' +import { useBracket } from '../components/useBracket' import View from '../components/View' import { Active, isList, isNumeric, toFlatList, toJSON } from '../utils' import { TIME_DURATION_INSTANT } from '../utils/constants' @@ -39,10 +40,6 @@ 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 @@ -243,12 +240,11 @@ const RenderComponent = ({ /* General showIf logic — the whole decision lives in `showIf.ts`, testable on its own. */ 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]) + // the form tells every field of once (`registrationsOf`, `modules/form/batchedForm.ts`). The bracket opens in a + // layout effect, before any field below registers in the passive effects, and closes in a passive effect, after + // they all have (`useBracket`). 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. + useBracket(registrationsOf(form), shown) if (!shown) return null switch (view) { diff --git a/src/core/modules/form/__tests__/batchedForm.document.test.js b/src/core/modules/form/__tests__/batchedForm.document.test.js index fa66f400..bb759b90 100644 --- a/src/core/modules/form/__tests__/batchedForm.document.test.js +++ b/src/core/modules/form/__tests__/batchedForm.document.test.js @@ -9,7 +9,7 @@ * valid. Removing a row is not such a case, and is checked too: the removal changes the form's values, and its * listeners hear the state that follows from that. */ -import React from 'react' +import React, { Suspense } from 'react' import { act, fireEvent, render, screen } from '@testing-library/react' import UIRender from '../../../../library/main' import { formsStorage } from '../../../state/formRegistry' @@ -111,6 +111,42 @@ describe('a document whose fields registered silently as it mounted', () => { expect(heard[heard.length - 1]).toBe(true) }) + it('keeps validating after a Suspense boundary hides the document and shows it again', async () => { + // React 18 and 19 run the layout effects of what such a boundary shows again, and not its passive ones: a + // node's bracket opened then, and nothing closed it (`useBracket`), so the form never validated again. React + // 16 and 17 run neither, and pass either way. + let pending = null + const Later = () => { + if (pending) throw pending.promise + return null + } + const meta = { view: 'Input', name: 'note', label: 'Note', validate: 'required' } + const page = () => ( + Loading

}> + {}} /> + +
+ ) + const { rerender } = render(page()) + const { form, heard } = formHolding('note') + let release + pending = { promise: new Promise(resolve => { release = resolve }) } + act(() => { rerender(page()) }) + expect(screen.getByText('Loading')).toBeTruthy() + + pending = null + await act(async () => { + release() + // React 16 and 17 retry the boundary from the scheduler, which act does not drive there. + await new Promise(resolve => setTimeout(resolve, 50)) + }) + expect(screen.queryByText('Loading')).toBeNull() + + act(() => { form.change('note', '') }) + expect(form.getState().valid).toBe(false) + expect(heard[heard.length - 1]).toBe(false) + }) + it('tells them so when the row with the empty required field is removed', () => { jest.useFakeTimers() try { diff --git a/src/core/modules/form/batchedForm.ts b/src/core/modules/form/batchedForm.ts index 4ffd245e..15a9209a 100644 --- a/src/core/modules/form/batchedForm.ts +++ b/src/core/modules/form/batchedForm.ts @@ -3,8 +3,9 @@ import type { Config, FormApi } from 'final-form' /** * Brackets around a burst of fields registering and unregistering, which an engine node opens in its layout effect - * and closes in its passive effect (`RenderComponent` in `engine/mapper.tsx`), on mount and when its `showIf` flips. - * Brackets nest. The outermost one pauses validation when nothing else has, and resumes it when it closes. + * and closes in its passive effect (`RenderComponent` in `engine/mapper.tsx`), on mount and when its `showIf` flips, + * through `useBracket` (`components/useBracket.ts`). Brackets nest. The outermost one pauses validation when nothing + * else has, and resumes it when it closes. */ export type Registrations = { begin: () => void, end: () => void } From a922320056ea461b01c2b320ff5acbc3f091ccde Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Tue, 13 Oct 2026 16:48:19 +0200 Subject: [PATCH 2/2] EPBDS-16211: fields that go on their own cost the form one pass 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. --- CLAUDE.md | 9 ++- src/core/components/Expand.tsx | 11 +++ .../__tests__/Expand.lifecycle.test.js | 36 ++++++++- src/core/engine/components/TableView.tsx | 15 +++- src/core/engine/mapper.tsx | 5 +- .../__tests__/batchedForm.document.test.js | 50 ++++++++++++ src/core/modules/form/batchedForm.ts | 5 +- src/demo/markdowns/changelog.md | 6 ++ .../__tests__/later-mount-budget.test.js | 77 ++++++++++++++++++- 9 files changed, 200 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87419062..4c81e950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,10 +132,11 @@ 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. `Modal` brackets its popup's fields the same way as it opens and closes, since nothing mounts when a - popup closes. Both open and close them through `useBracket` (`components/useBracket.ts`), which opens none when a - Suspense boundary shows a component again: React 18 and 19 run its layout effects then, and not its passive ones, - so nothing would close it, and the form would stay paused. + in between. Fields can also go when nothing mounts and no `showIf` flips, and the component that removes them + brackets them the same way: `Modal` as a popup opens and closes, `Expand` when a collapse's animation ends, and + `TableView` when rows collapse or go with the items. All of them open and close brackets through `useBracket` + (`components/useBracket.ts`), which opens none when a Suspense boundary shows a component again: React 18 and 19 + run its layout effects then, and not its passive ones, so nothing would close it, and the form would stay paused. 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. diff --git a/src/core/components/Expand.tsx b/src/core/components/Expand.tsx index 4301c5f0..5237d76f 100644 --- a/src/core/components/Expand.tsx +++ b/src/core/components/Expand.tsx @@ -5,6 +5,8 @@ import AnimateHeight from './AnimateHeight' import Icon from './Icon' import { STYLE } from './styles' import Text from './Text' +import { useBracket } from './useBracket' +import type { Bracket } from './useBracket' import View from './View' /** What `onClick` is told on every change: the new state, and which `Expand` it was. */ @@ -39,6 +41,11 @@ export type ExpandProps = { renderLabel?: (title: React.ReactNode) => React.ReactNode /** Argument to pass to 'onClick' callback as `index` */ index?: string | number + /** + * Opened before the content mounts or goes, and closed after (`useBracket`): the engine hands it the brackets of + * the document's form, so that the fields of the content register, or unregister, as one burst + */ + bracket?: Bracket [key: string]: unknown } @@ -100,6 +107,7 @@ function Expand (props: ExpandProps) { iconClosed = 'chevron-right', iconOpened = 'chevron-down', renderLabel, + bracket, ...rest } = props const [state, setState] = useState(() => ({ @@ -142,6 +150,9 @@ function Expand (props: ExpandProps) { const {expanded, changing} = state const hasContent = children != null const shown = hasContent && (expanded || changing) + // The content goes a `duration` after a collapse, from the timer above, when nothing else mounts or changes that + // would bracket its fields going: so the Expand brackets them itself, and those that come when it expands. + useBracket(bracket, shown) // A render function's content is built when it is first shown, and kept while `children` is the same, // however often it is collapsed and shown again: the function is called once (`Expand.lifecycle.test.js`). // A ref held it until 2026-10-08. It is state now, stored by the render that builds it, which then runs diff --git a/src/core/components/__tests__/Expand.lifecycle.test.js b/src/core/components/__tests__/Expand.lifecycle.test.js index ef2511d3..783df085 100644 --- a/src/core/components/__tests__/Expand.lifecycle.test.js +++ b/src/core/components/__tests__/Expand.lifecycle.test.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { act, fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' import Expand from '../Expand' @@ -10,6 +10,8 @@ const wrap = ui => ( const labelOf = container => container.querySelector('.app__expand > .text') +const MAJOR = Number(React.version.split('.')[0]) + describe('Expand lifecycle contracts', () => { beforeEach(() => jest.useFakeTimers()) afterEach(() => jest.useRealTimers()) @@ -310,4 +312,36 @@ describe('Expand lifecycle contracts', () => { cleared.mockRestore() } }) + + it('brackets its content mounting, and its content going when the collapse is over', () => { + // The engine hands an Expand the brackets of its document's form (`useBracket`). The content goes from a + // timer, a `duration` after the collapse, when nothing else would bracket its fields going. + const log = [] + let depth = 0 + const bracket = { + begin: () => { depth += 1; log.push('begin') }, + end: () => { depth -= 1; log.push('end') }, + } + const Content = () => { + useEffect(() => { + log.push(`mounted at ${depth}`) + return () => { log.push(`gone at ${depth}`) } + }, []) + return Content + } + const { container } = render(wrap()) + // The mount's own bracket, with nothing in it: the content is collapsed. + expect(log).toEqual(['begin', 'end']) + + fireEvent.click(labelOf(container)) + expect(log.slice(2)).toEqual(['begin', 'mounted at 1', 'end']) + + fireEvent.click(labelOf(container)) + expect(log.slice(5)).toEqual([]) + act(() => jest.advanceTimersByTime(100)) + expect(screen.queryByText('Content')).not.toBeInTheDocument() + // React 16 runs a removed component's effect cleanups during the commit, before any layout effect. + expect(log.slice(5)).toEqual(MAJOR > 16 ? ['begin', 'gone at 1', 'end'] : ['gone at 0', 'begin', 'end']) + expect(depth).toBe(0) + }) }) diff --git a/src/core/engine/components/TableView.tsx b/src/core/engine/components/TableView.tsx index f1017cfa..e4cce39d 100644 --- a/src/core/engine/components/TableView.tsx +++ b/src/core/engine/components/TableView.tsx @@ -6,6 +6,8 @@ import Row from '../../components/Row' import ScrollView from '../../components/ScrollView' import Table from '../../components/Table' import Text from '../../components/Text' +import { useBracket } from '../../components/useBracket' +import type { Bracket } from '../../components/useBracket' import View from '../../components/View' import { ENGINE_PROPS, FIELD_ONLY_PROPS, omitProps } from '../../components/domProps' import { by, get, hasListValue, isEqual, isEqualList, isFunction, toJSON } from '../../utils' @@ -108,6 +110,11 @@ export type TableViewProps = { className?: string name?: string fieldArrayName?: string + /** + * Opened before rows that expand or collapse, or come or go with the items, mount or go, and closed after + * (`useBracket`): the engine hands it the brackets of the document's form + */ + bracket?: Bracket [key: string]: unknown } @@ -484,6 +491,12 @@ function TableView (props: TableViewProps) { Object.assign(self, {props, state, headers, itemsSorted, renderHeader, renderItem, renderItemsVertical, renderItemData}) + // The fields of rows that collapse, and of rows that go with the items, go when nothing mounts or changes that would + // bracket their going: the table brackets them itself, and those of rows that expand or come. Rows are keyed by + // their index, so rows go only when there are fewer. A page's rows go when another page's mount, which bracket them. + useBracket(props.bracket, state.items) + useBracket(props.bracket, itemsSorted.length) + if (!headers) return {'Table has no data!'} const { fill, className, sorts, onSort, extraHeaders, renderExtraItem, showEmptyAs, vertical, colGroup, usePagination, @@ -494,7 +507,7 @@ function TableView (props: TableViewProps) { // carried over: a dead entry in the list whose whole job is keeping props off the DOM // reads as protection that is not there. itemsExpanded: _6, translate: _7, additionalCellsStyles: _8, rowsPerPage: _9, - fieldArrayName: _10, + fieldArrayName: _10, bracket: _11, ...rest } = props const {activePage, rowsPerPage} = state diff --git a/src/core/engine/mapper.tsx b/src/core/engine/mapper.tsx index 2f9426d0..d069c2e4 100644 --- a/src/core/engine/mapper.tsx +++ b/src/core/engine/mapper.tsx @@ -321,11 +321,11 @@ const RenderComponent = ({ } if (props.name != null && props.title == null) props.title = props.name if (items.length) props.children = () => items.map(Render) - return + return } case FIELD.TYPE.EXPAND_LIST: { - return + return } case FIELD.TYPE.BUTTON: { @@ -527,6 +527,7 @@ const RenderComponent = ({ fieldArrayName={fieldArrayName} {...table} translate={translate} + bracket={registrationsOf(form)} /> } diff --git a/src/core/modules/form/__tests__/batchedForm.document.test.js b/src/core/modules/form/__tests__/batchedForm.document.test.js index bb759b90..264a72c2 100644 --- a/src/core/modules/form/__tests__/batchedForm.document.test.js +++ b/src/core/modules/form/__tests__/batchedForm.document.test.js @@ -111,6 +111,56 @@ describe('a document whose fields registered silently as it mounted', () => { expect(heard[heard.length - 1]).toBe(true) }) + it('tells them it is valid when an Expand that holds an empty required field collapses', async () => { + const meta = { + view: 'Col', + items: [ + { view: 'Input', name: 'title', label: 'Title' }, + { view: 'Expand', title: 'Details', items: [{ view: 'Input', name: 'detail', label: 'Detail', validate: 'required' }] }, + ], + } + render( {}} />) + const { form, heard } = formHolding('title') + const title = screen.getByRole('button', { name: 'Details' }) + expect(heard).toEqual([true]) + + await act(async () => { fireEvent.click(title) }) + expect(screen.getByLabelText('Detail')).toBeTruthy() + expect(heard[heard.length - 1]).toBe(false) + + await act(async () => { fireEvent.click(title) }) + // The content goes when the collapse's animation ends, 500 ms after the click. + await act(async () => { await new Promise(resolve => setTimeout(resolve, 600)) }) + expect(screen.queryByLabelText('Detail')).toBeNull() + expect(form.getState().valid).toBe(true) + expect(heard[heard.length - 1]).toBe(true) + }) + + it('tells them it is valid when a table\'s rows collapse, one of them with an empty required field', async () => { + const meta = { + view: 'Table', + name: 'rows', + headers: [ + { id: 'note', label: 'Note' }, + { id: '#', renderHeader: { view: 'Checkbox', label: 'Expand all', onChange: 'handleToggleExpandAll' } }, + ], + renderItem: { view: 'Input', name: 'detail', label: 'Detail', validate: 'required' }, + } + const data = () => ({ rows: [{ note: 'A', detail: 'a' }, { note: 'B', detail: '' }] }) + render( {}} />) + const { form, heard } = formHolding('rows') + expect(heard).toEqual([true]) + + await act(async () => { fireEvent.click(screen.getByLabelText('Expand all')) }) + expect(screen.getAllByLabelText('Detail')).toHaveLength(2) + expect(heard[heard.length - 1]).toBe(false) + + await act(async () => { fireEvent.click(screen.getByLabelText('Expand all')) }) + expect(screen.queryByLabelText('Detail')).toBeNull() + expect(form.getState().valid).toBe(true) + expect(heard[heard.length - 1]).toBe(true) + }) + it('keeps validating after a Suspense boundary hides the document and shows it again', async () => { // React 18 and 19 run the layout effects of what such a boundary shows again, and not its passive ones: a // node's bracket opened then, and nothing closed it (`useBracket`), so the form never validated again. React diff --git a/src/core/modules/form/batchedForm.ts b/src/core/modules/form/batchedForm.ts index 15a9209a..8d99dc75 100644 --- a/src/core/modules/form/batchedForm.ts +++ b/src/core/modules/form/batchedForm.ts @@ -4,8 +4,9 @@ import type { Config, FormApi } from 'final-form' /** * Brackets around a burst of fields registering and unregistering, which an engine node opens in its layout effect * and closes in its passive effect (`RenderComponent` in `engine/mapper.tsx`), on mount and when its `showIf` flips, - * through `useBracket` (`components/useBracket.ts`). Brackets nest. The outermost one pauses validation when nothing - * else has, and resumes it when it closes. + * through `useBracket` (`components/useBracket.ts`). `Modal`, `Expand` and `TableView` do the same around the fields + * they remove on their own. Brackets nest. The outermost one pauses validation when nothing else has, and resumes it + * when it closes. */ export type Registrations = { begin: () => void, end: () => void } diff --git a/src/demo/markdowns/changelog.md b/src/demo/markdowns/changelog.md index cfdb9fc2..70b9ac2f 100644 --- a/src/demo/markdowns/changelog.md +++ b/src/demo/markdowns/changelog.md @@ -866,6 +866,12 @@ Nothing to do for these: #### Fixes +- **Fields that go on their own cost the form one pass.** An Expand's content goes when its collapse's animation + ends, and a table's rows go when they collapse 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, as a closed popup's did. `Expand` + and the table bracket them now. In Chromium, collapsing an Expand that holds a 200-row table beside 200 required + fields takes 124 ms instead of 535: 8 to 11 ms of it is script, and the rest is the frames of the animation. On + React 16 these fields still go one by one. - **Fields that mount or go later cost the form one pass in two more cases.** react-final-form's probe of a field's first render validated every field in the form while validation ran, once for each new field: in Chromium, showing a 200-row table beside 200 required fields took 153 ms, and takes 98. A popup's fields diff --git a/src/library/__tests__/later-mount-budget.test.js b/src/library/__tests__/later-mount-budget.test.js index 47bf67ea..41888c67 100644 --- a/src/library/__tests__/later-mount-budget.test.js +++ b/src/library/__tests__/later-mount-budget.test.js @@ -21,6 +21,12 @@ * before any bracket opened. Here that was each field once more. Beside 100 other required fields, it was 101 times * each new field: showing the table there ran 10502 validators, 10201 of them from the probes. * + * Fields can also go when nothing mounts and no `showIf` flips: a popup that closes, an Expand whose collapse ends, rows + * that collapse, or that go with the host's data. The component that removes them brackets them then: `Modal`, + * `Expand` and `TableView` (2026-10-14). Before, beside 100 other required fields, each of these validated every + * field left once for each field that went: closing the popup ran 15150 validators, the Expand's collapse 15150, + * collapsing the table's rows 6275, and data with 5 of its 50 rows 14307. + * * React 16 runs a removed component's effect cleanups during the commit, before any layout effect, so no bracket is * open when the table's fields go: there they still go one by one, and cost what they did. A number here that falls * is progress: lower it. One that rises is a regression. @@ -57,8 +63,9 @@ jest.mock('final-form', () => { }) const ROWS = 50 +const MAJOR = Number(React.version.split('.')[0]) // Whether the fields that go are bracketed too: not on React 16 (see above). -const REMOVALS_BRACKETED = Number(React.version.split('.')[0]) > 16 +const REMOVALS_BRACKETED = MAJOR > 16 const table = { view: 'Table', name: 'rows', @@ -69,18 +76,21 @@ const table = { } const data = () => ({ show: 'no', rows: Array.from({ length: ROWS }, (_, i) => ({ note: `note ${i}`, qty: i })) }) -/** What an action costs the form. */ -async function costOf (action) { +/** What an action costs the form, with what it sets off within `wait` milliseconds. */ +async function costOf (action, wait = 100) { mockCount.computed = 0 mockCount.validated = 0 await act(async () => { action() // A tab changes after its 50 ms transition (`Tabs.tsx`). - await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise(resolve => setTimeout(resolve, wait)) }) return { ...mockCount } } +const besideOthers = () => Array.from({ length: 100 }, (_, i) => ({ view: 'Input', name: `other${i}`, validate: 'required' })) +const valuesOf = fields => Object.fromEntries(fields.map(({ name }) => [name, 'x'])) + const formHolding = key => [...formsStorage.values()].map(entry => entry.form).find(candidate => key in candidate.getState().values) describe('fields that mount after the first render: a 50-row table of two fields per row', () => { @@ -145,4 +155,63 @@ describe('fields that mount after the first render: a 50-row table of two fields // validated and told every field left, 15150 validators in all, which React 16 still does. expect(closed).toEqual(REMOVALS_BRACKETED ? { computed: 600, validated: 100 } : { computed: 60800, validated: 15150 }) }) + + it('costs an Expand that holds it beside 100 other required fields, opening and collapsing, a pass over them each', async () => { + // The content goes when the collapse's animation ends, 500 ms after the click, from a timer: nothing mounts or + // changes then that would bracket its fields going, so the Expand brackets them. + const others = besideOthers() + const meta = { view: 'Col', items: [...others, { view: 'Expand', title: 'Details', items: [table] }] } + const values = { ...data(), ...valuesOf(others) } + render( {}} />) + const form = formHolding('other0') + const title = screen.getByRole('button', { name: 'Details' }) + const opened = await costOf(() => fireEvent.click(title)) + expect(form.getRegisteredFields()).toHaveLength(2 * ROWS + 101) + // The click starts the collapse, and nothing goes yet. + expect(await costOf(() => fireEvent.click(title))).toEqual({ computed: 0, validated: 0 }) + const collapsed = await costOf(() => {}, 600) + expect(form.getRegisteredFields()).toHaveLength(100) + expect(opened).toEqual({ computed: 2011, validated: 201 }) + expect(collapsed).toEqual(REMOVALS_BRACKETED ? { computed: 600, validated: 100 } : { computed: 60800, validated: 15150 }) + }) + + it('costs every row of a table expanding beside 100 other required fields, and collapsing again, a pass over them each', async () => { + // Which rows are expanded is the table's own state: nothing mounts or changes when they collapse that would + // bracket their fields going, so the table brackets them. + const others = besideOthers() + const rows = { + view: 'Table', + name: 'rows', + headers: [ + { id: 'note', label: 'Note' }, + { id: '#', renderHeader: { view: 'Checkbox', label: 'Expand all', onChange: 'handleToggleExpandAll' } }, + ], + renderItem: { view: 'Input', name: 'detail', label: 'Detail', validate: 'required' }, + } + const values = { rows: data().rows.map(({ note }, i) => ({ note, detail: `detail ${i}` })), ...valuesOf(others) } + render( {}} />) + const form = formHolding('other0') + const expanded = await costOf(() => fireEvent.click(screen.getByLabelText('Expand all'))) + expect(form.getRegisteredFields()).toHaveLength(ROWS + 101) + const collapsed = await costOf(() => fireEvent.click(screen.getByLabelText('Expand all'))) + expect(form.getRegisteredFields()).toHaveLength(101) + expect(expanded).toEqual({ computed: 1459, validated: 151 }) + expect(collapsed).toEqual(REMOVALS_BRACKETED ? { computed: 606, validated: 101 } : { computed: 25302, validated: 6275 }) + }) + + it('costs data with 5 of its 50 rows, beside 100 other required fields, a pass over what is left', async () => { + // The table's rows are keyed by their index, so rows go only when there are fewer, and nothing mounts then that + // would bracket their fields going: the table brackets them. + const others = besideOthers() + const meta = { view: 'Col', items: [...others, table] } + const values = count => ({ rows: data().rows.slice(0, count), ...valuesOf(others) }) + const { rerender } = render( {}} />) + const form = formHolding('other0') + const fewer = await costOf(() => rerender( {}} />)) + expect(form.getRegisteredFields()).toHaveLength(2 * 5 + 101) + // React 18 computes the form's state once more here, two field states for each field left, with the bracket or + // without it: measured on 18.3.1, against 17.0.2 and 19.3.0. + const bracketed = MAJOR === 18 ? { computed: 1977, validated: 333 } : { computed: 1755, validated: 333 } + expect(fewer).toEqual(REMOVALS_BRACKETED ? bracketed : { computed: 57201, validated: 14217 }) + }) })