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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
109 changes: 109 additions & 0 deletions src/core/components/__tests__/useBracket.test.js
Original file line number Diff line number Diff line change
@@ -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 <Below record={record} k={k}/>
}

/** 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(<Bracketed record={record} bracket={record.bracket} k="a"/>)
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(<Bracketed record={record} bracket={record.bracket} k="a"/>)
rerender(<Bracketed record={record} bracket={record.bracket} k="a"/>)
expect(record.log).toEqual(['begin', 'below a at 1', 'end'])

rerender(<Bracketed record={record} bracket={record.bracket} k="b"/>)
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 = () => (
<Suspense fallback={<p>Loading</p>}>
<Bracketed record={record} bracket={record.bracket} k="a"/>
<Later/>
</Suspense>
)
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(<StrictMode><Bracketed record={record} bracket={record.bracket} k="a"/></StrictMode>)
const once = ['begin', 'below a at 1', 'end']
expect(record.log).toEqual(MAJOR >= 18 ? [...once, ...once] : once)
})

// React 19's `<Activity>` 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 <Activity> shows it again', () => {
const { Activity } = React
const record = recorded()
const page = mode => <Activity mode={mode}><Bracketed record={record} bracket={record.bracket} k="a"/></Activity>
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(<Bracketed record={record} bracket={undefined} k="a"/>)
expect(record.log).toEqual(['below a at 0'])
})
})
37 changes: 37 additions & 0 deletions src/core/components/useBracket.ts
Original file line number Diff line number Diff line change
@@ -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])
}
10 changes: 3 additions & 7 deletions src/core/engine/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
// A control inside that used the key claims it: an open listbox closes on Escape and says so.
Expand Down
18 changes: 7 additions & 11 deletions src/core/engine/mapper.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 37 additions & 1 deletion src/core/modules/form/__tests__/batchedForm.document.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = () => (
<Suspense fallback={<p>Loading</p>}>
<UIRender meta={meta} data={{ note: 'x' }} initialValues={{ note: 'x' }} onSubmit={() => {}} />
<Later />
</Suspense>
)
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 {
Expand Down
5 changes: 3 additions & 2 deletions src/core/modules/form/batchedForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
Loading