Skip to content

fix(F0SearchInput): focus declaratively instead of on a timer - #5194

Open
albertcalasanzs wants to merge 10 commits into
mainfrom
followup/searchinput-declarative-focus
Open

fix(F0SearchInput): focus declaratively instead of on a timer#5194
albertcalasanzs wants to merge 10 commits into
mainfrom
followup/searchinput-declarative-focus

Conversation

@albertcalasanzs

@albertcalasanzs albertcalasanzs commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

F0SearchInput's autoFocus focused on a timer instead of taking focus once. #5095 already replaced the original 50 ms setInterval with a one-shot setTimeout(50), which stops the runaway loop — this removes the remaining timer entirely.

It is a simplification of #5095, not a correction of it: #5095 added the very guard that makes the delay unnecessary.

Type of change

  • Refactor / internal change (no API or behavior change)
  • Bug fix

Implementation details

What changes relative to #5095

#5095 taught focusSelectedItem to return early when focus is already on a descendant of the content. Given that guard, the input no longer has to win a race, so it no longer needs a delay:

useEffect(() => {
  if (!props.autoFocus || props.disabled) return
  input.current?.focus()
}, [props.autoFocus, props.disabled])

Effects run child-before-parent, so the input owns focus before any ancestor's open-focus effect runs; the guard then backs off. Three things follow:

  1. The 0–50 ms window disappears. Before, autoFocus had provably not happened yet at mount — the new test focuses on mount without waiting for any timer fails on this branch's base and passes with the change. In a Select that window is when the selected option holds focus, so a fast typist's first keystrokes did not land in the search box.
  2. Two delays stop being coupled. F0SearchInput's setTimeout(50) and SelectContentImpl's setTimeout(0) were tuned against each other. Both orderings happen to converge today thanks to the guard, but nothing in the code says so, and the 50 ms is unexplained.
  3. 20 lines go away — the timeout, the focus listener that cancelled it, and the bookkeeping keeping those in sync.

The autoFocus false → true transition

F0Select drives autoFocus={!asList && !isFiltersOpenLocal}, so autoFocus turns back on for an input that never unmounted — when the filters panel closes. React's native autoFocus only fires when the DOM node is created and cannot cover that, which is why an effect has to exist here at all rather than just forwarding the attribute. That behaviour was untested; it now has a test.

How this code came to be

Context for reviewers, because the original intent is not recoverable from the code:

date PR what happened
2025-10-10 #2775 feat(select): primitive multiselect Vendors the Radix Select fork, including focusSelectedItem() on isPositioned and "we prevent open autofocus because we manually focus the selected item".
2025-10-31 #2884 feat: add support for source prop for "in" filter Puts a search box inside that popover. In one squashed commit (16 fix: bullets, incl. "search working again"), four artifacts appear together: the 50 ms setInterval, a focus() inside the debounced onChange, tabIndex={-1}, and key="search-input".
2025-11-06 #2914 fix: improve select glitchy behavior Hoists the interval into a useRef — functionally a no-op, but it made the code read as deliberate.
2026-06-03 #4199 F1SearchBoxF0SearchInput, experimental/components/. The interval rides along untouched: a private workaround becomes public API.
2026-06-12 #4216 Promoted to stable.
2026-06-30 #4576 feat(ai): move One chat navigation into the sidebar ChatHeaderSearch passes autoFocus — the obvious prop name for "focus the search box". Chat renders inline in a header: no portal, no focus scope, nothing to race. It inherited a workaround for a problem it does not have.

Nobody made a bad call; the defect lives in the seams. The tell that this was accretion rather than design is tabIndex={-1} sitting beside the interval — the component simultaneously declared "you cannot reach me with Tab" and "I will seize focus twenty times a second". Those cannot both be intentional.

The user-visible consequence in chat: while the header search was open, focus returned to the search input within 50 ms of any click, so the message composer could not be focused and Radix popovers closed on focus-out almost immediately.

Test plan

Red/green verified locally. With the source change reverted and only the new tests applied, both new assertions fail; with the change applied, both pass.

New tests:

  • focuses on mount without waiting for any timer — no advanceTimersByTime; pins the absence of a timing dependency
  • focuses again when autoFocus flips back on while mounted — the F0Select toggle, previously uncovered

Existing coverage unchanged — 85 passed, 1 pre-existing skip across F0SearchInput, F0Select, ui/Select, including the guarantees added by #5095:

  • should not lose the focus when the search input is focused and the list changes
  • keeps search focus when async options load / keeps footer focus when async options load
  • focuses once without reclaiming focus after navigation
  • cancels a pending retry after the input receives focus

pnpm tsc clean, oxlint 0 warnings, oxfmt applied.

Manual verification in a standalone harness (200-option Select with showSearchBox, every 5th option disabled, plus an 8,000-message F0Chat), with a probe reading document.activeElement and counting focusout on role="searchbox":

  • opening the Select focuses the search box immediately, with no 50 ms hesitation
  • typing, then sweeping the pointer over options, disabled rows and the list edges: the search box keeps focus
  • ArrowDown still moves into the options (the keyboard carve-out from feat(F0Select): add inline variant #5095)
  • clicking an unrelated field moves focus there and it stays there
  • in chat, with header search open: focusin/sec is 0 (was ~20) and the composer can be focused and typed into

Deliberately not included

  • onChangeLocal's focus restore (the remaining setTimeout). feat(F0Select): add inline variant #5095's shouldRestoreFocus guard already stops it stealing focus. It may now be redundant, but it compensates for a theft during the onChange re-render and proving that needs its own evidence.
  • tabIndex={-1} and key="search-input" — the other two artifacts from feat: add support for source prop for "in" filter #2884. Both look vestigial and tabIndex={-1} is arguably an a11y bug, but neither is load-bearing here.
  • A guard on handleItemLeave. It is called unconditionally by a pointer move over a disabled option and by a scroll indicator's pointer move, so in principle it can pull focus out of the search box. In practice neither path is reachable in F0Select today: disabled options get data-disabled and therefore pointer-events-none, and F0's SelectContent never renders the Radix scroll buttons (it uses ScrollArea + useVirtualizer). A guard there would be speculative hardening, and a unit test for it only fails because jsdom ignores pointer-events. Worth knowing if scroll buttons are ever rendered.

pedroruizpareja and others added 10 commits August 12, 2026 09:55
Implemented-with: factorial-dev-workflow/frontend
#5095 replaced the original 50ms `setInterval` with a one-shot
`setTimeout(50)`, which stops the runaway loop. It also taught
`focusSelectedItem` to leave an already-focused descendant alone — and that
guard makes the remaining delay unnecessary.

Effects run child-before-parent, so the input can just take focus on mount:
it owns focus before any ancestor open-focus effect runs, and the guard then
backs off. This removes the 0-50ms window in which `autoFocus` had not
happened yet, and drops the timeout, the `focus` listener and the cancel
bookkeeping that kept them in sync.

It also covers the `autoFocus` false -> true transition on an input that never
unmounted, which is how F0Select drives it
(`autoFocus={!asList && !isFiltersOpenLocal}`). React's native `autoFocus`
only fires when the DOM node is created, so an effect has to exist for that
case at all; it was previously untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xd5igNn7FDNAGFdtjHFLH
@github-actions github-actions Bot added fix react Changes affect packages/react labels Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Review policy: Code change

Default rule: any other change needs one approval from f0-devs (rule 4).

Required approvals

Team Why Status
@factorialco/f0-devs Every code change needs a dev approval ⏳ pending
How this was decided
  • PRs touching only sds/ modules require their owners and nothing else.
  • Otherwise, docs-only changes (*.md, *.mdx, *.stories.tsx, anything in __stories__/) → one f0-general approval.
  • Otherwise, feat: titles → one f0-devs and one f0-designers approval. Not a feature? Fix the title prefix.
  • Anything else → one f0-devs approval.
  • Add the needs-design-review label to also request a design approval on any PR.
  • Creating a new sds/ module (new package.yml) additionally requires an f0-general approval.

Policy source: ownership/review-policy.ts · Team members: ownership/teams.yml

@albertcalasanzs
albertcalasanzs marked this pull request as ready for review August 20, 2026 17:56
@albertcalasanzs
albertcalasanzs requested a review from a team as a code owner August 20, 2026 17:56
@github-actions

Copy link
Copy Markdown
Contributor

📦 Alpha Package Version Published

Use pnpm i github:factorialco/f0#npm/alpha-pr-5194 to install the package

Use pnpm i github:factorialco/f0#969e3b35a8bf97b3bb4ff7d7941a83030ffd3d0e to install this specific commit

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Visual review for your branch is published 🔍

Here are the links to:

@github-actions

Copy link
Copy Markdown
Contributor

♿ Accessibility (axe) — components changed in this PR

2 issues across 2 stories — all non-blocking (todo).

Story Rule WCAG Impact Nodes Mode
Components/Select/Inline / Open aria-hidden-focus WCAG 4.1.2 A (2.0) serious 1 🟡 todo
Components/Select/Inline / Snapshot aria-hidden-focus WCAG 4.1.2 A (2.0) serious 22 🟡 todo

Scope: only stories in the files/component folders this PR changed. It can't yet flag downstream ripple from shared-code/token changes, or diff against main (planned: base-vs-head delta).

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for packages/react

Status Category Percentage Covered / Total
🔵 Lines 67.2% 27622 / 41103
🔵 Statements 66.2% 29204 / 44112
🔵 Functions 59.89% 6548 / 10932
🔵 Branches 60.08% 20599 / 34283
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/react/src/components/F0SearchInput/F0SearchInput.tsx 95.45% 95.23% 80% 100% 48
Generated in workflow #17320 for commit a77885d by the Vitest Coverage Report Action

Base automatically changed from feat/f0select-inline-variant to main August 28, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix react Changes affect packages/react

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants