Skip to content

A non-LazyItemFactory child collapses its whole subtree into one lazy item, so a nested ForEach is neither lazily evaluated nor lazily rendered #508

Description

@Aecasorg

A non-LazyItemFactory child collapses its whole subtree into one lazy item, so a ForEach nested one level down is neither lazily evaluated nor lazily rendered

Line references are against main @ c8332cb.

Provenance for every number below: arm64 Android emulator (API 35), debug build, our production driver app — not the linked repro — built against our fork of skip-ui pinned at 1.59.1004, which carries our own unmerged patches to Presentation.swift and ComposeExtensions.swift (PRs #499 / #505). Those patches do not touch the lazy path, but the build is not stock upstream and you should discount accordingly.

Mechanism

LazyVStack.Render evaluates its content and walks the result (Containers/LazyVStack.swift:69, :185-191):

for renderable in renderables {
    if let factory = renderable as? LazyItemFactory, factory.shouldProduceLazyItems() {
        factory.produceLazyItems(collector: itemCollector.value, modifiers: listOf(), level: 0)
    } else {
        itemCollector.value.item(renderable, 0)   // :189
    }
}

Exactly five types conform to LazyItemFactory: ForEach (ForEach.swift:10), ModifiedContent (pass-through, LazySupport.swift:113), LazyLevelRenderable (:45), LazySectionHeader (:78), LazySectionFooter (:96). Everything else takes the else branch.

VStack is : View, Renderable with no Evaluate override (VStack.swift:26), so View.Evaluate short-circuits on self as? Renderable and returns it whole (View/View.swift:68-69); its children are only evaluated later inside VStack.Render, which passes options: 0 and so discards lazyItemLevel (VStack.swift:53).

Net effect — the LazyColumn sees two items, and none of the 100 rows is ever a lazy item:

LazyVStack {
    header
    VStack {                      // ← one lazy item containing all N rows
        ForEach(lines) { row($0) }
    }
}

This is materially worse than the documented unroll case (README.md:2681), where each element still becomes its own item and rendering stays deferred. Here all N rows land in a single item slot and are composed and rendered together.

The same as? LazyItemFactory dispatch appears in LazyHStack.swift:157, LazyVGrid.swift:205, LazyHGrid.swift:175 and List.swift:381, so whatever is done presumably applies to all five.

What it cost us

Our order-detail sheet had exactly this shape — the ForEach sat inside a computed some View that wrapped its contents in a VStack. It is indistinguishable from the working version at the call site: same modifiers, same layout, no warning, identical rendering.

100-line list, tap → sheet content reaches the bottom of the screen (automated luminance probe over a screen recording, 40 ms sampling):

arm cold open n gfxinfo 99th-pct frame n
eager VStack (no lazy container) 1640 ms (800–4442) 11 1100 / 1100 / 1100 ms 3
LazyVStack, ForEach nested in a VStack 920 ms (840–1000) 6 1150 / 800 / 1150 ms 3
ForEach hoisted to a direct child ~540 ms (480–720) 11 850 / 200 / 200 ms 3
hoisted and one top-level view per element 400 ms (360–520) 6

Two honest caveats. The nested-lazy arm is no better than eager on frame time (1150 vs 1100 ms at n=3 — within noise) while being better on cold open; we previously described it to ourselves as "worse than no fix", which the frame data does not support. And the last two rows differ by more than the hoist — they also change per-row padding — so treat the deltas as the whole app-side change rather than the container alone.

What we are not asking for

Not for VStack/HStack/ZStack to conform to LazyItemFactory. That would diverge from SwiftUI, where a nested VStack inside a LazyVStack genuinely is not lazy. It would also break layout: ModifiedContent.produceLazyItems accumulates and forwards modifiers (LazySupport.swift:118-120) and the receiving factory applies them per item (ForEach.swift:208, :221, :231), so VStack { … }.padding().background(…) would paint per child, and the stack's own spacing/alignment would be replaced by the LazyColumn's verticalArrangement/horizontalAlignment (LazyVStack.swift:60-61, :127).

The gap we would like closed is the silence, not the semantics.

Suggestions

  1. A debug log at the decision point. ForEach.isUnrollRequired (ForEach.swift:195-203) already knows isLazy and renderables.size, and ForEach.Evaluate knows its element count — so a one-line debug message when a ForEach evaluates eagerly inside a lazy container is cheap and would have replaced weeks of measurement for us. (We had first suggested logging at LazyVStack.swift:189; that is the wrong place, because the opaque renderable has not been evaluated yet and the container cannot see what is inside it.)
  2. A documentation note on the five lazy containers naming what must be a direct child, cross-referencing the existing ForEach note at README.md:2681 the way the grid section does at :2724. Worth stating positively what is free, because it is generous and not obvious: Group forwards options verbatim (Group.swift:32-33), as do if/if let, multiple ViewBuilder statements, and extracted some View subviews. We kept our sheet's structure and moved one ForEach.

Happy to add a scene to https://github.com/Aecasorg/skip-fuse-perf-repro demonstrating it against stock upstream if that would help.

Sibling issue about the firstOrNull() discard in produceLazyItems filed separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions