Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/virtual-list-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@gpuix/native": patch
"@gpuix/react": patch
---

Report virtual-list visible-range changes after data and layout updates.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,12 @@ function MessageList({ messages }: { messages: Message[] }) {
}
```

`onVisibleRange` runs when the visible logical range changes, including after
the item count, mounted window, row-height estimate, or viewport size changes.
It does not run again when an update leaves the range unchanged. For example,
shrinking a list while scrolled near its end clamps the scroll position and
reports the new range so a windowed list can mount the rows now in view.

`role` and ARIA props on `<virtual-list>` project an accessibility node exactly
as they do on a `<div>`. Use `role="list"` on the list and `role="listitem"` on
its rows to match the react-dom equivalent of `<ul>` and `<li>`.
Expand Down
200 changes: 190 additions & 10 deletions packages/native/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash as _, Hasher as _};
use std::path::PathBuf;
#[cfg(any(target_os = "macos", target_family = "wasm"))]
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))]
Expand Down Expand Up @@ -10368,6 +10367,8 @@ struct VirtualListEntry {
/// rather than assuming the list has no id-bearing ancestors.
accessibility_ancestor_path: Option<Vec<gpui::ElementId>>,
pending_focus: Option<PendingVirtualFocus>,
reported_visible_range: Rc<RefCell<Option<std::ops::Range<usize>>>>,
last_visible_range_check: Option<(u64, gpui::Size<gpui::Pixels>, usize, gpui::Pixels)>,
}

impl VirtualListEntry {
Expand Down Expand Up @@ -10403,6 +10404,8 @@ impl VirtualListEntry {
seen_rows: HashSet::new(),
accessibility_ancestor_path: None,
pending_focus: None,
reported_visible_range: Rc::new(RefCell::new(None)),
last_visible_range_check: None,
}
}

Expand Down Expand Up @@ -10478,6 +10481,8 @@ impl VirtualListEntry {
.seen_rows
.retain(|id| replacement.child_ids.contains(id));
replacement.pending_focus = self.pending_focus.take();
replacement.reported_visible_range = self.reported_visible_range.clone();
replacement.last_visible_range_check = self.last_visible_range_check;
if !should_follow {
replacement.state.scroll_to(scroll_top);
}
Expand Down Expand Up @@ -14648,12 +14653,20 @@ fn build_virtual_list(
if element.events.contains("visibleRange") {
let callback = ctx.event_callback.clone();
let list_id = element.id;
let reported_range = ctx
.virtual_lists
.get(&list_id)
.expect("virtual list entry exists")
.reported_visible_range
.clone();
list_state.set_scroll_handler(move |event, _window, _cx| {
bump_virtual_list_scroll_generation(list_id);
emit_event_full(&callback, list_id, "visibleRange", |payload| {
payload.start_index = Some(event.visible_range.start as f64);
payload.end_index = Some(event.visible_range.end as f64);
});
emit_virtual_visible_range(
&callback,
list_id,
event.visible_range.clone(),
&reported_range,
);
});
}

Expand Down Expand Up @@ -14683,12 +14696,65 @@ fn build_virtual_list(
cx,
)
});
let list = gpui::list(list_state, render_item)
let list = gpui::list(list_state.clone(), render_item)
.with_sizing_behavior(gpui::ListSizingBehavior::Auto)
.size_full();
// `List` doesn't run GPUI's interactivity layout and paint hooks. Let a
// div own the virtual list's bounds, focus identity, and tab stop while
// the List keeps its scrolling and row hit testing.
let needs_visible_range_check = ctx.virtual_lists.get(&element.id).is_some_and(|entry| {
let scroll_top = entry.state.logical_scroll_top();
entry.last_visible_range_check
!= Some((
element.subtree_revision,
entry.state.viewport_bounds().size,
scroll_top.item_ix,
scroll_top.offset_in_item,
))
});
if element.events.contains("visibleRange") && needs_visible_range_check {
let list_id = element.id;
let subtree_revision = element.subtree_revision;
let callback = ctx.event_callback.clone();
let view = cx.weak_entity();
window.on_next_frame(move |_window, app| {
let _ = view.update(app, |view, _cx| {
let Some(entry) = view.virtual_lists.get_mut(&list_id) else {
return;
};
let scroll_top = entry.state.logical_scroll_top();
entry.last_visible_range_check = Some((
subtree_revision,
entry.state.viewport_bounds().size,
scroll_top.item_ix,
scroll_top.offset_in_item,
));
// A cross-window focus request has already reported the estimated
// range needed to mount its target. Reporting the still-current
// viewport here could replace that request before React mounts it.
if entry.pending_focus.is_some() {
return;
}
let count = entry.config.logical_count(entry.child_ids.len());
if let Some(range) = virtual_list_visible_range(
&entry.state,
count,
entry.config.estimated_item_height,
) {
if entry.reported_visible_range.borrow().is_none() {
*entry.reported_visible_range.borrow_mut() = Some(range);
} else {
emit_virtual_visible_range(
&callback,
list_id,
range,
&entry.reported_visible_range,
);
}
}
});
});
}
let mut surface = gpui::div()
.id(gpui::ElementId::Integer(element.id))
.child(list);
Expand Down Expand Up @@ -18198,10 +18264,124 @@ fn emit_virtual_window_advance(
(target_index + viewport_rows).min(logical_count),
),
};
emit_event_full(callback, list_id, "visibleRange", |payload| {
payload.start_index = Some(start_index as f64);
payload.end_index = Some(end_index as f64);
});
emit_virtual_visible_range(
callback,
list_id,
start_index..end_index,
&entry.reported_visible_range,
);
}

fn emit_virtual_visible_range(
callback: &Option<EventCallback>,
list_id: u64,
range: std::ops::Range<usize>,
reported_range: &Rc<RefCell<Option<std::ops::Range<usize>>>>,
) {
let changed = {
let mut previous = reported_range.borrow_mut();
if previous.as_ref() == Some(&range) {
false
} else {
*previous = Some(range.clone());
true
}
};
if changed {
emit_event_full(callback, list_id, "visibleRange", |payload| {
payload.start_index = Some(range.start as f64);
payload.end_index = Some(range.end as f64);
});
}
}

/// Read the range GPUI laid out after a non-scroll update. The anchor starts
/// at the first potentially visible row, and GPUI has measured each row as it
/// enters the viewport, so this walks only the visible rows and the first row
/// below them. An unknown bound means layout has not measured enough yet; a
/// later paint will try again.
fn virtual_list_visible_range(
state: &gpui::ListState,
logical_count: usize,
estimated_item_height: Option<f32>,
) -> Option<std::ops::Range<usize>> {
if logical_count == 0 {
return Some(0..0);
}

let scroll_top = state.logical_scroll_top();
if scroll_top.item_ix >= logical_count {
return estimated_virtual_list_visible_range(
state,
logical_count,
estimated_item_height,
);
}

let mut start = scroll_top.item_ix;
while start > 0 {
match state.item_is_above_viewport(start) {
Some(true) => start -= 1,
Some(false) => break,
None => {
return estimated_virtual_list_visible_range(
state,
logical_count,
estimated_item_height,
);
}
}
}

let mut end = start;
while end < logical_count {
let Some(below) = state.item_is_below_viewport(end) else {
return estimated_virtual_list_visible_range(
state,
logical_count,
estimated_item_height,
);
};
end += 1;
if below {
break;
}
}
Some(start..end)
}

fn estimated_virtual_list_visible_range(
state: &gpui::ListState,
logical_count: usize,
estimated_item_height: Option<f32>,
) -> Option<std::ops::Range<usize>> {
estimated_virtual_list_visible_range_for_height(
state,
logical_count,
estimated_item_height,
state.viewport_bounds().size.height,
)
}

fn estimated_virtual_list_visible_range_for_height(
state: &gpui::ListState,
logical_count: usize,
estimated_item_height: Option<f32>,
viewport_height: gpui::Pixels,
) -> Option<std::ops::Range<usize>> {
let height = estimated_item_height.filter(|height| *height > 0.0)?;
let scroll_top = state.logical_scroll_top();
if scroll_top.item_ix >= logical_count {
let viewport_height = f32::from(viewport_height).max(0.0);
let rows_in_view = (viewport_height / height).ceil() as usize;
return Some(logical_count.saturating_sub(rows_in_view)..logical_count);
}
let viewport_height = f32::from(viewport_height).max(0.0);
let rows_in_view = ((f32::from(scroll_top.offset_in_item).max(0.0) + viewport_height) / height)
.floor() as usize
+ 1;
let start = scroll_top.item_ix;
Some(start..start.saturating_add(rows_in_view).min(logical_count))
}

fn window_size(window: &gpui::Window) -> WindowSize {
Expand Down
18 changes: 18 additions & 0 deletions packages/native/src/test_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3191,6 +3191,24 @@ impl TestGpuixRenderer {
events.drain(..).collect()
}

/// Return and clear events of one type while leaving other queued events
/// available to the test renderer's next full drain.
#[napi]
pub fn drain_events_of_type(&self, event_type: String) -> Vec<EventPayload> {
let mut events = self.events.lock().unwrap();
let mut matching = Vec::new();
let mut remaining = Vec::with_capacity(events.len());
for event in events.drain(..) {
if event.event_type == event_type {
matching.push(event);
} else {
remaining.push(event);
}
}
*events = remaining;
matching
}

/// Return and clear native frame timestamps since the last drain.
/// Timestamps are collected synchronously — no event loop queuing.
#[napi]
Expand Down
37 changes: 37 additions & 0 deletions packages/react/src/__tests__/instance-scroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,43 @@ describeNative("host instance scroll properties", () => {
expect(testRoot.renderer.getScrollOffset(scroller.id)).toEqual([0, -40])
})

it("reports scroll when a content update clamps the position", async () => {
const scrollPositions: number[] = []
const scrollerRef = React.createRef<PublicInstance>()
let contentHeight = 400

function ScrollBox() {
return (
<div
ref={scrollerRef}
data-testid="clamped-scroller"
style={{ width: 200, height: 100, overflowY: "scroll" }}
onScroll={(event) => scrollPositions.push(event.currentTarget.scrollTop)}
>
<div style={{ height: contentHeight, flexShrink: 0 }}>
<text>scrollable</text>
</div>
</div>
)
}

testRoot.render(<ScrollBox />)
const scroller = scrollerRef.current!
testRoot.renderer.scrollTo(scroller.id, 0, -300)
testRoot.renderer.flush()
testRoot.renderer.dispatchNativeEvents()
expect(scroller.scrollTop).toBe(300)
expect(scrollPositions).toContain(300)

contentHeight = 80
testRoot.render(<ScrollBox />)
await testRoot.waitFor(() => {
testRoot.renderer.dispatchNativeEvents()
expect(scroller.scrollTop).toBe(0)
expect(scrollPositions.at(-1)).toBe(0)
}, { timeout: 5_000 })
})

it("reports a viewport for an element that cannot scroll", () => {
const ref = React.createRef<PublicInstance>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ describeNative("<virtual-list> cross-window focus navigation", () => {
overflowY: "scroll",
}}
onVisibleRange={(event) => {
if ((event.endIndex ?? 0) <= end) return
const next = Math.max(0, Math.floor(event.startIndex ?? 0) - 2)
release.current = () => setStart((current) => (current === next ? current : next))
}}
Expand Down Expand Up @@ -567,6 +568,7 @@ describeNative("<virtual-list> cross-window focus navigation", () => {
overflowY: "scroll",
}}
onVisibleRange={(event) => {
if ((event.endIndex ?? 0) <= end) return
const next = Math.max(0, (event.endIndex ?? 1) - 1)
setStart((current) => (current === next ? current : next))
}}
Expand Down Expand Up @@ -783,6 +785,7 @@ describeNative("<virtual-list> cross-window focus navigation", () => {
overflowY: "scroll",
}}
onVisibleRange={(event) => {
if ((event.endIndex ?? 0) <= end) return
const next = Math.max(0, Math.floor(event.startIndex ?? 0) - 2)
releases.push(() => setStart((current) => (current === next ? current : next)))
}}
Expand Down Expand Up @@ -821,7 +824,9 @@ describeNative("<virtual-list> cross-window focus navigation", () => {
screen.renderer.focusNext()
screen.renderer.flush()
screen.renderer.drawPendingFrame()
expect(releases).toHaveLength(1)
// A real viewport-range change may also ask the app to widen its
// window before the deferred focus request is released.
expect(releases.length).toBeGreaterThan(0)

// A real, unrelated wheel scroll happens before the app ever builds
// Button 20 — this is what supersedes the pending request, regardless
Expand Down
Loading