Skip to content

Fix layout-pass timing race in ScrollExpansion.updateHeight#5

Merged
dfed merged 8 commits into
mainfrom
dfed--default-core-animation
May 1, 2026
Merged

Fix layout-pass timing race in ScrollExpansion.updateHeight#5
dfed merged 8 commits into
mainfrom
dfed--default-core-animation

Conversation

@dfed

@dfed dfed commented May 1, 2026

Copy link
Copy Markdown

Why

ScrollExpansion.updateHeight has a layout-pass timing race that surfaces on iOS 18 / Apple Silicon hosting-controller layout-pass reorderings. Drift investigation on the apple-side consumer traced ~50 files of regen drift to this race and confirmed it persists on iOS 26 too — same layout pipeline, just lands "lucky" more often.

The race surfaces three ways:

  1. Inner UIScrollView.contentSize lags the outer viewDidLayoutSubviews — captures land at one viewport height instead of the expanded content height.
  2. Non-scroll intrinsic size lagsUIHostingController.intrinsicContentSize propagates across multiple passes; captures sometimes bail before the second pass lands.
  3. Safe-area inset propagation is async — late-arriving safe-area insets translate captured content vertically (HomeFeaturePhotoUploadViewController showed ~150 px run-to-run translation).

Plus a related fourth case: SwiftUI @State mutations triggered in viewDidAppear propagate after the first viewDidLayoutSubviews window — settling on those passes locks in transient EmptyView / partial content (canary: SelfRewardClaimViewController width collapsed 1418 → 786 in some captures because the a11y legend depended on content the body wasn't yet rendering).

iOS 26 hits the same races but lands "lucky" more often, so its regen counts are smaller. The bug is shared.

What changed

Stabilization-based settle loop in ScrollExpansion.updateHeight:

  • Scroll path: wait until two consecutive passes report the same non-zero contentHeight before committing. New pendingContentSizeRetries + lastObservedContentHeight on the protocol.
  • Non-scroll path: same pattern, but observed via view.systemLayoutSizeFitting(...) (UIKit) or view.fittingSize (AppKit). New pendingIntrinsicSizeRetries + lastObservedIntrinsicSize + hostFittingSize. Returns nil (immediate completion) for views that don't have a meaningful intrinsic size.
  • Cap at 10 retries per axis; the existing 30s ExpandingViewController timeout remains the ultimate safety net.

Safe-area pin in ExpandingViewController.init:

view.insetsLayoutMarginsFromSafeArea = false
view.directionalLayoutMargins = .zero

So late-arriving safe-area insets can't translate captured content vertically. AppKit's safe-area model is window-level, so no AppKit-side change.

viewDidAppear gate for the UIKit settle loop. viewDidLayoutSubviews no longer invokes the settle loop directly; instead, viewDidAppear flips a didAppear flag and kicks off the first pass. SwiftUI @State propagation triggered in viewDidAppear lands before we measure. Reset in removeConstraints so the host VC can be reused.

AppKit: only the protocol-required stored properties are added on AppKitContainer. No setNeedsAnotherLayoutPass override — the protocol's no-op default takes over, and the AppKit settle loop falls through to immediate completion. We don't exercise AppKit in our CI (iOS simulator only) and don't want to maintain an AppKit-correct re-entry mechanism for code we don't actually run.

Platform setNeedsAnotherLayoutPass on UIKit (ExpandingViewController): view.setNeedsLayout() — relies on UIKit's natural pipeline to re-invoke viewDidLayoutSubviews. State is reset in removeConstraints.

Related upstream work

EmergeTools/SnapshotPreviews-iOS#183 (open since 2024-08-26, never merged) identified the inner UIScrollView.contentSize race but proposed only a single synchronous firstScrollView.layoutIfNeeded() flush. That isn't sufficient on iOS 18 when the SwiftUI host hasn't propagated state yet — the multi-pass retry approach handles that case. This PR additionally addresses the non-scroll intrinsic-size race, the safe-area inset race, and the viewDidAppear-driven @State propagation race.

Comment thread Sources/SnapshotPreviewsCore/View+Snapshot.swift Outdated
Comment thread Sources/SnapshotPreviewsCore/ScrollExpansion.swift
Comment thread Sources/SnapshotPreviewsCore/ScrollExpansion.swift
ExpandingViewController.viewDidLayoutSubviews kicks off updateHeight()
on every outer hosting-controller layout pass. iOS 18 reordered those
passes so the inner UIKit/SwiftUI layout (where the inner UIScrollView's
contentSize gets assigned) can lag the first outer viewDidLayoutSubviews
non-deterministically. When that happens, contentHeight is 0,
diff = -visibleHeight, previousHeight is still nil — the existing guard
takes the else branch and complete() fires while the snapshot is still
exactly one screen-height tall.

Defer completion when previousHeight == nil and contentHeight == 0:
bump a retry counter, ask the host to schedule another layout pass via
view.setNeedsLayout(), and return without completing. Cap the counter
at 10 so a genuinely empty scroll view still terminates (and the
existing 30s ExpandingViewController timeout remains the ultimate
safety net). AppKitContainer gets the no-op default since this race is
UIKit-specific.

Related upstream work (unmerged):
getsentry#183 (open) attempted to flush the inner
layout synchronously via firstScrollView.layoutIfNeeded(). That flush
isn't sufficient on iOS 18 when the SwiftUI host hasn't propagated the
pass yet — the multi-pass retry approach handles that case.
@dfed dfed force-pushed the dfed--default-core-animation branch from 721f88a to 78128f5 Compare May 1, 2026 12:25
@dfed dfed changed the title Default snapshot capture to the Core Animation path Fix layout-pass timing race in ScrollExpansion.updateHeight May 1, 2026
dfed added 2 commits May 1, 2026 05:33
- AppKitContainer overrides setNeedsAnotherLayoutPass to re-dispatch
  updateScrollViewHeight on the next runloop tick. The default no-op
  inherited from the protocol extension would hang the AppKit pipeline
  because updateViewConstraints is not re-triggered by needsLayout
  flags alone.
- Refactor the contentSize-not-laid-out retry in updateHeight from an
  if/return into a guard with the negated condition, per the project's
  no-early-return-from-if rule.
Drop the runloop hop and the [weak self]. Setting needsLayout and
synchronously re-entering matches the UIKit pattern; the retry counter
in updateHeight bounds recursion at 10.
Comment thread Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift Outdated
dfed and others added 4 commits May 1, 2026 06:00
Previously the retry path returned as soon as contentHeight > 0, which
let us commit at a partial height when SwiftUI hosting / UIKit child
layout completes in multiple passes (a known iOS 18 race). Now we keep
retrying until two consecutive passes report the same contentHeight —
a stable observation — before proceeding. This eliminates a class of
run-to-run dimension drift on iPhone 16 / iOS 18 captures where the
inner scroll view advanced through several intermediate heights.

Also adds previousHeight / pendingContentSizeRetries reset to AppKit's
removeConstraints to match UIKit's hygiene.
…parent wrapper

Two additional layout-pass timing flake classes that the existing
contentHeight settle loop didn't catch:

1. Non-scroll intrinsic-content-size race
   UIHostingController's two-pass layout can drop intrinsic content
   size into the host view *after* the first viewDidLayoutSubviews —
   same race as the scroll-view path, but observed via
   systemLayoutSizeFitting / fittingSize instead of contentSize.
   Affected views: HomeFeatureLoreEntryViewController,
   HomeFeatureNotebookEntryViewController, etc.

   Add pendingIntrinsicSizeRetries / lastObservedIntrinsicSize +
   hostFittingSize on ScrollExpansionProviding. When firstScrollView
   is nil, wait for two consecutive identical fitting-size
   observations before completing — symmetric with the scroll-view
   path, capped at 10 retries. Falls through to immediate completion
   when the host has no useful intrinsic size (zero fitting size).

2. Safe-area inset resolution timing
   UIHostingController resolves safeAreaInsets across multiple
   layout passes (the host's insets propagate from the window /
   scene asynchronously), translating rendered content vertically
   run-to-run on layouts that read directionalLayoutMargins (e.g.
   HomeFeaturePhotoUploadViewController/Viewfinder showed ~150px
   vertical drift). Pin both insetsLayoutMarginsFromSafeArea = false
   and directionalLayoutMargins = .zero in ExpandingViewController
   init to eliminate that source of drift.

AppKit gets the symmetric intrinsic-size additions (fittingSize as
hostFittingSize, retry-state reset in removeConstraints). NSWindow's
safeAreaInsets only apply to full-content layout windows and the
BorderlessWindow used here doesn't trigger that, so no AppKit
safe-area wrapper is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit, the settle loop was driven from viewDidLayoutSubviews,
which fires before viewDidAppear. SwiftUI @State / @published mutations set
in viewDidAppear (a common pattern: viewModel.state = .someValue once the
view is on screen) only propagate to the view tree after that, so the
settle loop could observe a transient EmptyView / partial-content state,
declare it stable, and lock in the wrong dimensions before the real
content rendered.

The canary was SelfRewardClaimViewController/Default/snapshot_a11y.png
collapsing 1418 → 786 in some captures (a11y legend strip was absent
because the SwiftUI body returned EmptyView while viewModel.state was
still nil). FriendChatViewController/Default/snapshot_a11y.png showed
the same family at smaller magnitude (24px height drift).

Now updateScrollViewHeight is only called once viewDidAppear has fired.
The retry-on-instability loop continues to run via setNeedsLayout →
next viewDidLayoutSubviews, but only after the first viewDidAppear
trigger. didAppear is reset in removeConstraints so the host VC can be
reused for subsequent previews without leaking state.

This is a UIKit-only change — AppKit's lifecycle and the failure mode
(SwiftUI hosting controller layout pass reordering on iOS 18) are
iOS-specific.
@dfed dfed marked this pull request as ready for review May 1, 2026 18:15
@dfed dfed self-assigned this May 1, 2026
We don't exercise AppKit in our CI (iOS simulator only), so maintaining
a synchronous-recursion implementation that has to coordinate AppKit
layout pipeline correctness is out of scope. The protocol's no-op
default lets the AppKit settle loop fall through to immediate
completion. Cursor flagged a real bug (recursion exhausts retries
without actual layout) but the right fix is 'don't try to handle
AppKit at all' rather than maintain a correct AppKit implementation.
The protocol-required stored properties stay so AppKit still compiles.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5194478503

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +130 to +133
func setNeedsAnotherLayoutPass() {
view.needsLayout = true
updateScrollViewHeight()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer AppKit settle retries to a future layout pass

setNeedsAnotherLayoutPass() marks the view dirty and then immediately calls updateScrollViewHeight(), which re-enters updateHeight synchronously before AppKit has a chance to run another layout cycle. That means the new stabilization counters can burn through all retries in one call stack while reading the same stale size, then complete early with an unstable height. This is most visible when host/scroll sizes only converge after a later run-loop layout pass.

Useful? React with 👍 / 👎.

@dfed dfed merged commit 93c8cc9 into main May 1, 2026
1 check passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 79608ca. Configure here.

// systemLayoutSizeFitting(compressedSize) and honors active layout
// constraints — same role in the settle loop.
view.fittingSize
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppKit hostFittingSize override prevents intended immediate completion

High Severity

AppKitContainer overrides hostFittingSize to return view.fittingSize (non-nil, typically non-zero), but its setNeedsAnotherLayoutPass() is a no-op (protocol default). The comment on lines 130–134 states the settle loop "falls through to immediate completion," which only works if hostFittingSize returns nil (the protocol extension default). With a real value returned, the non-scroll settle loop defers on its first observation (since lastObservedIntrinsicSize is nil ≠ fittingSize) and calls the no-op, so updateScrollViewHeight() may never be re-invoked — hanging the rendered callback indefinitely. The same issue affects the scroll path's new pendingContentSizeRetries guard.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 79608ca. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant