From 311f05dfa7c2f0dc19099e105046e83a6903ef1a Mon Sep 17 00:00:00 2001
From: Aliaksandr Samuseu
Date: Tue, 13 Oct 2026 16:32:51 +0200
Subject: [PATCH] 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 }