#3295

TextView: reveal_range in a virtual list can panic with duplicate accessibility nodes

Opensuxiaoshaoopened 1mo ago

Description

Calling TextViewState::reveal_range() for a line deep inside a tall virtual-list row can abort a native debug build with Duplicate a11y node id when accessibility is active.

The failure occurs during the list's autoscroll prepaint retry. An accessible control in the row is registered once during the discarded attempt and again during the retry, even though its element ID is unique in the view. The published GPUI snapshot appears to roll back other prepaint state but not the accessibility builder.

Environment

  • GPUI: gpui-pre 0.3.7 (Zed snapshot 1a28cff4b409169bac058bca40dfbfeb7621d19b)
  • GPUI Component: 0.7.0
  • GPUI Kit / Base: 0.7.0
  • Platform: macOS 27.0 (26A428), Apple Silicon
  • Native debug build, accessibility active through a macOS accessibility client

Steps to Reproduce

  1. Render a non-scrollable TextView inside a bounded gpui::list or MessageScroller. Use a row substantially taller than the list viewport, for example 90 Markdown paragraphs in a 500px-high viewport. Include an ordinary accessible Button in the same row, with a unique, stable ID.
  2. Activate the window's accessibility tree with an accessibility client. The relevant condition is window.is_a11y_active().
  3. Wait for Markdown parsing to finish. With that row mounted, call reveal_range() for text near its bottom, below the visible part of the row.
  4. The containing list honors the autoscroll request, changes its offset and prepaints the row again. The second pass triggers the duplicate-node assertion.

Example generic content:

let markdown = (0..90)
    .map(|i| format!("Paragraph {i}: some text to make this row taller than the viewport."))
    .collect::<Vec<_>>()
    .join("\n\n");

The retained TextViewState can be rendered in the row as:

div()
    .w_full()
    .child(TextView::new(&text_state).w_full().min_w_0())
    .child(Button::new("long-row-action").label("Action"))

After rendered_text() contains the target, trigger the reveal:

text_state.update(cx, |state, cx| {
    let rendered = state.rendered_text();
    let needle = "Paragraph 89";
    let start = rendered.as_str().find(needle).unwrap();
    state.reveal_range(start..start + needle.len(), cx).unwrap();
});

This needs a native accessibility-enabled window; ordinary headless layout tests do not exercise the failing accessibility registration path.

Screenshots

No screenshot is needed for the assertion failure; the diagnostic and source path are below.

Expected

Revealing a line scrolls it into view without crashing. Accessibility state from an abandoned prepaint attempt should be discarded together with the other transaction state, so the final frame contains each live node once.

Actual

The debug build aborts with:

Duplicate a11y node id: #<node-id>. In a release build, this node would be silently discarded from the a11y tree.
fatal runtime error: failed to initiate panic, error 3, aborting

The release path returns false for the duplicate node instead of asserting, so disabling debug assertions does not address the accessibility state problem.

Source investigation

  • Inline::request_reveal requests autoscroll for the target line.
  • ListState::prepaint_items runs inside window.transact(...) and returns an error carrying the adjusted scroll offset when a retry is needed.
  • Window::transact rolls back hitboxes, tooltips, deferred draws, dispatch nodes, accessed element states and text layouts, but does not roll back the accessibility builder.
  • A11yNodeBuilder::can_push still sees IDs inserted during the first attempt and rejects the retry.

This points to the bundled GPUI prepaint transaction boundary, rather than duplicated IDs in the caller. Filing here because the failure is reachable through the released Kit TextViewState::reveal_range integration and its pinned GPUI snapshot.

Activity

0

No activity yet.