diff --git a/CLAUDE.md b/CLAUDE.md index 04dd6d61..4549fa95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/e2e/interactive-views.pw.js b/e2e/interactive-views.pw.js index c4b4d6b1..2e2d7f1c 100644 --- a/e2e/interactive-views.pw.js +++ b/e2e/interactive-views.pw.js @@ -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') diff --git a/e2e/reference.js b/e2e/reference.js index 56597863..1f4b9c2b 100644 --- a/e2e/reference.js +++ b/e2e/reference.js @@ -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', } /** diff --git a/e2e/view-coverage.js b/e2e/view-coverage.js index d3a17cb3..80883ecd 100644 --- a/e2e/view-coverage.js +++ b/e2e/view-coverage.js @@ -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', }, diff --git a/src/core/components/InputDate.tsx b/src/core/components/InputDate.tsx index 4df15537..f849687e 100644 --- a/src/core/components/InputDate.tsx +++ b/src/core/components/InputDate.tsx @@ -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' @@ -25,6 +26,55 @@ type DatePickerProps = PickerProps */ const Picker = PickerJs as React.ComponentType> +// 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 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 `` as they are: `role`, `aria-*` and `data-*` (rc-util's `pickAttrs`). */ +function ariaAndData (props: Record): Record { + const picked: Record = {} + 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 @@ -113,6 +163,20 @@ const InputDate = ({ // inside the element its ref hands out. const field = useRef(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(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) @@ -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 , so this + // is a form-control boundary: ENGINE_PROPS only. + const passed = omitProps(props, ENGINE_PROPS) as Record + // The props rc-picker is handed below, before its own, and so what the stand-in renders from. + const given: Record = { + name, id, className: 'ui-render-picker', disabled, 'aria-describedby': idHelp, placeholder: translate(placeholder), + ...passed, + } + return ( {translate(label)}} - , 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 ? ( + + ) : ( + // 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. +
| undefined} + onMouseLeave={given.onMouseLeave as React.MouseEventHandler | 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')} + > +
+ setMounted('focus')} + // Nothing types here: a focus mounts the picker first. + onChange={() => undefined} + /> +
+
+ )}
{idHelp && diff --git a/src/core/components/__tests__/InputDate.server.test.js b/src/core/components/__tests__/InputDate.server.test.js new file mode 100644 index 00000000..fe8633dd --- /dev/null +++ b/src/core/components/__tests__/InputDate.server.test.js @@ -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( + + {}} /> + + ) + + expect(html).toContain('
{ this.callback([...this.elements].map(target => ({ target, isIntersecting }))) }) + } +} + +const withObserver = () => { global.IntersectionObserver = FakeObserver } +const withoutObserver = () => { delete global.IntersectionObserver } +afterEach(withoutObserver) + +const field = (props, config = initialConfigState) => ( + + {}} {...props}/> + +) + +/** The container's markup with each element's attributes sorted, as the examples' DOM contract compares it. */ +function markupOf (container) { + const copy = container.cloneNode(true) + for (const element of copy.querySelectorAll('*')) { + const attributes = [...element.attributes].map(({ name, value }) => [name, value]).sort(([a], [b]) => (a < b ? -1 : 1)) + for (const [name] of attributes) element.removeAttribute(name) + for (const [name, value] of attributes) element.setAttribute(name, value) + } + return copy.innerHTML +} + +const CASES = { + 'no value': {}, + 'a value': { value: '2024-03-15' }, + 'a placeholder': { placeholder: 'Pick a day', value: '' }, + 'disabled': { disabled: true, value: '2024-03-15' }, + 'read-only': { readonly: true, value: '2024-03-15' }, + 'an error, which the input names': { error: 'Required', value: '' }, + 'a name and a label': { name: 'start', label: 'Start', value: '2024-03-15' }, + 'a title, which rc-picker drops': { title: 'When', value: '2024-03-15' }, + 'aria, data, role, tab index and required': { 'aria-label': 'Start Date', 'data-row': '3', role: 'textbox', tabIndex: 2, required: true }, + 'its own autocomplete': { autoComplete: 'bday', value: '2024-03-15' }, + 'a Date value': { value: new Date(2024, 2, 15) }, +} + +describe('a date field before its picker mounts', () => { + it.each(Object.entries(CASES))('renders what rc-picker renders for a closed field, with %s', (_, props) => { + withObserver() + const standIn = render(field(props)) + expect(watched.elements.size).toBe(1) + const before = markupOf(standIn.container) + standIn.unmount() + + withoutObserver() + const picker = render(field(props)) + expect(markupOf(picker.container)).toBe(before) + }) + + it('renders a month name as rc-picker does, in its own locale, whatever moment\'s global one is', () => { + const config = { ...initialConfigState, dateFormat: 'DD MMM YYYY' } + const previous = moment.locale() + moment.locale('fr') + try { + withObserver() + const standIn = render(field({ value: '2024-03-15' }, config)) + const before = markupOf(standIn.container) + standIn.unmount() + + withoutObserver() + const picker = render(field({ value: '2024-03-15' }, config)) + expect(markupOf(picker.container)).toBe(before) + expect(screen.getByDisplayValue('15 Mar 2024')).toBeTruthy() + } finally { + moment.locale(previous) + } + }) + + it('mounts the picker when the field comes near the viewport, and stops watching it', () => { + withObserver() + const { container } = render(field({ value: '2024-03-15' })) + const standIn = container.querySelector('input') + watched.near() + expect(watched.elements.size).toBe(0) + expect(standIn.isConnected).toBe(false) + expect(container.querySelector('input').value).toBe('03-15-2024') + // Nothing took the focus. + expect(document.activeElement).toBe(document.body) + }) + + it('keeps the stand-in while the field is still far from the viewport', () => { + withObserver() + const { container } = render(field({ value: '2024-03-15' })) + const standIn = container.querySelector('input') + watched.near(false) + expect(standIn.isConnected).toBe(true) + expect(watched.elements.size).toBe(1) + }) + + it('ignores a report for a field that has stopped waiting', () => { + withObserver() + const { container, unmount } = render(field({ value: '2024-03-15' })) + const element = container.querySelector('.ui-render-picker') + unmount() + act(() => { watched.callback([{ target: element, isIntersecting: true }]) }) + expect(watched.elements.size).toBe(0) + }) + + it('mounts the picker as a pointer comes over the field, before it is pressed', () => { + withObserver() + const { container } = render(field({ value: '2024-03-15' })) + const standIn = container.querySelector('input') + fireEvent.pointerEnter(container.querySelector('.ui-render-picker')) + expect(standIn.isConnected).toBe(false) + // Mounted as it is near the viewport: closed, and not focused. + expect(document.activeElement).toBe(document.body) + expect(document.querySelector('.ui-render-picker-dropdown')).toBeNull() + }) + + it('mounts the picker on a focus, and hands it the focus, which the field hears', () => { + withObserver() + const heard = [] + const { container } = render(field({ value: '2024-03-15', onFocus: () => heard.push('focus') })) + act(() => { container.querySelector('input').focus() }) + const input = container.querySelector('input') + expect(document.activeElement).toBe(input) + expect(heard).toEqual(['focus']) + expect(document.querySelector('.ui-render-picker-dropdown')).toBeNull() + }) + + it('mounts the picker open and focused on a press no pointer came over before', () => { + withObserver() + const { container } = render(field({ value: '2024-03-15' })) + fireEvent.pointerDown(container.querySelector('.ui-render-picker')) + expect(document.activeElement).toBe(container.querySelector('input')) + expect(document.querySelector('.ui-render-picker-dropdown')).not.toBeNull() + }) + + it('changes nothing on a change that reaches the stand-in, which only a script can send before a focus', () => { + withObserver() + const changes = [] + const { container } = render(field({ value: '2024-03-15', onChange: value => changes.push(value) })) + const standIn = container.querySelector('input') + fireEvent.change(standIn, { target: { value: '04-01-2024' } }) + expect(standIn.value).toBe('03-15-2024') + expect(standIn.isConnected).toBe(true) + expect(changes).toEqual([]) + }) + + it('stops waiting when the field goes before it came near', () => { + withObserver() + const { unmount } = render(field({ value: '2024-03-15' })) + expect(watched.elements.size).toBe(1) + unmount() + expect(watched.elements.size).toBe(0) + }) +}) diff --git a/src/demo/markdowns/changelog.md b/src/demo/markdowns/changelog.md index 70b9ac2f..a63e875a 100644 --- a/src/demo/markdowns/changelog.md +++ b/src/demo/markdowns/changelog.md @@ -866,6 +866,14 @@ Nothing to do for these: #### Fixes +- **A date field mounts its calendar picker when it is needed.** A closed rc-picker cost about as much as the rest + of a 200-row period table. A date field now renders what rc-picker renders for a closed field, the same element + with the same attributes, until it comes within 200 px of the viewport, a pointer comes over it, or it takes the + focus. Then rc-picker takes its place, and a click or a key reaches it as before. In Chromium, the 200-row period + table mounts in 150 ms instead of 251. The pickers of the rows a user scrolls to mount as they come near, so a + scroll through the whole table does the work its mount no longer does, a few milliseconds a frame. Where there is + no IntersectionObserver, as in jsdom, the picker mounts before the first paint, so a host's tests see rc-picker as + before. - **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`