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 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.)
- 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.
A non-
LazyItemFactorychild collapses its whole subtree into one lazy item, so aForEachnested one level down is neither lazily evaluated nor lazily renderedLine 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 toPresentation.swiftandComposeExtensions.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.Renderevaluates its content and walks the result (Containers/LazyVStack.swift:69,:185-191):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 theelsebranch.VStackis: View, Renderablewith noEvaluateoverride (VStack.swift:26), soView.Evaluateshort-circuits onself as? Renderableand returns it whole (View/View.swift:68-69); its children are only evaluated later insideVStack.Render, which passesoptions: 0and so discardslazyItemLevel(VStack.swift:53).Net effect — the
LazyColumnsees two items, and none of the 100 rows is ever a lazy item: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? LazyItemFactorydispatch appears inLazyHStack.swift:157,LazyVGrid.swift:205,LazyHGrid.swift:175andList.swift:381, so whatever is done presumably applies to all five.What it cost us
Our order-detail sheet had exactly this shape — the
ForEachsat inside a computedsome Viewthat wrapped its contents in aVStack. 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):
gfxinfo99th-pct frameVStack(no lazy container)LazyVStack,ForEachnested in aVStackForEachhoisted to a direct childTwo 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/ZStackto conform toLazyItemFactory. That would diverge from SwiftUI, where a nestedVStackinside aLazyVStackgenuinely is not lazy. It would also break layout:ModifiedContent.produceLazyItemsaccumulates and forwards modifiers (LazySupport.swift:118-120) and the receiving factory applies them per item (ForEach.swift:208,:221,:231), soVStack { … }.padding().background(…)would paint per child, and the stack's ownspacing/alignmentwould be replaced by theLazyColumn'sverticalArrangement/horizontalAlignment(LazyVStack.swift:60-61,:127).The gap we would like closed is the silence, not the semantics.
Suggestions
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.)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 inproduceLazyItemsfiled separately.