From 311f05dfa7c2f0dc19099e105046e83a6903ef1a Mon Sep 17 00:00:00 2001
From: Aliaksandr Samuseu
Date: Tue, 13 Oct 2026 16:32:51 +0200
Subject: [PATCH 1/4] 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/4] 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 })
+ })
})
From b16373d6b910d3700054aa03bff4bcb46e9a5fe1 Mon Sep 17 00:00:00 2001
From: Aliaksandr Samuseu
Date: Tue, 13 Oct 2026 17:10:21 +0200
Subject: [PATCH 3/4] EPBDS-16211: the browser check times a table that comes
and goes beside 200 fields
Three documents put the tabs document's 200-row table beside 200 required
fields: shown by a `showIf`, in a popup, and in an Expand. Each times the action
that brings the table and the one that takes it away, which #220, #223 and the
Expand brackets made cost one pass over the form: only the jsdom contract held
them until now.
The tabs document and these share the table's declaration, and the tabs test
and these share `alternate`. The ceilings are estimated from an Apple M4's
numbers at the ratios #222 measured, until the runner has measured them.
---
CLAUDE.md | 7 ++-
e2e/perf/scenarios.perf.js | 91 ++++++++++++++++++++++-----
scripts/fixtures/packed-perf-entry.js | 55 +++++++++++-----
3 files changed, 120 insertions(+), 33 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c81e950..04dd6d61 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,9 +30,10 @@ 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, 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.
+ median of five runs of each action (an edit, a blur, a pick, a tab switch, a table shown and taken away), in eight
+ documents of `scripts/fixtures/packed-perf-entry.js`. The documents include a 200-row period table, a 200-row
+ table with a Select column, two tabs whose second holds a 200-row table, and that table beside 200 required
+ fields, shown by a `showIf`, in a popup, or in an Expand.
- **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=` measures a tarball packed elsewhere, such as another commit's, so two
diff --git a/e2e/perf/scenarios.perf.js b/e2e/perf/scenarios.perf.js
index 8b9e4ec6..68ab069c 100644
--- a/e2e/perf/scenarios.perf.js
+++ b/e2e/perf/scenarios.perf.js
@@ -7,8 +7,9 @@
* 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. 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
+ * shows the last run's value, so that an action that stops changing anything fails instead of measuring nothing. An
+ * action that brings a table, a tab switch or a `showIf`, a popup or an Expand, waits for the table's field to be there,
+ * and the one that takes it away for it to be gone: a collapse, when its animation ends. 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.
@@ -37,6 +38,10 @@ const CEILINGS = {
selectForm: { mount: 340, pick: 100, edit: 100 },
numberTable: { mount: 780, edit: 100 },
tabs: { mount: 210, switch: 360, back: 100 },
+ // Estimated from an Apple M4's numbers at the ratios above, until the runner has measured them.
+ reveal: { mount: 460, show: 510, hide: 210 },
+ popup: { mount: 470, open: 530, close: 230 },
+ expand: { mount: 500, expand: 860, collapse: 620 },
}
const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
@@ -73,6 +78,21 @@ async function record (scenario, name, measure, action) {
results[scenario][name] = { median: median(runs), runs }
}
+/**
+ * Runs `there` and then `back`, REPEAT times, and records the median of what each cost: an action that brings a
+ * table, and the one that takes it away again.
+ */
+async function alternate (scenario, [thereName, backName], measure, there, back) {
+ const theres = []
+ const backs = []
+ for (let i = 0; i < REPEAT; i++) {
+ theres.push(await measure(there))
+ backs.push(await measure(back))
+ }
+ results[scenario][thereName] = { median: median(theres), runs: theres }
+ results[scenario][backName] = { median: median(backs), runs: backs }
+}
+
/**
* Adds a scenario's numbers to PERF_OUT, then checks them against its ceilings. A failed test's worker is replaced,
* and this module's state with it, so the file, not `results`, is what holds every scenario's.
@@ -147,25 +167,64 @@ test('two tabs, the second a 200-row table: a switch to it, and back', async ({
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 alternate('tabs', ['switch', 'back'], measure, async () => {
+ await rows.click()
+ await cell.waitFor()
+ }, async () => {
+ await summary.click()
+ await cell.waitFor({ state: 'detached' })
+ })
await rows.click()
await expect(cell).toHaveValue('note 1')
check('tabs')
})
+test('a `showIf` that shows a 200-row table beside 200 required fields, and hides it', async ({ page }) => {
+ const { mount, measure } = await open(page, 'reveal')
+ results.reveal = { mount: { median: mount, runs: [mount] } }
+ const show = page.getByRole('textbox', { name: 'Show', exact: true })
+ const cell = page.locator('input[name="rows[1].note"]')
+ await alternate('reveal', ['show', 'hide'], measure, async () => {
+ await show.fill('yes')
+ await cell.waitFor()
+ }, async () => {
+ await show.fill('no')
+ await cell.waitFor({ state: 'detached' })
+ })
+ check('reveal')
+})
+
+test('a popup that holds a 200-row table beside 200 required fields: opening it, and closing it', async ({ page }) => {
+ const { mount, measure } = await open(page, 'popup')
+ results.popup = { mount: { median: mount, runs: [mount] } }
+ const opener = page.getByRole('button', { name: 'Open rows', exact: true })
+ const cell = page.locator('input[name="rows[1].note"]')
+ await alternate('popup', ['open', 'close'], measure, async () => {
+ await opener.click()
+ await cell.waitFor()
+ }, async () => {
+ await page.getByRole('dialog').press('Escape')
+ await cell.waitFor({ state: 'detached' })
+ })
+ check('popup')
+})
+
+test('an Expand that holds a 200-row table beside 200 required fields: expanding it, and collapsing it', async ({ page }) => {
+ const { mount, measure } = await open(page, 'expand')
+ results.expand = { mount: { median: mount, runs: [mount] } }
+ const title = page.getByRole('button', { name: 'Rows', exact: true })
+ const cell = page.locator('input[name="rows[1].note"]')
+ // A collapse waits for the content to go, when the animation ends.
+ await alternate('expand', ['expand', 'collapse'], measure, async () => {
+ await title.click()
+ await cell.waitFor()
+ }, async () => {
+ await title.click()
+ await cell.waitFor({ state: 'detached' })
+ })
+ check('expand')
+})
+
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] } }
diff --git a/scripts/fixtures/packed-perf-entry.js b/scripts/fixtures/packed-perf-entry.js
index 9a99d047..79de09bf 100644
--- a/scripts/fixtures/packed-perf-entry.js
+++ b/scripts/fixtures/packed-perf-entry.js
@@ -10,6 +10,19 @@ const ROWS = 200
const range = count => Array.from({ length: count }, (_, i) => i)
const records = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' }]
const choices = [{ text: 'A', value: 'a' }, { text: 'B', value: 'b' }, { text: 'C', value: 'c' }]
+// A 200-row table of a required text column and a bounded number column, and its rows.
+const rowsTable = {
+ 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 } },
+ ],
+}
+const tableRows = () => ({ rows: range(ROWS).map(i => ({ note: `note ${i}`, qty: i })) })
+// 200 required fields beside it, which every field that comes or goes used to validate, each.
+const besideFields = range(ROWS).map(i => ({ view: 'Input', name: `field${i}`, label: `Field ${i}`, validate: 'required' }))
+const besideValues = () => Object.fromEntries(range(ROWS).map(i => [`field${i}`, `value ${i}`]))
const periodField = name => ({
view: 'Input',
name,
@@ -57,27 +70,41 @@ 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.
+ // Two tabs, the second the 200-row table, 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 } },
- ],
- },
- },
+ { tab: 'Rows', content: rowsTable },
+ ],
+ },
+ data: tableRows(),
+ }),
+ // The 200-row table, which a `showIf` shows and hides, beside 200 required fields.
+ reveal: () => ({
+ meta: {
+ view: 'Col',
+ items: [{ view: 'Input', name: 'show', label: 'Show' }, ...besideFields, { ...rowsTable, showIf: { name: 'show', equal: 'yes' } }],
+ },
+ data: { show: 'no', ...besideValues(), ...tableRows() },
+ }),
+ // A popup that holds the 200-row table, beside 200 required fields.
+ popup: () => ({
+ meta: {
+ view: 'Col',
+ items: [
+ { view: 'Button', children: 'Open rows', onClick: { name: 'popupOpen', args: ['rows'] } },
+ ...besideFields,
+ { view: 'Popup', id: 'rows', title: 'Rows', items: [rowsTable] },
],
},
- data: { rows: range(ROWS).map(i => ({ note: `note ${i}`, qty: i })) },
+ data: { ...besideValues(), ...tableRows() },
+ }),
+ // An Expand that holds the 200-row table, beside 200 required fields. Its content goes when its animation ends.
+ expand: () => ({
+ meta: { view: 'Col', items: [...besideFields, { view: 'Expand', title: 'Rows', items: [rowsTable] }] },
+ data: { ...besideValues(), ...tableRows() },
}),
// A column of number fields with bounds, beside a text column.
numberTable: () => ({
From a23450d3fcba8343d5a6fa4c4ccd77763a821f8c Mon Sep 17 00:00:00 2001
From: Aliaksandr Samuseu
Date: Wed, 14 Oct 2026 10:22:40 +0200
Subject: [PATCH 4/4] EPBDS-16211: the new documents' ceilings follow what the
runner measured
The runner's first measure of them, in this PR's browser-perf run: reveal 107,
109 and 39 ms, popup 104, 136 and 44, expand 112, 240 and 158 (mount, the action
that brings the table, the one that takes it away). The ceilings estimated from
the M4 were 3.6 to 5.4 times that. They are 2.5 times it now, and at least 100 ms
for an action, as the header says.
---
e2e/perf/scenarios.perf.js | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/e2e/perf/scenarios.perf.js b/e2e/perf/scenarios.perf.js
index 68ab069c..8c1b3d87 100644
--- a/e2e/perf/scenarios.perf.js
+++ b/e2e/perf/scenarios.perf.js
@@ -38,10 +38,9 @@ const CEILINGS = {
selectForm: { mount: 340, pick: 100, edit: 100 },
numberTable: { mount: 780, edit: 100 },
tabs: { mount: 210, switch: 360, back: 100 },
- // Estimated from an Apple M4's numbers at the ratios above, until the runner has measured them.
- reveal: { mount: 460, show: 510, hide: 210 },
- popup: { mount: 470, open: 530, close: 230 },
- expand: { mount: 500, expand: 860, collapse: 620 },
+ reveal: { mount: 270, show: 270, hide: 100 },
+ popup: { mount: 260, open: 340, close: 110 },
+ expand: { mount: 280, expand: 600, collapse: 400 },
}
const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]