VirtualView Lazy Loading

Overview

A VirtualViewCallback returns a partial DOM that represents only the slice of content currently in (or near) the viewport, plus a virtual size that drives the scrollbar as if all content were rendered. WIP — the manager and callback contract are stable; the cap on EDGE_THRESHOLD, the absence of left / top edge triggers, and the manual reset semantics are likely to change.

The runtime calls the callback only when something happens that could require new content: the initial render, a parent DOM rebuild, the container expanding past the rendered slice, or the user scrolling within EDGE_THRESHOLD (200 px) of an unfetched edge. Coordination lives in layout/src/managers/virtual_view.rs. The callback ABI is in core/src/callbacks.rs.

This page documents the callback signature and the manager that decides when to fire it. For how the resulting child DOM is composited into the parent's display list and pipelined through WebRender, see IFrame Scroll and Display Lists.

The callback signature

pub type VirtualViewCallbackType =
    extern "C" fn(RefAny, VirtualViewCallbackInfo) -> VirtualViewReturn;

pub struct VirtualViewCallback {
    pub cb: VirtualViewCallbackType,
    pub ctx: OptionRefAny,  // foreign callable for FFI; None for native Rust
}

Native Rust callers construct the wrapper via VirtualViewCallback::create(fn_ptr). FFI bindings store the user's foreign callable in ctx and a trampoline in cb that extracts both RefAnys and dispatches.

The two RefAnys a foreign callable receives are the user's data (held in the wrapping RefAny that bound this callback to the DOM) and the foreign-language callable itself (extracted from ctx). Native Rust closures live entirely in the cb function pointer.

VirtualViewCallbackInfo

pub struct VirtualViewCallbackInfo {
    pub reason: VirtualViewCallbackReason,
    pub system_fonts: *const FcFontCache,
    pub image_cache:  *const ImageCache,
    pub window_theme: WindowTheme,
    pub bounds: HidpiAdjustedBounds,            // logical_size + DPI factor
    pub scroll_size:           LogicalSize,     // currently rendered content size
    pub scroll_offset:         LogicalPosition, // origin of rendered content in virtual space
    pub virtual_scroll_size:   LogicalSize,     // size the scrollbar pretends content has
    pub virtual_scroll_offset: LogicalPosition, // origin of virtual space (usually zero)
    callable_ptr: *const OptionRefAny,
    _abi_mut: *mut c_void,
}

The two raw pointers (system_fonts, image_cache) are accessed through internal helper methods that re-borrow them with the callback-info lifetime. The unsafe deref is centralised so user code doesn't see it.

bounds.get_physical_size() gives the size in physical pixels accounting for DPI. get_image(image_id) and get_system_fonts() are convenience accessors that walk image_cache and system_fonts respectively. set_callable_ptr(&OptionRefAny) and get_ctx() are the FFI hooks for binding the foreign callable through the info struct (parallel to the LayoutCallbackInfo mechanism). Native Rust code doesn't call them; the trampoline in foreign bindings does.

Why the callback is invoked: VirtualViewCallbackReason

pub enum VirtualViewCallbackReason {
    InitialRender,
    DomRecreated,
    BoundsExpanded,
    EdgeScrolled(EdgeType),       // EdgeType: Top | Bottom | Left | Right
    ScrollBeyondContent,
}

Today VirtualViewManager::check_reinvoke produces InitialRender, BoundsExpanded, and EdgeScrolled(Bottom|Right). DomRecreated is set when the parent DOM rebuilds and the runtime has to re-prime the manager via reset_all_invocation_flags. The resulting check_reinvoke call returns InitialRender rather than DomRecreated, since the per-state flag was cleared. ScrollBeyondContent is reserved for the future case of programmatic scroll past virtual_scroll_size. The predicate isn't implemented yet.

The reason lets the callback short-circuit: an InitialRender may build a different fallback DOM than an EdgeScrolled(Bottom) extension fetch, and a DomRecreated callback usually re-emits the existing slice without re-querying the data source.

Return value: VirtualViewReturn

pub struct VirtualViewReturn {
    pub dom: OptionDom,                   // None = keep current DOM, only update bounds
    pub scroll_size:           LogicalSize,
    pub scroll_offset:         LogicalPosition,
    pub virtual_scroll_size:   LogicalSize,
    pub virtual_scroll_offset: LogicalPosition,
}

impl VirtualViewReturn {
    pub fn with_dom(dom: Dom, scroll_size, scroll_offset,
                    virtual_scroll_size, virtual_scroll_offset) -> Self;
    pub fn keep_current(scroll_size, scroll_offset,
                        virtual_scroll_size, virtual_scroll_offset) -> Self;
}

The two size pairs encode the virtualisation contract:

  • scroll_size and scroll_offset are the size and position of the actually-rendered DOM slice. For a table showing rows 10 to 30 at 30 px per row, scroll_size = (full_width, 600) and scroll_offset = (0, 300).
  • virtual_scroll_size and virtual_scroll_offset are the size the scrollbar represents. For a 1000-row table that's (full_width, 30000) regardless of which slice is rendered. virtual_scroll_offset is normally (0, 0) unless the virtual space starts at a non-zero origin.

Returning dom: OptionDom::None (keep_current(...)) is the optimisation path. The rendered slice is still adequate for the current scroll position, and only the scroll bounds need updating. The runtime won't rebuild the nested DOM.

VirtualViewManager

pub struct VirtualViewManager {
    states:        BTreeMap<(DomId, NodeId), VirtualViewState>,
    pipeline_ids:  BTreeMap<(DomId, NodeId), PipelineId>,
    next_dom_id:   usize,        // starts at 1; 0 is the root DOM
}

One state per (parent DomId, NodeId of the virtualised element). The manager owns:

  • get_or_create_nested_dom_id(parent_dom, node_id) -> DomId allocates the child DOM identifier the callback's returned Dom will live under.
  • get_or_create_pipeline_id(parent_dom, node_id) -> PipelineId assigns the WebRender pipeline so the nested DOM has its own scroll frame. It's encoded as PipelineId(dom_id.inner as u32, node_id.index() as u32). The encoding is deterministic, not counter-allocated, so the same VirtualView gets the same pipeline across rebuilds.
  • get_scroll_size, get_virtual_scroll_size, and was_virtual_view_invoked are accessors used during display-list generation and hit-testing.
  • update_virtual_view_info(parent_dom, node_id, scroll_size, virtual_scroll_size) is called after the callback returns to record the reported sizes. If the new scroll_size exceeds the old one, the invoked_for_current_expansion latch is reset so the next layout pass can request more.
  • mark_invoked(parent_dom, node_id, reason) flips the per-reason latches (virtual_view_was_invoked, invoked_for_current_expansion, invoked_for_current_edge, last_edge_triggered).

The next_dom_id counter starts at 1 because DomId { inner: 0 } is reserved for the root window DOM.

Re-invocation logic: check_reinvoke

pub fn check_reinvoke(
    &mut self,
    dom_id: DomId,
    node_id: NodeId,
    scroll_manager: &ScrollManager,
    layout_bounds: LogicalRect,
) -> Option<VirtualViewCallbackReason>;

The decision tree is:

  1. Never invoked? Return Some(InitialRender). This also runs after reset_all_invocation_flags, so a parent DOM rebuild yields InitialRender rather than DomRecreated today.
  2. Container grew? If layout_bounds.size.{width,height} is larger than the previously recorded last_bounds, clear the invoked_for_current_expansion latch.
  3. Update last_bounds to the current frame's value.
  4. Compute scroll_offset = scroll_manager.get_current_offset(dom_id, node_id).
  5. Delegate to VirtualViewState::check_reinvoke_condition.

The state-level predicate returns:

  • Some(BoundsExpanded) when the container is wider or taller than the recorded scroll_size and the expansion latch hasn't yet fired.
  • Some(EdgeScrolled(Bottom)) when bottom-edge scrolling is possible (scroll_size.height > container_size.height), the cursor is within EDGE_THRESHOLD (200 px) of the bottom, the edge latch is clear, and last_edge_triggered.bottom is false.
  • Some(EdgeScrolled(Right)) similarly for the right edge.
  • None otherwise.

Top and left edge variants are computed (current_edges.top, current_edges.left) but aren't currently emitted. Only bottom and right trigger callbacks. This is intentional for the common infinite-scroll case but means top-anchored „load more“ isn't yet supported through this path.

Re-invocation lifecycle

+----------------------------------------------------------------------+
|  Frame N: layout pass for a node with a VirtualViewCallback          |
|                                                                      |
|   1. VirtualViewManager.check_reinvoke(parent, node, scroll, bounds) |
|        |-- never invoked            -> Some(InitialRender)           |
|        |-- bounds grew              -> Some(BoundsExpanded)          |
|        |-- scroll near edge         -> Some(EdgeScrolled(Bottom|Right))
|        `-- otherwise                -> None  -> reuse last DOM       |
|                                                                      |
|   2. If Some(reason):                                                |
|        a. Build VirtualViewCallbackInfo with reason + bounds + scroll|
|        b. Invoke callback -> VirtualViewReturn { dom, sizes... }     |
|        c. update_virtual_view_info(scroll_size, virtual_scroll_size) |
|        d. mark_invoked(reason)                                       |
|        e. If dom is Some, replace VirtualViewPlaceholder with        |
|           VirtualView { child_dom_id, bounds, clip_rect } in the     |
|           parent's display list                                      |
|                                                                      |
|   3. ScrollManager records virtual_scroll_size / virtual_scroll_offset
|      so scrollbar geometry reflects the virtual content rectangle.   |
+----------------------------------------------------------------------+

The placeholder / replacement mechanism is documented in IFrame Scroll and Display Lists.

Latch reset rules

The latches in VirtualViewState prevent the same condition from firing every frame. The reset rules are:

  • virtual_view_was_invoked resets on force_reinvoke() or reset_all_invocation_flags().
  • invoked_for_current_expansion resets when container bounds grew (in check_reinvoke) or scroll_size grew (in update_virtual_view_info).
  • invoked_for_current_edge resets on force_reinvoke() or reset_all_invocation_flags().
  • last_edge_triggered.{top,bottom,left,right} resets on force_reinvoke() or reset_all_invocation_flags().

reset_all_invocation_flags is called from layout_and_generate_display_list after the layout cache is cleared. The child DOMs no longer exist in layout_results, so the callback must run again from scratch. force_reinvoke(dom_id, node_id) is the per-VirtualView equivalent used by trigger_virtual_view_rerender.

last_edge_triggered is not cleared when the user scrolls away from an edge. That's currently a deliberate choice. Once you've requested more content for the bottom, you don't want to re-request it every time the user scrolls back to the bottom. The trade-off is that callers must use force_reinvoke to allow the same edge to fire again.

Coordination with ScrollManager

The nested DOM has its own scroll frame in ScrollManager. When a callback returns virtual_scroll_size larger than scroll_size, the scroll manager's AnimatedScrollState.virtual_scroll_size is set to that value via set_virtual_scroll_size. Clamping logic for the nested DOM then uses it instead of content_rect.size, so the scrollbar can scroll past the actually-rendered content into the virtual area. When the user scrolls past scroll_offset + scroll_size, the next check_reinvoke call reads the new scroll_offset and detects the edge condition.

Hit-testing nested DOMs

A hit on a node inside a nested VirtualView DOM produces a HitTestItem.is_virtual_view_hit = Some((parent_dom_id, virtual_view_origin)). The dispatcher uses virtual_view_origin to translate viewport coordinates into the nested DOM's local frame before invoking callbacks. See Event System Internals for the full hit-test data flow.

Debug introspection

pub struct VirtualViewDebugInfo {
    pub parent_dom_id: usize,
    pub parent_node_id: usize,
    pub nested_dom_id: usize,
    pub scroll_size_width: Option<f32>,
    pub scroll_size_height: Option<f32>,
    pub virtual_scroll_size_width: Option<f32>,
    pub virtual_scroll_size_height: Option<f32>,
    pub was_invoked: bool,
    pub last_bounds_x: f32,
    pub last_bounds_y: f32,
    pub last_bounds_width: f32,
    pub last_bounds_height: f32,
}

pub fn get_all_virtual_view_infos(&self) -> Vec<VirtualViewDebugInfo>;
pub fn debug_counts(&self) -> (usize, usize);  // (states.len(), pipeline_ids.len())

debug_counts is read by AZ_E2E_TEST to assert that the manager's internal maps don't grow unboundedly across resize / scroll loops.

Constants and tuning

EDGE_THRESHOLD is 200.0 pixels. The threshold is hardcoded; per-VirtualView configuration would require a field on VirtualViewManager or a parameter to check_reinvoke. Mobile-density displays that want a smaller pre-fetch window currently have no knob.

See also

  • IFrame Scroll and Display Lists — how the placeholder is replaced and how the nested DOM is composited into the parent's pipeline.
  • Event System Internals — how the nested DOM enters the event pipeline and how the hit-test result distinguishes virtual-view children from regular nodes.
  • DOM Internals — the NodeType::VirtualView variant that drives the placeholder emission.

Coming Up Next

Back to guide index