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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ react-final-form takes the first notification after it as the probe's state.

`form-mount-budget.test.js` and `later-mount-budget.test.js` count what the bursts cost.

A date field mounts rc-picker only when it is needed (`InputDate.tsx`, 2026-10-14): a closed rc-picker cost about as
much as the rest of a 200-row period table. Until the field comes within 200 px of the viewport, a pointer comes over
it, or it takes the focus, it renders a stand-in: the element rc-picker renders for a closed field, read from the same
props. `InputDate.stand-in.test.js` compares the two for a set of props, so an rc-picker update that changes its closed
DOM fails there. Where there is no IntersectionObserver, which is jsdom and the server, the picker mounts before the
first paint, so jsdom tests see rc-picker. `e2e/interactive-views.pw.js` drives the stand-in in Chromium with an
observer that never reports. The work moves rather than goes: a scroll through all 200 rows of that table mounts the
pickers of the rows it passes, measured at about 255 ms against 66 on an unminified build, a few milliseconds a frame.

To find what a cost comes from, count before naming the cause: wrap the suspected function or component with
`jest.mock` and count calls per action. Comparing timings with and without a feature does not name a cause.

Expand Down
36 changes: 36 additions & 0 deletions e2e/interactive-views.pw.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ test.describe('date input: the calendar overlay', () => {
})
})

test.describe('date input: a field whose picker has not mounted', () => {
// A date field renders a stand-in for rc-picker until it comes near the viewport (`InputDate.tsx`, 2026-10-14).
// With an observer that never reports, every field keeps its stand-in, so a press or a focus is the first thing it
// meets, and mounts the picker under the pointer or the keyboard.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.IntersectionObserver = class { observe () {} unobserve () {} disconnect () {} }
})
})
const field = page => page.locator('#tableForm input[name="dataKind.period[0].startDate"]')
const calendar = page => page.locator('.ui-render-picker-dropdown:not(.ui-render-picker-dropdown-hidden)')

test('[I] a click opens the calendar, the field keeps the focus, and a day picked is written', async ({ page, pageErrors }) => {
await openExample(page, 'tableForm')
await field(page).click()

await expect(calendar(page)).toBeVisible()
await expect(field(page)).toBeFocused()
await calendar(page).locator(`td[title="${DATE_INPUT.PICKED_DAY}"]`).click()
await expect(field(page)).toHaveValue(DATE_INPUT.PICKED_VALUE)
expect(pageErrors).toEqual([])
})

test('[I] a focus hands the field the focus, and a date typed there is written', async ({ page, pageErrors }) => {
await openExample(page, 'tableForm')
await field(page).focus()

await expect(field(page)).toBeFocused()
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.type(DATE_INPUT.TYPED_VALUE)
await page.keyboard.press('Tab')
await expect(field(page)).toHaveValue(DATE_INPUT.TYPED_VALUE)
expect(pageErrors).toEqual([])
})
})

test.describe('popup: the modal a `popupOpen` action opens', () => {
const trigger = page => page.locator('#popupContent').getByRole('button', { name: 'Open Popup 1' })
const popup = page => page.locator('.app__popup')
Expand Down
2 changes: 2 additions & 0 deletions e2e/reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ const DATE_INPUT = {
INITIAL_VALUE: '01-01-2022',
PICKED_DAY: '2022-01-15',
PICKED_VALUE: '01-15-2022',
/** [I] A date typed in the display format, which the field keeps. */
TYPED_VALUE: '02-03-2022',
}

/**
Expand Down
2 changes: 2 additions & 0 deletions e2e/view-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const VIEW_COVERAGE = {
t(IV, '[I] a click on a day writes it in the display format and closes the calendar'),
t(IV, '[I] a click outside closes the calendar and leaves the value alone'),
t(IV, '[I] a date field inside it opens its calendar above it, where a click picks a day'),
t(IV, '[I] a click opens the calendar, the field keeps the focus, and a day picked is written'),
t(IV, '[I] a focus hands the field the focus, and a date typed there is written'),
],
gap: 'text and number inputs: native controls with nothing positioned or layered; typing, formatting and validation are state, and jsdom covers them',
},
Expand Down
163 changes: 138 additions & 25 deletions src/core/components/InputDate.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classNames from '../utils/classNames'
import React, { useCallback, useContext, useMemo, useRef } from 'react'
import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import Row from './Row'
import Text from './Text'
import View from './View'
Expand All @@ -25,6 +26,55 @@ type DatePickerProps = PickerProps<Moment>
*/
const Picker = PickerJs as React.ComponentType<DatePickerProps & { resize?: boolean } & React.RefAttributes<PickerRef>>

// 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

/**
* How far outside the viewport a field mounts its picker, so that one scrolled to is ready when it is reached.
*/
const NEAR = '200px'

/** The fields that wait to come near the viewport, and what each then does. One observer watches them all. */
const waiting = new Map<Element, () => void>()
let observer: IntersectionObserver | null = null

/** Calls `near` once `element` comes within NEAR of the viewport, and returns what stops the wait. */
function whenNear (element: Element, near: () => void): () => void {
if (!observer) {
observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const call = waiting.get(entry.target)
waiting.delete(entry.target)
observer!.unobserve(entry.target)
if (call) call()
}
}, {rootMargin: NEAR})
}
waiting.set(element, near)
observer.observe(element)
return () => {
waiting.delete(element)
observer!.unobserve(element)
}
}

/** The props rc-picker copies onto its `<input>` as they are: `role`, `aria-*` and `data-*` (rc-util's `pickAttrs`). */
function ariaAndData (props: Record<string, unknown>): Record<string, unknown> {
const picked: Record<string, unknown> = {}
for (const key in props) {
if (key === 'role' || key.startsWith('aria-') || key.startsWith('data-')) picked[key] = props[key]
}
return picked
}

/**
* What mounted a field's picker: coming near the viewport or under a pointer, or a focus or a press while it was not
* there yet.
*/
type PickerMount = 'near' | 'focus' | 'press'

/** The named props are read here; the rest is passed to rc-picker through ./domProps. */
export type InputDateProps = {
name?: string
Expand Down Expand Up @@ -113,6 +163,20 @@ const InputDate = ({
// inside the element its ref hands out.
const field = useRef<Element | null>(null)
const onPicker = useCallback((picker: PickerRef | null) => { field.current = picker && picker.nativeElement }, [])
const onStandIn = useCallback((element: HTMLDivElement | null) => { field.current = element }, [])
// rc-picker cost a closed field about as much as the rest of a 200-row period table did (2026-10-14): a field
// renders what rc-picker renders while closed, the stand-in below, until it comes near the viewport. Where there is
// no IntersectionObserver, which is the server and jsdom, the picker mounts before the first paint.
const [mounted, setMounted] = useState<PickerMount | null>(autofocus ? 'focus' : null)
useBeforePaintEffect(() => {
if (mounted) return undefined
if (typeof IntersectionObserver === 'undefined') {
setMounted('near')
return undefined
}
// An assertion, not a guard: the stand-in's ref is attached before its parent's layout effects run.
return whenNear(field.current!, () => setMounted(current => current || 'near'))
}, [mounted])
const ownId = useOwnId(idGiven ? undefined : derivedId, field)
const id = idGiven || ownId
if (!label && title) props.title = translate(title)
Expand Down Expand Up @@ -157,6 +221,15 @@ const InputDate = ({
onChange(changedDate ? changedDate.format('YYYY-MM-DD') : null)
}

// DOM boundary (see ./domProps): what reaches rc-picker, which forwards unknown props to its own <input>, so this
// is a form-control boundary: ENGINE_PROPS only.
const passed = omitProps(props, ENGINE_PROPS) as Record<string, unknown>
// The props rc-picker is handed below, before its own, and so what the stand-in renders from.
const given: Record<string, unknown> = {
name, id, className: 'ui-render-picker', disabled, 'aria-describedby': idHelp, placeholder: translate(placeholder),
...passed,
}

return (
<View
className={classNames('input--wrapper', className, {
Expand All @@ -168,30 +241,70 @@ const InputDate = ({
{label && <Label htmlFor={id} title={translate(title)}>{translate(label)}</Label>}
</Row>
<Row className={classNames('input', {icon, lefty, error, info, unit})}>
<Picker
ref={onPicker}
name={name}
id={id}
prefixCls={'ui-render-picker'}
className={'ui-render-picker'}
disabled={disabled}
resize={resize}
aria-describedby={idHelp}
placeholder={translate(placeholder)}
generateConfig={generateConfig}
// DOM boundary (see ./domProps): the spread reaches rc-picker, which forwards unknown props to its own <input>, so this is
// a form-control boundary: ENGINE_PROPS only.
{...omitProps(props, ENGINE_PROPS)}
value={value}
allowClear={false}
locale={enUs}
picker='date'
format={[dateFormat, 'YYYY-MM-DD']}
onChange={onDateChanged}
onCalendarChange={onSelect}
onFocus={onFocus}
onBlur={onBlur}
/>
{mounted ? (
<Picker
ref={onPicker}
name={name}
id={id}
prefixCls={'ui-render-picker'}
className={'ui-render-picker'}
disabled={disabled}
resize={resize}
aria-describedby={idHelp}
placeholder={translate(placeholder)}
generateConfig={generateConfig}
{...passed}
// Mounted by a focus or a press on the stand-in: it takes the focus, and a press opens it.
{...(mounted !== 'near' && {autoFocus: true})}
{...(mounted === 'press' && {defaultOpen: true})}
value={value}
allowClear={false}
locale={enUs}
picker='date'
format={[dateFormat, 'YYYY-MM-DD']}
onChange={onDateChanged}
onCalendarChange={onSelect}
onFocus={onFocus}
onBlur={onBlur}
/>
) : (
// The stand-in: what rc-picker renders for a closed field, attribute for attribute, read from the same
// props (`InputDate.stand-in.test.js`). It has rc-picker's elements, and none of its behaviour.
<div
ref={onStandIn}
className={classNames('ui-render-picker', {'ui-render-picker-disabled': given.disabled}, given.className as string)}
onMouseEnter={given.onMouseEnter as React.MouseEventHandler<HTMLDivElement> | undefined}
onMouseLeave={given.onMouseLeave as React.MouseEventHandler<HTMLDivElement> | undefined}
// A pointer comes over a field before it presses it, a touch or a pen as it touches: the picker
// mounts then, at once, so that the press is rc-picker's own. A press that came first (a pointer
// that has not moved since the field rendered under it) mounts it open and focused instead.
onPointerEnter={() => flushSync(() => setMounted(current => current || 'near'))}
onPointerDown={() => setMounted(current => current || 'press')}
>
<div className="ui-render-picker-input">
<input
aria-invalid={false}
autoComplete="off"
{...ariaAndData(given)}
readOnly={given.inputReadOnly as boolean | undefined}
required={given.required as boolean | undefined}
name={given.name as string | undefined}
{...(given.autoComplete !== undefined && {autoComplete: given.autoComplete as string})}
// rc-picker's width, in characters: the format's, and at least 10, and 2 more.
size={Math.max(10, dateFormat.length) + 2}
id={given.id as string | undefined}
// rc-picker's own text: the first format, in its locale, whatever moment's global one is.
value={value ? generateConfig.locale.format(enUs.locale, value, dateFormat) : ''}
placeholder={given.placeholder as string | undefined}
disabled={given.disabled as boolean | undefined}
tabIndex={given.tabIndex as number | undefined}
onFocus={() => setMounted('focus')}
// Nothing types here: a focus mounts the picker first.
onChange={() => undefined}
/>
</div>
</div>
)}
</Row>
{idHelp &&
<View id={idHelp} className='field-help'>
Expand Down
28 changes: 28 additions & 0 deletions src/core/components/__tests__/InputDate.server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** @jest-environment node */
/**
* `InputDate` ON THE SERVER. It renders the stand-in for rc-picker there (`InputDate.stand-in.test.js`): the server has
* no IntersectionObserver and runs no effect, so nothing mounts the picker. Its effect is a plain one where there is no
* `window`, since React 16 and 17 warn about a layout effect on every server render.
*/
import React from 'react'
import { renderToString } from 'react-dom/server'
import InputDate from '../InputDate'
import { ConfigContext, initialConfigState } from '../../contexts/ConfigContext'

it('renders the stand-in on the server, with the value in the configured format, and no warning', () => {
const errors = jest.spyOn(console, 'error').mockImplementation(() => {})
try {
const html = renderToString(
<ConfigContext.Provider value={initialConfigState}>
<InputDate name="start" value="2024-03-15" onChange={() => {}} />
</ConfigContext.Provider>
)

expect(html).toContain('<div class="ui-render-picker ui-render-picker"><div class="ui-render-picker-input"><input')
expect(html).toContain('name="start"')
expect(html).toContain('value="03-15-2024"')
expect(errors).not.toHaveBeenCalled()
} finally {
errors.mockRestore()
}
})
Loading
Loading