A non-LazyItemFactory child collapses its whole subtree into one lazy item, so a nested ForEach is neither lazily evaluated nor lazily rendered
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 330
- Forks
- 76
- Avg merge
- 3h 6m
- Merged PRs (30d)
- 1
Description
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
- A debug log at the decision point.
ForEach.isUnrollRequired(ForEach.swift:195-203) already knowsisLazyandrenderables.size, andForEach.Evaluateknows its element count — so a one-line debug message when aForEachevaluates eagerly inside a lazy container is cheap and would have replaced weeks of measurement for us. (We had first suggested logging atLazyVStack.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.) - A documentation note on the five lazy containers naming what must be a direct child, cross-referencing the existing
ForEachnote atREADME.md:2681the way the grid section does at:2724. Worth stating positively what is free, because it is generous and not obvious:Groupforwardsoptionsverbatim (Group.swift:32-33), as doif/if let, multiple ViewBuilder statements, and extractedsome Viewsubviews. We kept our sheet's structure and moved oneForEach.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with LazyVStack.Render and the lazy-item dispatch in LazyVStack.swift, then compare the corresponding paths in LazyHStack.swift, LazyVGrid.swift, LazyHGrid.swift, and List.swift. Read ForEach.isUnrollRequired and ForEach.Evaluate to establish the available decision point; done should be an agreed diagnostic or documentation change that makes nested eager evaluation discoverable without changing stack semantics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100