Skip to content

a11y: Basic accessibility tree implementation for web contents#42338

Open
alice wants to merge 7 commits intoservo:mainfrom
alice:accessibility-tree
Open

a11y: Basic accessibility tree implementation for web contents#42338
alice wants to merge 7 commits intoservo:mainfrom
alice:accessibility-tree

Conversation

@alice
Copy link
Copy Markdown
Contributor

@alice alice commented Feb 4, 2026

This change introduces the accessibility_tree module, containing code to build an in-memory representation of a very basic accessibility tree for web contents. Currently, the tree for a given document contains:

  • a RootWebArea which has the document root node as its sole child,
  • an Unknown node for the root DOM node,
  • a GenericContainer node for each DOM element, and
  • a TextRun node for each text node.

This allows us to make basic assertions about the tree contents in the accessibility test by doing a tree walk to find text nodes and checking their contents.

Right now, the tree is rebuilt from scratch when accessibility is enabled and when a navigation occurs (via Constellation::set_frame_tree_for_webview() sending ScriptThreadMessage::SetAccessibilityActive); it's not responsive to changes in the page.

Fixes: Part of #4344

@alexecution
Copy link
Copy Markdown

I would happily test accessibility if there is a binary or something I can easily spin up and test screen reader functionality.

@alice
Copy link
Copy Markdown
Contributor Author

alice commented Feb 10, 2026

@alex-chap Thank you! This branch is still very much a work in progress for the time being, but as we start landing more of it we can ping back here for you to take a look. #42333 is the first step: adding a pref which can be set from the command line to enable the accessibility code to run at all. (Right now, it has no effect.) As we go, we'll land code behind that pref, so it'll be available in any nightly binary built after it lands.

@RastislavKish
Copy link
Copy Markdown

we can ping back here for you to take a look.

@alice please do! This is an amazing project and I'll be also happy to help with testing as much as I can. You servo devs and contributors are doing a fantastic job!

@alice alice force-pushed the accessibility-tree branch 2 times, most recently from d5b7784 to 5570a73 Compare February 19, 2026 10:34
@delan delan force-pushed the accessibility-tree branch from 5dd453d to 00311a7 Compare February 20, 2026 03:18
@codecov-commenter

This comment was marked as outdated.

@delan delan force-pushed the accessibility-tree branch 7 times, most recently from 9599978 to 9ccafbc Compare February 20, 2026 08:11
dom_worklet_timeout_ms: 10,
dom_visual_viewport_enabled: false,
accessibility_enabled: false,
accessibility_enabled: true,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

remove before landing

ConstellationWebView::new(webview_id, browsing_context_id, user_content_manager_id),
);

// TODO: enable accessibility if necessary?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think something like this will be necessary. embedders think in terms of webviews, not in terms of pipelines, since those change every time you navigate. for example, servoshell will want to have a subtree graft for each tab, but it shouldn’t have to know which pipeline is currently loaded in each tab.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

some thoughts from our pairing today…

does the webview get its own trivial subtree that the pipeline subtrees are grafted into, or does the webview lend its subtree to whatever pipeline is currently loaded in its top-level browsing context? i think we want to give each webview its own trivial subtree, because that’s conceptually simpler, and less likely to cause problems when we deal with bfcache issues (see below)?

each pipeline gets a subtree, and likely its own subtree id (see above). but what about navigating then clicking back? bfcache means that the previous page should generally be revived cheaply and instantly, but as soon as we disconnect the previous page’s subtree, we think the accesskit consumer would destroy the subtree contents. will we need some way of keeping the subtree contents around, but inactive? maybe we can use the AT-SPI showing state (≈ !accesskit::Node::is_hidden()), which firefox also uses for inactive tabs? this is not necessarily just a performance optimisation, because destroying and recreating content in the a11y tree may have AT side effects. i think to answer this we need to know two things: (a) what do ATs do? and (b) what do other browsers do?

image

@delan delan force-pushed the accessibility-tree branch 3 times, most recently from 2897a88 to 450e963 Compare February 25, 2026 05:59
@alice alice force-pushed the accessibility-tree branch from c52c94d to 4a980fa Compare February 25, 2026 12:28
@delan delan force-pushed the accessibility-tree branch 4 times, most recently from 8e5b6e1 to 50163f6 Compare February 26, 2026 11:35
self.embedder_proxy.send(EmbedderMsg::AccessibilityTreeId(
webview_id,
webview.active_top_level_pipeline_id.accesskit_tree_id(),
));
Copy link
Copy Markdown
Member

@delan delan Feb 26, 2026

Choose a reason for hiding this comment

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

notable change: one of three locations where we set a11y tree id. relies on deterministic PipelineId → TreeId conversion to avoid asking script thread for a stored TreeId. need to document downstream-activation-flag pattern (this is the “existing” case)

@delan delan force-pushed the accessibility-tree branch 4 times, most recently from fee071d to bda072a Compare March 30, 2026 10:19
@delan
Copy link
Copy Markdown
Member

delan commented Apr 1, 2026

we have a crash when deactivating then reactivating accessibility. this should repro with plasma on linux.

one window:

  • accessibility enabled, run servoshell = ok
  • accessibility disabled, run servoshell, enable, focus servoshell = ok
  • accessibility enabled, run servoshell, disable, focus servoshell, enable, focus = crash

two windows:

  • accessibility enabled, run servoshell, open second window, focus both = ok
  • accessibility disabled, run servoshell, open second window, enable, focus both = ok
  • accessibility enabled, run servoshell, open second window, disable, focus both, enable = crash

in both cases, we get the subtree-before-graft panic:

panic details
Cannot push subtree TreeId(879211fb-8799-5492-9a31-95a35c05a192): no graft node exists for this tree. Push the graft node (with tree_id property set) before pushing the subtree. (thread main, at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_consumer-0.35.0/src/tree.rs:123)
   0: print
             at /home/servo/servo/ports/servoshell/backtrace.rs:18:5
   1: panic_hook
             at /home/servo/servo/ports/servoshell/panic_hook.rs:40:17
   2: <alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::call
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/alloc/src/boxed.rs:2019:9
      std::panicking::panic_with_hook
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:842:13
   3: std::panicking::panic_handler::{{closure}}
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:707:13
   4: std::sys::backtrace::__rust_end_short_backtrace
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/sys/backtrace.rs:174:18
   5: __rustc::rust_begin_unwind
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:698:5
   6: core::panicking::panic_fmt
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/core/src/panicking.rs:80:14
   7: update
   8: accesskit_consumer::tree::Tree::update_and_process_changes
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_consumer-0.35.0/src/tree.rs:627:14
   9: update
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_atspi_common-0.18.0/src/adapter.rs:519:14
  10: update_if_active<servoshell::desktop::gui::{impl#1}::update::{closure_env#1}>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_unix-0.21.0/src/adapter.rs:198:52
  11: update_if_active<servoshell::desktop::gui::{impl#1}::update::{closure_env#1}>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_winit-0.32.2/src/platform_impl/unix.rs:30:22
      update_if_active<servoshell::desktop::gui::{impl#1}::update::{closure_env#1}>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/accesskit_winit-0.32.2/src/lib.rs:263:20
      update
             at /home/servo/servo/ports/servoshell/desktop/gui.rs:535:21
  12: handle_winit_window_event
             at /home/servo/servo/ports/servoshell/desktop/headed_window.rs:546:17
  13: window_event
             at /home/servo/servo/ports/servoshell/desktop/app.rs:196:31
  14: dispatch_event_for_app<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/event_loop.rs:642:56
      {closure#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/event_loop.rs:265:49
      call_mut<(winit::event::Event<servoshell::desktop::event_loop::AppEvent>, &winit::event_loop::ActiveEventLoop), winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.rustup/toolchains/1.92.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:298:21
      call_mut<(winit::event::Event<servoshell::desktop::event_loop::AppEvent>, &winit::event_loop::ActiveEventLoop), &mut winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.rustup/toolchains/1.92.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:298:21
      single_iteration<servoshell::desktop::event_loop::AppEvent, &mut &mut winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/wayland/event_loop/mod.rs:502:17
      poll_events_with_timeout<servoshell::desktop::event_loop::AppEvent, &mut &mut winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/wayland/event_loop/mod.rs:328:14
      pump_events<servoshell::desktop::event_loop::AppEvent, &mut winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/wayland/event_loop/mod.rs:226:18
  15: run_on_demand<servoshell::desktop::event_loop::AppEvent, winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/wayland/event_loop/mod.rs:190:24
  16: run_on_demand<servoshell::desktop::event_loop::AppEvent, winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/mod.rs:820:61
      run<servoshell::desktop::event_loop::AppEvent, winit::event_loop::{impl#6}::run_app::{closure_env#0}<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/platform_impl/linux/mod.rs:813:14
  17: run_app<servoshell::desktop::event_loop::AppEvent, servoshell::desktop::app::App>
             at /home/servo/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/winit-0.30.13/src/event_loop.rs:265:25
      run_app
             at /home/servo/servo/ports/servoshell/desktop/event_loop.rs:87:22
  18: main
             at /home/servo/servo/ports/servoshell/desktop/cli.rs:46:20
  19: call_once<fn(), ()>
             at /home/servo/.rustup/toolchains/1.92.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
      __rust_begin_short_backtrace<fn(), ()>
             at /home/servo/.rustup/toolchains/1.92.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/backtrace.rs:158:18
  20: std::rt::lang_start::{{closure}}
             at /home/servo/.rustup/toolchains/1.92.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/rt.rs:206:18
  21: core::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_once
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/core/src/ops/function.rs:287:21
      std::panicking::catch_unwind::do_call
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:590:40
      std::panicking::catch_unwind
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:553:19
      std::panic::catch_unwind
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panic.rs:359:14
      std::rt::lang_start_internal::{{closure}}
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/rt.rs:175:24
      std::panicking::catch_unwind::do_call
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:590:40
      std::panicking::catch_unwind
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panicking.rs:553:19
      std::panic::catch_unwind
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/panic.rs:359:14
      std::rt::lang_start_internal
             at /rustc/ded5c06cf21d2b93bffd5d884aa6e96934ee4234/library/std/src/rt.rs:171:5
  22: main
  23: <unknown>
  24: __libc_start_main
  25: _start
[2026-04-01T09:37:02Z ERROR servoshell::panic_hook] Cannot push subtree TreeId(879211fb-8799-5492-9a31-95a35c05a192): no graft node exists for this tree. Push the graft node (with tree_id property set) before pushing the subtree.
mozilla::detail::MutexImpl::~MutexImpl: pthread_mutex_destroy failed: Device or resource busy
Caught signal 11 in thread "main"
   0: print
             at /home/servo/servo/ports/servoshell/backtrace.rs:18:5
   1: handler
             at /home/servo/servo/ports/servoshell/crash_handler.rs:38:21
   2: <unknown>
   3: _ZN7mozilla6detail9MutexImplD1Ev
   4: <unknown>
   5: exit
   6: <unknown>
   7: __libc_start_main
   8: _start
Servo was terminated by signal 11

github-merge-queue bot pushed a commit that referenced this pull request Apr 1, 2026
this patch plumbs the webview accessibility trees (#43029, #43556) into
servoshell. we add a global flag in servoshell, which is set when the
platform activates accessibility and cleared when the platform
deactivates accessibility. the flag in turn [activates
accessibility](https://doc.servo.org/servo/struct.WebView.html#method.set_accessibility_active)
in existing and new webviews.

Testing: none in this patch, but will be covered by end-to-end platform
a11y tests in WPT
Fixes: part of #4344, extracted from our work in #42338

Signed-off-by: delan azabani <[email protected]>
Co-authored-by: Luke Warlow <[email protected]>
Co-authored-by: Alice Boxhall <[email protected]>
@alice alice force-pushed the accessibility-tree branch 2 times, most recently from e4b3021 to 3e015dc Compare April 2, 2026 10:36
@alice
Copy link
Copy Markdown
Contributor Author

alice commented Apr 2, 2026

I got to the bottom of the crash: we weren't sending a new TreeUpdate with the graft node for the web contents' subtree after re-enabling accessibility, so we were getting a TreeUpdate for the web contents' subtree that had nowhere to go.

Fixed by clearing out grafted_accesskit_tree_id in WebView when accessibility is disabled: 406ce68

alice added a commit to alice/servo that referenced this pull request Apr 2, 2026
@alice alice force-pushed the accessibility-tree branch from 3e015dc to 779b7f3 Compare April 2, 2026 10:42
alice added a commit to alice/servo that referenced this pull request Apr 2, 2026
@alice alice force-pushed the accessibility-tree branch from 779b7f3 to 9066f3b Compare April 2, 2026 10:44
alice added a commit to alice/servo that referenced this pull request Apr 2, 2026
@alice alice force-pushed the accessibility-tree branch from 9066f3b to 1f4b668 Compare April 2, 2026 15:21
alice added a commit to alice/servo that referenced this pull request Apr 2, 2026
@alice alice force-pushed the accessibility-tree branch from 1f4b668 to 039d488 Compare April 2, 2026 15:22
@alice alice changed the title Build accessibility trees and send them to AccessKit a11y: Basic accessibility tree implementation for web contents Apr 2, 2026
alice added a commit to alice/servo that referenced this pull request Apr 3, 2026
@alice
Copy link
Copy Markdown
Contributor Author

alice commented Apr 3, 2026

Just noticed a crash when navigating back in history, investigating now!

@alice
Copy link
Copy Markdown
Contributor Author

alice commented Apr 3, 2026

Ok, new crash also fixed: 1e6f091

Investigating it made me realise I'd inadvertently lied in the issue description, but the code now matches the accidental lie: we were previously rebuilding the accessibility tree and sending a tree update containing the full tree on every reflow, not just when accessibility was enabled and when navigating. The fix for the crash now resets the needs_accessibility_update flag after the accessibility tree has been updated during reflow.

The spammy tree updates caused the crash because in servoshell we "only" send tree updates to accesskit once per tick, after we've ensured that graft nodes exist for every active webview's top level pipeline. The crash was caused by a tree update sent immediately before the navigation which was still pending at the point of the next gui tick. While avoiding spammy updates should generally prevent this in most cases, I've added code to check the TreeId of each update before we send it to accesskit as well.

delan and others added 7 commits April 3, 2026 16:34
Co-authored-by: Luke Warlow <[email protected]>
Co-authored-by: Alice Boxhall <[email protected]>
Signed-off-by: delan azabani <[email protected]>
- Clear needs_accessibility_update after accessibility update in LayoutThread
- Check all TreeUpdates in servoshell Gui::update() are for a TreeId which has been grafted.

Signed-off-by: Alice Boxhall <[email protected]>
This simply tests that after set_accessibility_active() is called, Servo:
1) Generates an accessibility tree for its (sole) webview
2) Whose root node is a `RootWebArea`.

Signed-off-by: Alice Boxhall <[email protected]>
Co-authored-by: Delan Azabani <[email protected]>
…o be sent

Signed-off-by: Alice Boxhall <[email protected]>
Co-authored-by: Delan Azabani <[email protected]>
@alice
Copy link
Copy Markdown
Contributor Author

alice commented Apr 3, 2026

The lint found some issues with Cargo.lock; I'll have to look into those next week.

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.

6 participants