Skip to content

fix(DateTimeInput): a press's deferred-click watchers leave with the field - #10155

Merged
cixzhang merged 2 commits into
meta:mainfrom
aashish254:fix/deferred-click-watchers-leave-with-the-field
Nov 5, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
aashish254:fix/deferred-click-watchers-leave-with-the-field

Conversation

@aashish254

@aashish254 aashish254 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A DateTimeInput with timeOptionInterval set ends a pointer press's deferral with one-shot document click listeners, installed once each by the time input and by its wrapper. When the field unmounts before the next click arrives — the dialog holding it closes on the same gesture, a repeatable form row disappears, a wizard step changes — both listeners stay installed: measured, a press installs 2 and delivers 0, and on main unmounting releases neither. The next click anywhere in the document then runs this field's work for a field that is gone, and if no further click reaches document the field's closure stays reachable for the rest of the page.

The restoration gives the field ownership of its listeners, so no later click answers for a component that no longer exists. Its scope is measured rather than assumed: only the press's two reset watchers can be left standing (timePointerActiveRef.current = false on a detached ref) — the deferred timePopover.show() is installed and delivered by the input's own click inside one dispatch, measured at 3 installed, 3 delivered, 0 standing — and nothing accumulates past a click, because once delivers every pending watcher on the next click that reaches document (two presses install 4; one document click delivers all 4).

Problem and solution fit

Why does the defect occur? Light dismiss counts a press in a text input as a press outside the list (a text input cannot be an invoker), so the field defers the show, and its own "the press is over" reset, to {once: true} listeners on document. The once option means only a delivered click removes them, and nothing tracked them, so unmount had no way to release them.

Why does that harm the task? The gesture the deferral exists to serve ends when the field goes away, but its two watchers outlive it. A click the user aimed at some other part of the app runs this field's handler instead, and until such a click arrives the page keeps the closure of a component nothing renders. Both bounds are measured above: the work an orphaned watcher does is a ref write, and the count a screen can hold is one pair per press-until-the-next-click.

Why does that harm matter here? The repo already states the rule and already tests it for the sibling field. architecture:react-component-runtime INV5 requires component-owned resources to be released at unmount, and Typeahead.test.tsx:561 — 'removes its document listeners when the field unmounts with the list open' — is that promise enforced for BaseTypeahead. This is the same deferral pattern copied without the teardown, so the defect is an exception to the project's own documented convention rather than a design choice.

Why does the narrow fix close the chain? Both watchers are now created in one place (addTimeClickWatcher) and dropped in one place (clearTimeClickWatchers, run by the field's unmount cleanup). A press that never becomes a click can no longer leave anything behind, and a press that does become a click still behaves exactly as before because the browser's own once delivery removes it first.

Expected behavior and authority

  • architecture:react-component-runtime INV5 — Resource ownership is complete (docs/architecture/react-component-runtime.md:137): "Every listener, timer, animation frame, observer, request, subscription, and imperative browser resource has one declared lifecycle owner. Component- or node-owned resources are removed, aborted, or released when disabled, replaced, or unmounted."
  • Same document, INV3 (docs/architecture/react-component-runtime.md:123): "When setup acquires, installs, or starts a resource, cleanup MUST release that resource."
  • INV5's escape clause does not cover these listeners: "A document-owned singleton MAY persist across consumer gaps when current authority names that lifetime and tests prove one shared installation." The one shared installation here is the Layer gesture counter's page-lifetime, capture-phase document click listener (packages/core/src/Layer/gestureCounter.ts:41), which this change deliberately leaves in place. The field's watchers are one per press, so they are component-owned.
  • docs/specs/components/DateTimeInput/DateTimeInput.spec.md:138 FR7 (authority: current) is the contract the deferral implements: "While open, the listbox stays open for a press on the time field or in the listbox and closes for a press elsewhere". This change does not alter that behavior; it only ends the deferral with the field.
  • Repo precedent for the teardown itself: packages/core/src/Typeahead/Typeahead.test.tsx:561.

Smallest restoration

PointerDateTimeField gains a ref-held set of its pending document click watchers. The two sites that installed a raw {once: true} listener — markTimePointerActive and the deferred branch of showTimeOptions — install through addTimeClickWatcher instead, and one unmount cleanup (clearTimeClickWatchers) removes every watcher still pending. No prop, type, visual affordance, default or interaction model changes; the popover, list-open and close-on-press-elsewhere behavior is byte-for-byte the same while the field is mounted.

Why not subscribeInput? pnpm review:pre reports, informationally, that Core owns document listeners and points at subscribeInput (packages/core/src/utils/inputStream.ts). That owner cannot carry this deferral: its native listener sits on the window's capture phase, which its own header documents as running before every document listener (inputStream.ts:16), and the whole purpose of the watcher is to act once the press's click has already reached the list's outside-press watch. Routing the reset through the capture phase would run it before that watch judges the click — a behavior change this bug fix must not make. The other click-shaped owner, awaitBrowserClick (packages/core/src/utils/browserClickWait.ts:41), fires only for clicks inside one element and gives up after BROWSER_CLICK_WAIT_MS (300 ms, browserClickWait.ts:25), which is again a different contract. Core already keeps a raw window click listener where the phase is load-bearing and releases it explicitly rather than through the stream — clickAndReport adds it in the capture phase and removes it in a finally at packages/core/src/utils/browserClickWait.ts:115-119, inside the module the finding names. What INV5 asks for is a declared lifecycle owner, and that is what this change adds.

Deliberately split out: packages/core/src/Typeahead/BaseTypeahead.tsx showLayer() has the identical shape and the same leak, and I have the same fix for it. Five open PRs already edit that file (#8778, #6360, #4329, #5999, #7910), and git merge-tree --write-tree against a Typeahead-only arm of this change reports a conflict in packages/core/src/Typeahead/BaseTypeahead.tsx for four of them (rc=1 for #8778, #6360, #4329, #5999; rc=0 for #7910). None of the five adds a listener removal, so the defect is not being fixed by any of them. Bundling that file here would make this PR depend on their ordering, so it is a follow-up — as soon as the file is not contested, or a maintainer can apply the same three-part change directly.

For this PR's file, the only open PRs touching packages/core/src/DateTimeInput/DateTimeInput.tsx are #10129 (merges cleanly with this change, rc=0) and the draft #6133 (rc=1, conflicting in an unrelated region); neither removes a document listener, so this restoration is not already in flight here.

Evidence

  • Reproduction before the change: the new test with only the fix removed (git show origin/main:packages/core/src/DateTimeInput/DateTimeInput.tsx, then NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/DateTimeInput.test.tsx -t 'drops the watchers') fails at DateTimeInput.test.tsx:2322 with expected 2 to be +0: after one pointerDown on the time field and unmount(), a click on document.body delivers 2 of the field's bubble-phase document click listeners. With the fix the same click delivers 0.
  • Result after the change: the same command passes; the watcher count drops to zero at unmount. NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/ → 300 passed (300) over 6 files.
  • Listener lifetime, measured on this branch with the same spy the test uses (document.addEventListener/removeEventListener wrapped for bubble-phase click, counting real deliveries). A press: installed 2, delivered 0. Press then unmount: with the fix, 2 removed and a later document.body click delivers 0; with only the useEffect removed, 2 stand and the later click delivers 2. Press then a click on the field: installed 3, delivered 3, 0 standing — the deferred show lives and dies inside one dispatch. Two presses with no click: installed 4; one document.body click delivers all 4, and a second delivers nothing more.
  • Representative unchanged path: the press contract itself is unchanged and was run by name on the fixed tree — DateTimeInput.test.tsx:2081 'keeps the time list open on a press in the time input, closes it on a press elsewhere' ✓ and DateTimeInput.test.tsx:2245 'opens from the keyboard after a pointer press that never became a click' ✓.
  • Mutation-sensitive proof: three arms against the fix, one at a time, each restored afterwards —
    • delete the useEffect that returns clearTimeClickWatchers: test fails (the teardown is what is being asserted);
    • delete the document.removeEventListener loop inside clearTimeClickWatchers: test fails;
    • delete the watchers.delete(watcher) bookkeeping inside the fired handler: test passes. Stated plainly, that line is not behavior-coverable: once already removes the listener from the document, so the delete only stops the set from holding every past press. It is memory bounding, and the test does not pretend to guard it.
    • The test counts real deliveries rather than spy calls: it wraps each bubble-phase click listener the document is given and increments on the call, so it fails when a watcher survives, not when a mock misfires. Asserting teardown this way follows the repo's own isolation check, packages/core/src/utils/clickGuardTeardown.test.ts, which asserts that an armed click guard does not outlive the run that armed it.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split. — the BaseTypeahead half is split out, with the conflict measurements that motivated the split above.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

From the repo root (Node 24, pnpm 11):

  • NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/ → 300 passed (6 files)
  • NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/DateTimeInput.test.tsx -t 'drops the watchers' → fails on base with expected 2 to be +0, passes with the fix
  • cd packages/core && npx tsc --noEmit -p tsconfig.json → exit 0
  • npx eslint packages/core/src/DateTimeInput/DateTimeInput.tsx packages/core/src/DateTimeInput/DateTimeInput.test.tsx → exit 0
  • pnpm specs:for --diff origin/main → the diff is governed by architecture:react-component-runtime INV2, INV3, INV5, INV6 via the effects-listeners-observers concern; the two claims that decide it (INV5, INV3) are quoted above, and the fix satisfies the other two by leaving the deferral itself untouched (INV2: the deferred work still runs from the handler that caused it, never from an Effect)
  • pnpm check:repo → green (runs as the pre-commit hook for this branch's commit)

@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 5, 2026 8:50pm UTC

Request Review

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Nov 5, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 5, 2026
…field

A press defers the time-option show past the click that ends it with a one-shot
`document` click listener. Nothing removed that listener when the field
unmounted first, so a press that never became a click left a watcher standing
that answered the next click anywhere in the document with work for a field that
was gone.

The watchers now register through one helper, and unmounting drops every watcher
still pending.
@aashish254
aashish254 force-pushed the fix/deferred-click-watchers-leave-with-the-field branch from eef23e7 to abab484 Compare November 5, 2026 19:53
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 5de3ece

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
DateTimeInput · Storybook 188.4 kB (+108 B) · 17.3 kB CSS (0) 3,567 (+12) ✓ · ✓ · ✓

Package size: @astryxdesign/core 6.61 MB packed (+738 B); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 23 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot

astracat-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 8:57 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested. The fix itself holds up: pending document click listeners are now removed when the field unmounts, the new test fails without the fix and passes with it, and behavior while the field is mounted is unchanged. Only the pull request description needs a correction:

  • The User impact section says a leftover listener calls timePopover.show() on the unmounted field, keeps its closure "for the life of the page" and accumulates "indefinitely", but in the case the new test reproduces a press leaves only the two timePointerActiveRef reset listeners (the deferred-show listener is added and run by the same click) and the next click that reaches document runs and removes all of them, so please reword it to that scope or add a reproduction for the broader claims.

CI has not run on this head yet; it waits for a maintainer's ci:run label. Two small comment-wording suggestions are inline and non-blocking.

[Automated review]

Comment on lines +1145 to +1148
// The watchers carrying that deferral are one-shot listeners on `document`,
// and they belong to this field. Unmounting takes them with it: a watcher
// left standing answers the next click anywhere in the document with work for
// a field that is gone.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (comment wording): the first sentence carries two topics, and "left standing" and "answers the next click ... with work" are idioms. A shorter version that keeps the reason:

// These one-shot `document` listeners belong to this field. Unmount removes
// them. Otherwise the next click runs work for a field that is gone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 5de3ece — the comment now reads exactly as suggested:

// These one-shot \`document\` listeners belong to this field. Unmount removes
// them. Otherwise the next click runs work for a field that is gone.

Comment on lines +1155 to +1156
// The `once` option removes this listener as the browser delivers it;
// deleting here is what keeps the set from holding every past press.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (comment wording): two topics joined by a semicolon. Suggested:

// The `once` option removes the listener from the document. Delete it from
// the set too, or the set keeps every past press.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 5de3ece:

// The \`once\` option removes the listener from the document. Delete it from
// the set too, or the set keeps every past press.

Comment on lines +2265 to +2266
// The press defers its show to a one-shot `document` click watcher, and a
// press that ends without a click leaves that watcher standing. It belongs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The press itself does not defer the show: it installs the two listeners that end its deferral (markTimePointerActive, from the input and from the wrapper), and those are what this test counts. The deferred-show listener is added only by the input's click, and that same click runs it. Suggested opening: "A press installs one-shot document click listeners that end its deferral, and a press that ends without a click leaves them in place."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the test now says so. Adopted in 5de3ece, with the title widened to match what a press actually installs:

'drops the watchers of a press that never became a click when the field unmounts'

The opening follows your sentence, and the rest records the measurement behind it. I re-ran the interaction with the same spy the test uses (bubble-phase click on document, counting real deliveries):

  • press only: installed 2, delivered 0 — the pair from markTimePointerActive on the input and on the wrapper.
  • press, then a click on the field: installed 3, delivered 3, 0 standing. The third is showTimeOptions' deferred show, and the click that installs it reaches document in the same dispatch, so it is consumed at once and cannot outlive the field. That is the case I wrongly folded into the description's timePopover.show().
  • two presses with no click: installed 4; one document.body click delivers all 4, and a second delivers nothing more. So the count is bounded by the next click, not unbounded — for the life of the page and accumulates indefinitely were both wrong, and the User impact and harm paragraphs now carry the measured scope instead.

User impact now claims only what reproduces: the two reset watchers outlive the field, the work each does is timePointerActiveRef.current = false on a detached ref, and they are released by the next click that reaches document — or stay reachable for the rest of the page if none arrives.

The base witness changed shape with the corrected assertion, so it is re-quoted: git show origin/main:packages/core/src/DateTimeInput/DateTimeInput.tsx, then NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/DateTimeInput.test.tsx -t 'drops the watchers' fails at DateTimeInput.test.tsx:2322 with expected 2 to be +0 — the later click delivered 2 listeners of an unmounted field. With the fix it delivers 0. The three mutation arms were re-run on this head too: deleting the useEffect fails, deleting the removeEventListener loop fails, deleting watchers.delete(watcher) passes, exactly as stated.

…eaves pending

Review asks for the narrower wording. The comments now say that a press installs
two one-shot `document` listeners that end its deferral, and that the deferred
show is not one of them: the input's own click installs it and delivers it in the
same dispatch.
@aashish254

Copy link
Copy Markdown
Contributor Author

Short answer: the description overstated the defect, and 5de3ece corrects it. The fix itself is unchanged — no line of the source behavior moved, only comments and the test title.

What I got wrong, and what measures now:

  • A press does not leave a pending timePopover.show() behind. The input's own click installs that watcher and delivers it in the same dispatch (installed 3, delivered 3, 0 standing). Only the two reset watchers from markTimePointerActive — one from the input, one from the wrapper — can outlive the field.
  • They do not accumulate indefinitely. once delivers every pending watcher on the next click that reaches document: two presses install 4, one document click delivers all 4. If no further click arrives, the field's closure stays reachable for the rest of the page; that is the retention, and it is now stated that way rather than as a growing leak.
  • The work an orphaned watcher runs is timePointerActiveRef.current = false on a detached ref, which is what the User impact section now says.

So the defect is the lifecycle rule, not a visible malfunction: architecture:react-component-runtime INV3 and INV5 make the component the owner of what it installs, and this field's sibling already enforces that for its own document listeners (packages/core/src/Typeahead/Typeahead.test.tsx:561). The restoration is unchanged in substance.

Both non-blocking comment suggestions are adopted verbatim, and the test's opening follows your sentence. Renamed: drops the watcher of a press that never became a click when the field unmounts is now drops the watchers of a press that never became a click when the field unmounts, since a press installs two.

Re-run on this head: NO_COLOR=1 npx vitest run --project ui packages/core/src/DateTimeInput/ → 300 passed (300) over 6 files; base witness expected 2 to be +0 at packages/core/src/DateTimeInput/DateTimeInput.test.tsx:2322; the three mutation arms reproduce (two fail, the watchers.delete arm passes and is reported as memory bounding only); cd packages/core && npx tsc --project tsconfig.json --noEmit → exit 0; npx eslint on both files → exit 0; pnpm review:pre → 120 authored lines, merge into origin/main clean, one informational finding left, the subscribeInput one answered in the description.

Ready for a re-review.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved pending CI: CI has not run on this head yet, and a maintainer adds the ci:run label so it runs.

The description now matches what the new test reproduces, and the fix holds up: the press's pending document click listeners are removed when the field unmounts, the test fails without the fix and passes with it, and behavior while the field is mounted is unchanged. Nothing else is needed from you.

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 5, 2026
@cixzhang cixzhang closed this Nov 5, 2026
@cixzhang cixzhang reopened this Nov 5, 2026
@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Nov 5, 2026
@cixzhang
cixzhang merged commit 17e36e7 into meta:main Nov 5, 2026
124 of 128 checks passed

This branch was successfully deployed

1 active deployment
Preview — 5de3ece5 Deployed Nov 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants