Skip to content

fix: focus before render - #2569

Merged
rkaraivanov merged 7 commits into
masterfrom
rkaraivanov/focus-before-render
Nov 3, 2026
Merged

rkaraivanov merged 7 commits into
masterfrom
rkaraivanov/focus-before-render

Conversation

@rkaraivanov

Copy link
Copy Markdown
Member

Description

A focus() call on a component right after it connects did nothing, because Lit renders in a microtask and the component has no inner control yet. In the checkbox, radio, select and switch, focus() and blur() threw a TypeError. A native control takes the focus at once.

Now the component stores the call and repeats it after its first render:

  • New internal module src/internals/deferred-focus.ts with deferFocus(host, options) and cancelDeferredFocus(host). The document has one focus, so the module keeps one request: a later request replaces it, and a focusin or a blur() of the host cancels it. It does nothing for a host that is not connected, has already rendered, has disabled set or has the focus.
  • All 16 component classes (22 tags) use one form in focus(): focus the inner target or call super.focus(), then call deferFocus(this, options). blur() calls cancelDeferredFocus(this) first. A rendered component still takes the focus at once.
  • The button, icon button, carousel, chip, color picker, date picker, date range picker and textarea now have their own focus() and blur(), so public-api.json lists them.
  • In the carousel, a deferred focus goes to a navigation button, because the indicators render only after the carousel finds its slides.

Known differences from native elements. All of them can occur only before the first render:

  • A move of the host in the same task keeps the request.
  • A focus() call on the element that already has the focus sends no focusin, so it does not cancel the request.
  • A host that cannot take the focus, for a reason other than its own disabled, replaces an earlier request. Examples: an inert or hidden ancestor, or a toggle button in a disabled group.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Related Issues

Follow-up to #2539, which fixed the same problem only for the chat focusInput().

Testing

  • The new shared suite runFocusBeforeRenderTests() runs for all 22 tags. It checks that:
    • the deferred focus lands on the same element as a focus after the render
    • blur(), a later focus() on another element and a removal cancel the request, and a move keeps it
    • a host with disabled set does not cancel an earlier request
    • after the render, focus() and blur() work at once
  • Unit specs for deferred-focus.ts cover each guard, the interaction with native autofocus, and a host whose shouldUpdate() returns false.
  • The new tests fail when the source changes are reverted.
  • A new select spec covers autofocus set as a property.
  • The QR code spec now uses a string fixture for its invalid version, so lit-analyzer reports no problems.
  • Chromium: the full suite passes (5,813 passed, 1 skipped: the carousel has no disabled). The SSR smoke test renders 90 tags.
  • Firefox 157: no focus test fails in the 22 changed spec files.
  • Consumer bundle size (esbuild, gzip): igc-button +144 B, igc-input +155 B, igc-select +135 B.

Checklist

  • My code follows the project's coding standards
  • I have tested my changes locally
  • I have updated documentation if needed
  • If I changed the public API on purpose, I have run npm run public-api:update and committed public-api.json
  • I have added a CHANGELOG.md entry under [Unreleased]

- Radio, checkbox, switch and select read their inner input with a cached
  query that is null until the first render. A focus() or blur() call
  right after the element connected threw a TypeError. The calls now do
  nothing, as in the other components.
- Add deferFocus() and cancelDeferredFocus() to internals. They store a
  focus() call on a connected component that has not rendered and repeat
  it after the first update. A later request, a focusin or a cancel drops
  the stored call. No component uses them yet.
- Add the runFocusBeforeRenderTests() spec helper. It checks this behavior
  for a component.
- A focus() call on a component right after it connects now moves the
  focus to the component after its first render, as on a native control.
  Before, the call did nothing. A blur() of the component, or a focus
  change before the render, cancels the call.
- This applies to the button, icon button, toggle button, carousel,
  checkbox, switch, radio, chip, color picker, combo, select, date picker,
  date range picker, date time input, file input, input, mask input,
  rating, slider, range slider and textarea. In the carousel, the focus
  can go to a navigation button, because the indicators render after the
  carousel finds its slides.
- The button, icon button, carousel, chip, color picker, date picker,
  date range picker and textarea now list focus() and blur() in their
  API.
- The specs use the shared runFocusBeforeRenderTests() suite. The
  spec.md files and the CHANGELOG describe the change.
- All 16 components use one form: they focus the inner target or call
  super.focus(), then call deferFocus(). The inner target is read once.
- deferFocus() returns nothing and checks hasUpdated first. It skips a
  host with disabled set, so that host does not cancel an earlier
  request.
- When shouldUpdate() returned false, the host skipped its render, and
  the repeated focus() deferred again with no end. The repeat now runs
  only after a render.
- The shared spec suite now also checks the disabled case, a move in the
  same task, and focus() and blur() after the render. It replaces the
  per-component "does not throw", disabled and old focus state tests,
  uses getDeepActiveElement() and waits one frame to settle.
- A select spec covers autofocus set as a property. The QR code spec
  uses a string fixture for an invalid version, so lit-analyzer no
  longer warns.
- The spec.md test items now describe the suite exactly.
Copilot AI balanced review requested due to automatic review settings November 3, 2026 07:15

Copilot AI 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 recommended

Unresolved deferred-focus bugs can steal focus after shadow-internal focus changes and drop pre-render carousel focus requests before indicators are ready.

2 open issues
What changed in this PR

Extends the earlier chat focus fix across the component library so calls made before the first render can take effect once an inner control exists.

Changes:

  • Adds shared deferred-focus handling and cancellation across component focus methods.
  • Adds shared regression tests, helper unit tests, and select autofocus coverage.
  • Updates component specifications, the public API snapshot, and release notes.
File Description
src/​internals/​testing/​focus-before-render.spec.ts Adds the shared focus regression suite.
src/​internals/​deferred-focus.ts Queues and cancels pre-render focus requests.
src/​internals/​deferred-focus.spec.ts Tests request guards, replacement, and autofocus.
src/​components/​textarea/​textarea.ts Adds deferred focus and blur cancellation.
src/​components/​textarea/​textarea.spec.ts Runs the shared focus suite.
src/​components/​textarea/​spec.md Documents focus methods and behavior.
src/​components/​slider/​spec.md Documents deferred focus for both sliders.
src/​components/​slider/​slider.spec.ts Tests both slider variants.
src/​components/​slider/​slider-base.ts Adds deferred thumb focus and cancellation.
src/​components/​select/​spec.md Documents safe pre-render focus calls.
src/​components/​select/​select.ts Guards the inner target and defers focus.
src/​components/​select/​select.spec.ts Adds focus and property-based autofocus cases.
src/​components/​rating/​spec.md Documents deferred focus behavior.
src/​components/​rating/​rating.ts Adds deferred focus and cancellation.
src/​components/​rating/​rating.spec.ts Runs the shared focus suite.
src/​components/​radio/​spec.md Documents safe pre-render focus calls.
src/​components/​radio/​radio.ts Guards the inner target and defers focus.
src/​components/​radio/​radio.spec.ts Replaces basic focus tests with shared coverage.
src/​components/​qr-code/​qr-code.spec.ts Uses a string fixture for invalid input.
src/​components/​mask-input/​spec.md Documents deferred focus behavior.
src/​components/​mask-input/​mask-input.spec.ts Runs the shared focus suite.
src/​components/​input/​spec.md Documents deferred focus behavior.
src/​components/​input/​input.spec.ts Runs the shared focus suite.
src/​components/​input/​input-base.ts Adds deferred focus for input subclasses.
src/​components/​file-input/​spec.md Documents deferred focus behavior.
src/​components/​file-input/​file-input.spec.ts Runs the shared focus suite.
src/​components/​date-time-input/​spec.md Documents deferred focus behavior.
src/​components/​date-time-input/​date-time-input.spec.ts Runs the shared focus suite.
src/​components/​date-time-input/​date-time-input.base.ts Adds deferred editor focus and cancellation.
src/​components/​date-range-picker/​spec.md Documents focus methods and both input modes.
src/​components/​date-range-picker/​date-range-picker-two-inputs.spec.ts Tests focus in two-input mode.
src/​components/​date-range-picker/​date-range-picker-single.spec.ts Tests focus in single-input mode.
src/​components/​date-picker/​spec.md Documents focus methods and behavior.
src/​components/​date-picker/​date-picker.spec.ts Runs the shared focus suite.
src/​components/​date-picker/​date-picker.base.ts Adds focus overrides shared by date pickers.
src/​components/​combo/​spec.md Documents deferred focus behavior.
src/​components/​combo/​combo.ts Adds deferred input focus and cancellation.
src/​components/​combo/​combo.spec.ts Runs the shared focus suite.
src/​components/​color-picker/​spec.md Documents focus methods and behavior.
src/​components/​color-picker/​color-picker.ts Adds deferred focus and blur cancellation.
src/​components/​color-picker/​color-picker.spec.ts Runs the shared focus suite.
src/​components/​chip/​spec.md Documents focus methods and behavior.
src/​components/​chip/​chip.ts Adds deferred focus and blur cancellation.
src/​components/​chip/​chip.spec.ts Runs the shared focus suite.
src/​components/​checkbox/​switch.spec.ts Replaces basic focus tests with shared coverage.
src/​components/​checkbox/​spec.md Documents checkbox and switch focus behavior.
src/​components/​checkbox/​checkbox.spec.ts Replaces basic focus tests with shared coverage.
src/​components/​checkbox/​checkbox-base.ts Guards inner targets and adds deferred focus.
src/​components/​carousel/​spec.md Documents focus methods and navigation fallback.
src/​components/​carousel/​carousel.ts Adds deferred focus and blur cancellation.
src/​components/​carousel/​carousel.spec.ts Tests focus with a changing initial target.
src/​components/​button/​spec.md Documents button focus methods and behavior.
src/​components/​button/​icon-button.spec.ts Runs the shared focus suite.
src/​components/​button/​button.spec.ts Runs the shared focus suite.
src/​components/​button/​button-base.ts Adds focus overrides for both button types.
src/​components/​button-group/​toggle-button.ts Adds deferred focus and cancellation.
src/​components/​button-group/​toggle-button.spec.ts Runs the shared focus suite.
src/​components/​button-group/​spec.md Documents toggle-button deferred focus.
public-api.json Records newly declared focus and blur methods.
CHANGELOG.md Describes focus fixes and API additions.

Review effort: Balanced

Comment thread src/components/carousel/carousel.ts Resolved
Comment thread src/internals/deferred-focus.ts Resolved
…roots

- The deferred focus() now waits while the host has a pending update.
  The carousel indicators come with the render after a slotchange, so a
  carousel with hide-navigation now takes the focus, and every carousel
  focuses the same element as after the render.
- A focus change inside one shadow root sends no focusin to the
  document. The module now keeps the focused element of the request and
  repeats the call only when that element, or the body, still has the
  focus.
- The spec helper no longer has the sameElement option. The carousel
  suite runs with and without hide-navigation. New unit specs cover the
  shadow root change and a blur() of the element that had the focus.

Copilot AI 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 recommended

Deferred focus can steal focus after subsequent focus changes inside shadow roots, and incomplete cleanup can retain detached DOM elements.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Cancel pending focus after shadow-root focus round trips

src/​internals/​deferred-focus.ts:63

If focus moves from one input to another and back within the same shadow root while a request is pending, neither transition reaches the document's focusin listener. The final active element still equals pendingActive, so the deferred call steals focus despite the later explicit focus calls. Observe focus changes in the previously focused control's shadow root, clean up that listener with the request, and add a regression test for this round trip.

2 resolved since last review

Review effort: Balanced

Comment thread src/internals/deferred-focus.ts Resolved
…focus

- A focusin does not leave the shadow root that holds both the old and
  the new focus. The module now listens on the document of the host and
  on each shadow root around the focused element, so any element that
  takes the focus cancels the request, also when the focus comes back.
  This replaces the check of the focused element and its body exception.
- The request is one object that holds only the host and its roots, and
  the module drops it on every cleanup path.
- The module uses the document of the host again, so a host in an
  iframe or a Picture-in-Picture window is tracked. The first wait goes
  through updateComplete again, so a request made inside the first
  update is kept.
- New unit specs cover a focus that leaves and comes back inside one
  shadow root, an element that takes the focus and loses it, a follow-up
  update that adds the element to focus, and a host in another document.
  The shared suite checks that the host matches :focus and uses
  fixtureSync for its containers.

Copilot AI 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 recommended

Deferred focus handling cancels valid focus redirects and misses focus changes in parent documents and iframe shadow roots, allowing components to steal focus.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Observe capture-phase focus to preserve focus redirects

src/​internals/​deferred-focus.ts:66

When a native control's focus handler appends a component and calls its focus(), the native control's trailing focusin immediately cancels the new request, leaving focus on the native control. Observe capture-phase focus events instead, with matching listener cleanup, so the earlier transition is not treated as a later focus change. Add a regression test for redirecting focus from a focus handler.

1 resolved since last review

Review effort: Balanced

Comment thread src/internals/deferred-focus.ts Resolved
Comment thread src/internals/deferred-focus.ts Resolved
- The listeners now also cover each same-origin document that holds the
  host in a frame, so a focus change in the parent page cancels the
  request of a host in a frame.
- Shadow roots are detected by nodeType. A shadow root that a frame
  creates belongs to the window of the frame and fails an instanceof
  check in the window of the module.
- When the call repeats, the module compares the focused element, from
  the top same-origin document down through open shadow roots and frames,
  with the one at request time. A move into a frame or into the shadow
  root of the focused host sends no focusin to the observed roots, and
  this check catches it.
- New unit specs cover these moves, a shadow root in a frame, a focus
  that leaves and comes back in a shadow root of the page, and an element
  of an outer shadow root that takes the focus and loses it.

Copilot AI 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 recommended

Focus changes within a same-origin iframe can go undetected when focus returns to its starting element before rendering, causing the pending parent component to steal focus incorrectly.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread src/internals/deferred-focus.ts Resolved
@rkaraivanov
rkaraivanov merged commit 93f00ba into master Nov 3, 2026
8 checks passed
@rkaraivanov
rkaraivanov deleted the rkaraivanov/focus-before-render branch November 3, 2026 09:36
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.

2 participants