From 8883db9764c78a7f727f2cca169949e768796aaf Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Wed, 14 Oct 2026 12:17:15 +0200 Subject: [PATCH] EPBDS-16211: 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: the same table with text fields in place of its 400 date fields mounted in about half the time. A date field now renders what rc-picker renders for a closed field, the same elements with the same attributes read from the same props, 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: under a pointer at once, so that the press is rc-picker's own, and on a focus with the focus handed to it. Where there is no IntersectionObserver, as in jsdom and on the server, the picker mounts before the first paint, so jsdom tests see rc-picker as before. InputDate.stand-in.test.js compares the two renderings for a set of props. In Chromium, the 200-row period table mounts in 150 ms instead of 251. --- CLAUDE.md | 9 + e2e/interactive-views.pw.js | 36 ++++ e2e/reference.js | 2 + e2e/view-coverage.js | 2 + src/core/components/InputDate.tsx | 163 +++++++++++++--- .../__tests__/InputDate.server.test.js | 28 +++ .../__tests__/InputDate.stand-in.test.js | 184 ++++++++++++++++++ src/demo/markdowns/changelog.md | 8 + 8 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 src/core/components/__tests__/InputDate.server.test.js create mode 100644 src/core/components/__tests__/InputDate.stand-in.test.js 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`