Skip to content

EPBDS-16211: a date field mounts its calendar picker when it is needed - #227

Merged
AlexSamBY merged 1 commit into
masterfrom
lazy-date-picker
Oct 14, 2026
Merged

AlexSamBY merged 1 commit into
masterfrom
lazy-date-picker

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What

A closed rc-picker was the largest cost left in the heaviest document's mount. I profiled the 200-row period table's mount on an unminified build in Chromium.

  • The profile. Over five runs, 117 ms of 314 sampled was under frames of rc-picker and the packages it brings: its trigger, rc-util and moment.
  • The comparison. The same table with text fields in place of its 400 date fields mounted in about 138 ms, against 285, in two alternating builds of five runs each.

A date field now renders a stand-in until it is needed: the element rc-picker renders for a closed field.

  • What it is. It has the same elements and attributes, read from the same props that rc-picker is handed. It has none of rc-picker's behaviour.
  • When rc-picker takes its place:
    • near the viewport. The field comes within 200 px of it. One IntersectionObserver watches every waiting field.
    • under a pointer. A pointer comes over the field, or a touch or a pen touches it. It mounts at once, through flushSync, so that the press that follows is rc-picker's own.
    • on a focus. The field takes the focus, by Tab, a label or a script. It mounts with autoFocus, so the focus moves to rc-picker's input, and the field's onFocus hears it once.
    • on a press with no pointer over it first. This is a pointer that has not moved since the field rendered under it. It mounts open and focused (defaultOpen).
  • Where there is no IntersectionObserver. In jsdom the picker mounts before the first paint, in a layout effect. On the server, which runs no effect, the stand-in renders. So jsdom tests, ours and a host's, see rc-picker as before.

Measured

In Chromium

npm run test:perf --tarball on the packed master and on this branch: five interleaved rounds on an Apple M4. The 200-row period table's mount:

rounds median
master 305 / 244 / 251 / 318 / 235 251
this branch 557 / 150 / 141 / 144 / 150 150
  • The 557. It is the first run of the first round, under a load average of 6. master's run in that round was 305, its second highest. This branch's next two runs came out 150 and 141.
  • Everything else is within the spread of the five rounds: the other seven documents' mounts, and every action. The load average was 2.9 to 9.3.

What scrolling now costs

The work moves rather than goes: a scroll through the table mounts the pickers of the rows it passes.

  • How I timed it. A scroll from the top to the bottom, 11 472 px at 300 px a frame, on an unminified build. Two interleaved rounds of five runs each, under a load average of 10 to 14 for both.
  • Per frame, that is about 6.5 ms, against 1.7 on master.
  • What it is. A profile of it puts the time in mounting pickers: 131 ms under React's frames, 54 under rc-picker's, 28 under its trigger's and 12 under rc-util's.
  • Who pays it. A user who does not scroll through the whole table pays only for the rows they reach.
master this branch
a scroll through the whole table 66 ms (67 / 66) 255 ms (255 / 258)

Designs measured and dropped

Mounting on the first press, hover or focus only. I prototyped this first. The table mounted in 121 and 123 ms, but:

  • A test that keeps the input across a focus then works on a removed element. One of ours did.
  • The rc-picker contract tests need the picker mounted first. 18 of them would have needed changes.
  • A click raced React's update. Five browser tests of the calendar failed: the press reached the stand-in before the picker had mounted.

The viewport version passes every existing test unchanged.

Tests

  • InputDate.stand-in.test.js, new, 20 cases.
    • The stand-in and rc-picker render the same DOM for 11 sets of props, attributes sorted as the examples' DOM contract sorts them. The props include a value, a placeholder, disabled, read-only, an error the input names, a label, a title rc-picker drops, aria, data, role, tab index, required and autocomplete. One more set is a month-name format, with moment's global locale set to French, which rc-picker ignores.
    • The test catches a difference. With the stand-in's size off by one, 12 of these cases fail.
    • What mounts the picker. Coming near the viewport, and nothing while it is still far. A pointer coming over the field. A focus, which hands the picker the focus. A press no pointer came over before, which opens it.
    • What else. A change a script sends to the stand-in changes nothing. A report for a field that went is ignored, and a field that goes stops waiting.
  • InputDate.server.test.js, new. The server renders the stand-in, with the value in the configured format, and no warning.
  • e2e/interactive-views.pw.js, two new cases, listed in e2e/view-coverage.js. An observer that never reports keeps every field a stand-in. Then a click opens the calendar, the field keeps the focus and a day picked is written. A focus hands the field the focus, and a date typed there is written.
  • InputDate.tsx is covered fully, as its threshold requires.

What a user may notice

  • Nothing in the DOM. The stand-in is rc-picker's closed markup.
  • A field reached by Tab, beyond 200 px of the viewport. The focus moves from the stand-in to rc-picker's input as the picker mounts. The two have the same id, label and attributes. A screen reader may announce the field again. I have not measured that.

Gates

All 22 gates and the manifest contract exit 0 on the code of HEAD, on Node 24.14.0.

  • The first run failed three.
    • test:coverage and test:react16 failed on view-browser-coverage.contract.test.js: e2e/view-coverage.js did not list the two new browser tests. It does now.
    • test:react17 lost a jest worker to SIGSEGV in Dropzone.behavior.test.js. That is the Node 24 crash of Crash in V8 GC on v24.x and earlier nodejs/node#62393.
    • The three exit 0 on the amended commit.
  • Since then, a last amend changed only the text of the changelog and CLAUDE.md.

Tests.

  • The suite has 2940 tests, 21 of them new. All pass on React 19.
  • On React 18, 2939 pass and one is skipped, as on master.
  • On React 16.14 and 17, 2935 pass and five are skipped, as on master.
  • In the browser, the demo's 64 tests pass in Chromium on React 18 and 19, 2 of them new. So do the packed tarball's on React 16.14 to 19.

🤖 Generated with Claude Code

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.
@AlexSamBY
AlexSamBY merged commit 6dd043b into master Oct 14, 2026
8 checks passed
@AlexSamBY
AlexSamBY deleted the lazy-date-picker branch October 14, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant