Skip to content

script: Prevent invisible elements from being focusable#43431

Merged
simonwuelker merged 2 commits into
servo:mainfrom
jakubadamw:issue-41312
Mar 18, 2026
Merged

script: Prevent invisible elements from being focusable#43431
simonwuelker merged 2 commits into
servo:mainfrom
jakubadamw:issue-41312

Conversation

@jakubadamw

Copy link
Copy Markdown
Contributor

Elements with visibility: hidden or visibility: collapse could receive focus via both sequential navigation and the JS focus() API, and already-focused elements did not lose focus when becoming invisible. Per the CSS Display spec, invisible elements ”are removed from navigation”, which we can interpret to mean sequential focus navigation. In practical terms, all popular user agents also prevent elements with visibility: hidden from being focusable via click/the JS API, so this PR makes servo match that behaviour as well.

Specifically, this adds a visibility check to Element::focusable_area_kind() so that invisible elements are excluded from all focusable area kinds.

Fixes #41312.

@jakubadamw jakubadamw requested a review from gterzian as a code owner March 18, 2026 15:17
@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Mar 18, 2026
@mrobinson mrobinson added the T-linux-wpt Do a try run of the WPT label Mar 18, 2026
@github-actions github-actions Bot removed the T-linux-wpt Do a try run of the WPT label Mar 18, 2026
@github-actions

Copy link
Copy Markdown

🔨 Triggering try run (#23253066704) for Linux (WPT)

@jakubadamw

Copy link
Copy Markdown
Contributor Author

I forgot to mention that these are the results for the Linux WPT try run in my fork: https://github.com/jakubadamw/servo/actions/runs/23249269690.

Fixing the commit sign-off issue now.

Elements with `visibility: hidden` or `visibility: collapse` could
receive focus via the JS `focus()` API, and already-focused elements
did not lose focus when becoming invisible. Per the CSS Display spec,
invisible elements "are removed from navigation."

This adds a visibility check to `Element::focusable_area_kind()` so
that invisible elements are excluded from all focusable area kinds.

Signed-off-by: Jacob Adam <software@jacobadam.net>
Signed-off-by: Jacob Adam <software@jacobadam.net>
@github-actions

Copy link
Copy Markdown

Test results for linux-wpt from try job (#23253066704):

Flaky unexpected result (21)
  • OK /FileAPI/url/url-with-fetch.any.worker.html (#21517)
    • FAIL [expected PASS] subtest: Revoke blob URL after calling fetch, fetch should succeed

      promise_test: Unhandled rejection with value: object "TypeError: Network error: Blob URL store error: InvalidFileID"
      

  • OK /_mozilla/css/offset_properties_inline.html (#40543)
    • FAIL [expected PASS] subtest: offsetTop

      assert_equals: offsetTop of #inline-1 should be 0. expected 0 but got -1
      

    • FAIL [expected PASS] subtest: offsetLeft

      assert_equals: offsetLeft of #inline-2 should be 40. expected 40 but got 25
      

  • TIMEOUT [expected OK] /_mozilla/mozilla/img_find_non_sibling_map.html
  • CRASH [expected PASS] /_mozilla/shadow-dom/move-element-with-ua-shadow-tree-crash.html (#39473)
  • CRASH [expected OK] /_webgl/conformance/rendering/draw-arrays-out-of-bounds.html
  • CRASH [expected ERROR] /_webgl/conformance2/misc/uninitialized-test-2.html (#41656)
  • OK /content-security-policy/frame-ancestors/frame-ancestors-path-ignored.window.html (#36468)
    • PASS [expected FAIL] subtest: A 'frame-ancestors' CSP directive with a URL that includes a path should be ignored.
  • OK /css/css-cascade/layer-cssom-order-reverse.html (#36094)
    • PASS [expected FAIL] subtest: Delete layer invalidates @font-face
  • ERROR [expected OK] /fetch/fetch-later/quota/same-origin-iframe/accumulated-oversized-payload.https.window.html (#41705)
  • ERROR /fetch/metadata/generated/serviceworker.https.sub.html (#36247)
    • PASS [expected FAIL] subtest: sec-fetch-site - Same origin, no options - registration
  • ERROR [expected OK] /focus/focus-event-after-switching-iframes.sub.html (#40368)
  • TIMEOUT /html/interaction/focus/the-autofocus-attribute/supported-elements.html (#24145)
    • PASS [expected TIMEOUT] subtest: Non-HTMLElement should not support autofocus
    • TIMEOUT [expected NOTRUN] subtest: Host element with delegatesFocus should support autofocus

      Test timed out
      

  • OK [expected TIMEOUT] /html/semantics/embedded-content/media-elements/src_object_blob.html (#40340)
    • PASS [expected TIMEOUT] subtest: HTMLMediaElement.srcObject blob
  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-location-replace.html (#32604)
    • FAIL [expected PASS] subtest: Navigating iframe loading='lazy' before it is loaded: location.replace

      uncaught exception: Error: assert_equals: expected "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?nav" but got "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?src"
      

  • TIMEOUT [expected OK] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_navigate_other_frame_popup.sub.html (#39702)
    • TIMEOUT [expected FAIL] subtest: Sandboxed iframe can not navigate other frame's popup

      Test timed out
      

  • OK /html/semantics/scripting-1/the-script-element/execution-timing/077.html (#22139)
    • FAIL [expected PASS] subtest: adding several types of scripts through the DOM and removing some of them confuses scheduler

      assert_array_equals: expected property 1 to be "Script #1 ran" but got "Script #3 ran" (expected array ["Script #2 ran", "Script #1 ran", "Script #3 ran", "Script #4 ran"] got ["Script #2 ran", "Script #3 ran", "Script #4 ran", "Script #1 ran"])
      

  • TIMEOUT [expected OK] /html/user-activation/navigation-state-reset-sameorigin.html
    • TIMEOUT [expected PASS] subtest: Post-navigation state reset.

      Test timed out
      

  • TIMEOUT [expected OK] /trusted-types/trusted-types-navigation.html?01-05 (#38975)
    • TIMEOUT [expected PASS] subtest: Navigate a window via anchor with javascript:-urls in report-only mode.

      Test timed out
      

    • NOTRUN [expected PASS] subtest: Navigate a window via anchor with javascript:-urls w/ default policy in report-only mode.
    • NOTRUN [expected PASS] subtest: Navigate a frame via anchor with javascript:-urls in enforcing mode.
  • OK [expected TIMEOUT] /trusted-types/trusted-types-navigation.html?26-30 (#38807)
    • PASS [expected TIMEOUT] subtest: Navigate a window via form-submission with javascript:-urls in report-only mode.
    • PASS [expected NOTRUN] subtest: Navigate a window via form-submission with javascript:-urls w/ default policy in report-only mode.
    • PASS [expected NOTRUN] subtest: Navigate a frame via form-submission with javascript:-urls in enforcing mode.
    • PASS [expected NOTRUN] subtest: Navigate a frame via form-submission with javascript:-urls w/ default policy in enforcing mode.
  • CRASH [expected OK] /webxr/layers/xrWebGLBinding_createQuadLayer.https.html
  • CRASH [expected OK] /webxr/xrSession_sameObject.https.html
Stable unexpected results that are known to be intermittent (20)
  • OK /FileAPI/url/url-with-fetch.any.html (#21517)
    • PASS [expected FAIL] subtest: Revoke blob URL after calling fetch, fetch should succeed
  • FAIL [expected PASS] /_mozilla/mozilla/sslfail.html (#10760)
  • TIMEOUT [expected OK] /_mozilla/mozilla/window_resize_event.html (#36741)
    • TIMEOUT [expected PASS] subtest: Popup onresize event fires after resizeTo

      Test timed out
      

  • OK [expected TIMEOUT] /content-security-policy/inheritance/document-write-iframe.html (#41195)
    • PASS [expected TIMEOUT] subtest: document.open() keeps inherited CSPs on transient about:blank.
  • OK /css/css-cascade/layer-font-face-override.html (#35935)
    • PASS [expected FAIL] subtest: @font-face override update with appended sheet 1
    • PASS [expected FAIL] subtest: @font-face override update with appended sheet 2
  • OK /css/css-fonts/generic-family-keywords-001.html (#37467)
    • FAIL [expected PASS] subtest: @font-face matching for quoted and unquoted generic(khmer-mul)

      assert_equals: quoted generic(khmer-mul) matches  @font-face rule expected 50 but got 30
      

  • OK /css/css-fonts/generic-family-keywords-003.html (#38994)
    • FAIL [expected PASS] subtest: @font-face matching for quoted and unquoted cursive (drawing text in a canvas)

      assert_equals: quoted cursive matches  @font-face rule expected 125 but got 40
      

    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted math (drawing text in a canvas)
    • FAIL [expected PASS] subtest: @font-face matching for quoted and unquoted generic(fangsong) (drawing text in a canvas)

      assert_equals: quoted generic(fangsong) matches  @font-face rule expected 125 but got 40
      

    • FAIL [expected PASS] subtest: @font-face matching for quoted and unquoted ui-serif (drawing text in a canvas)

      assert_equals: quoted ui-serif matches  @font-face rule expected 125 but got 40
      

    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted ui-sans-serif (drawing text in a canvas)
  • OK [expected ERROR] /fetch/fetch-later/quota/same-origin-iframe/multiple-iframes.https.window.html (#35176)
  • OK /html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/a-click.html (#28697)
    • FAIL [expected PASS] subtest: aElement.click() before the load event must NOT replace

      assert_equals: expected "http://web-platform.test:8000/common/blank.html?thereplacement" but got "http://web-platform.test:8000/html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/resources/code-injector.html?pipe=sub(none)&amp;code=%0A%20%20%20%20const%20a%20%3D%20document.createElement(%22a%22)%3B%0A%20%20%20%20a.href%20%3D%20%22%2Fcommon%2Fblank.html%3Fthereplacement%22%3B%0A%20%20%20%20document.currentScript.before(a)%3B%0A%20%20%20%20a.click()%3B%0A%20%20"
      

  • OK /html/browsers/history/the-history-interface/traverse_the_history_2.html (#21383)
    • FAIL [expected PASS] subtest: Multiple history traversals, last would be aborted

      assert_array_equals: Pages opened during history navigation expected property 1 to be 3 but got 2 (expected array [6, 3] got [6, 2])
      

  • OK /html/browsers/history/the-history-interface/traverse_the_history_3.html (#21383)
    • PASS [expected FAIL] subtest: Multiple history traversals, last would be aborted
  • OK [expected TIMEOUT] /html/interaction/focus/the-autofocus-attribute/document-with-fragment-top.html (#28259)
    • FAIL [expected TIMEOUT] subtest: Autofocus elements in top-level browsing context's documents with "top" fragments should work.

      assert_not_equals: got disallowed value Element node &lt;body&gt;&lt;/body&gt;
      

  • OK /html/semantics/document-metadata/the-meta-element/pragma-directives/attr-meta-http-equiv-refresh/allow-scripts-flag-changing-1.html (#39694)
    • PASS [expected FAIL] subtest: Meta refresh is blocked by the allow-scripts sandbox flag at its creation time, not when refresh comes due
  • OK /navigation-timing/test-navigation-type-reload.html (#33334)
    • FAIL [expected PASS] subtest: Reload domInteractive &gt; Original domInteractive

      assert_true: Reload domInteractive &gt; Original domInteractive expected true got false
      

    • FAIL [expected PASS] subtest: Reload fetchStart &gt; Original fetchStart

      assert_true: Reload fetchStart &gt; Original fetchStart expected true got false
      

  • TIMEOUT [expected OK] /preload/modulepreload-sri-importmap.html (#43354)
    • TIMEOUT [expected PASS] subtest: Script should not be loaded if modulepreload's integrity is invalid

      Test timed out
      

  • CRASH [expected OK] /resource-timing/render-blocking-status-link.html (#41664)
  • OK /resource-timing/test_resource_timing.https.html (#25216)
    • PASS [expected FAIL] subtest: PerformanceEntry has correct name, initiatorType, startTime, and duration (iframe)
    • FAIL [expected PASS] subtest: PerformanceEntry has correct name, initiatorType, startTime, and duration (xmlhttprequest)

      assert_equals: expected 51.02000000000001 but got 51.02
      

  • OK /webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-buffer-stitching.html (#22849)
    • FAIL [expected PASS] subtest: buffer-stitching-1

      assert_approx_equals: Stitched sine‑wave buffers at sample rate 44100 sample[16680] |9.864612566198297e+21 - 0.47198569774627686| = 9.864612566198297e+21 &gt; 0.000090957 expected 0.47198569774627686 +/- 0.000090957 but got 9.864612566198297e+21
      

  • OK [expected TIMEOUT] /webstorage/localstorage-about-blank-3P-iframe-opens-3P-window.partitioned.html (#29053)
    • PASS [expected TIMEOUT] subtest: StorageKey: test 3P about:blank window opened from a 3P iframe
  • OK [expected ERROR] /webxr/render_state_update.https.html (#27535)

@github-actions

Copy link
Copy Markdown

✨ Try run (#23253066704) succeeded.

@servo-highfive servo-highfive removed the S-awaiting-review There is new code that needs to be reviewed. label Mar 18, 2026
@mrobinson

Copy link
Copy Markdown
Member

@jakubadamw Let me know when you've signed-off on this commit and I will send it to the merge queue.

@servo-highfive servo-highfive added the S-awaiting-review There is new code that needs to be reviewed. label Mar 18, 2026
@jakubadamw

Copy link
Copy Markdown
Contributor Author

@mrobinson, thank you for the review! Done. 🙂

@simonwuelker simonwuelker enabled auto-merge March 18, 2026 16:29
@simonwuelker simonwuelker added this pull request to the merge queue Mar 18, 2026
@servo-highfive servo-highfive added the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Mar 18, 2026
Merged via the queue into servo:main with commit e6d06b2 Mar 18, 2026
33 checks passed
@servo-highfive servo-highfive removed the S-awaiting-merge The PR is in the process of compiling and running tests on the automated CI. label Mar 18, 2026
Gae24 pushed a commit to Gae24/servo that referenced this pull request Mar 26, 2026
Elements with `visibility: hidden` or `visibility: collapse` could
receive focus via both sequential navigation and the JS `focus()` API,
and already-focused elements did not lose focus when becoming invisible.
Per the CSS Display spec, invisible elements ”are removed from
navigation”, which we can interpret to mean sequential focus navigation.
In practical terms, all popular user agents also prevent elements with
`visibility: hidden` from being focusable via click/the JS API, so this
PR makes servo match that behaviour as well.

Specifically, this adds a visibility check to
`Element::focusable_area_kind()` so that invisible elements are excluded
from all focusable area kinds.

Fixes servo#41312.

---------

Signed-off-by: Jacob Adam <software@jacobadam.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-awaiting-review There is new code that needs to be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Element with visibility: hidden being focusable

4 participants