Skip to content
Merged
6 changes: 6 additions & 0 deletions Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
}
var heightAnchor: NSLayoutConstraint?
var previousHeight: CGFloat?
var pendingContentSizeRetries: Int = 0

func setNeedsAnotherLayoutPass() {
view.needsLayout = true
updateScrollViewHeight()
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment on lines +130 to +133

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 👍 / 👎.


public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?) -> Void)? {
didSet { didCall = false }
Expand Down
6 changes: 6 additions & 0 deletions Sources/SnapshotPreviewsCore/ExpandingViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi

private var didCall = false
var previousHeight: CGFloat?
var pendingContentSizeRetries: Int = 0

var heightAnchor: NSLayoutConstraint?
private var widthAnchor: NSLayoutConstraint?
Expand Down Expand Up @@ -55,6 +56,11 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
heightAnchor = nil
widthAnchor = nil
previousHeight = nil
pendingContentSizeRetries = 0
}

func setNeedsAnotherLayoutPass() {
view.setNeedsLayout()
}

public func setupView(layout: PreviewLayout) {
Expand Down
27 changes: 27 additions & 0 deletions Sources/SnapshotPreviewsCore/ScrollExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,24 @@ protocol ScrollExpansionProviding: AnyObject, FirstScrollViewProviding {
var previousHeight: CGFloat? { get set }
var heightAnchor: NSLayoutConstraint? { get }
var supportsExpansion: Bool { get }
// Tracks how many times we've deferred completion waiting for the inner
// scroll view's contentSize to be populated. iOS 18 reordered hosting
// controller layout passes so the inner UIKit layout (where contentSize
// is assigned) can lag the outer viewDidLayoutSubviews — without this
// counter we'd race into complete() at one screen-height.
var pendingContentSizeRetries: Int { get set }
// Asks the host to schedule another layout pass. UIKit hosts implement
// this as view.setNeedsLayout(); AppKit / unsupported platforms can no-op.
func setNeedsAnotherLayoutPass()
}

extension ScrollExpansionProviding {
func setNeedsAnotherLayoutPass() {}
Comment thread
cursor[bot] marked this conversation as resolved.

func updateHeight(_ complete: (() -> Void)) {
// Cap on contentSize-not-laid-out retries before we give up and complete
// anyway. Prevents an infinite wait on a genuinely empty scroll view.
let maxPendingContentSizeRetries = 10
// If heightAnchor isn't set, this was a fixed size and we don't expand the scroll view
guard let heightAnchor else {
complete()
Expand All @@ -42,6 +56,19 @@ extension ScrollExpansionProviding {
let supportsExpansion = supportsExpansion
let scrollView = firstScrollView
if let scrollView, supportsExpansion {
// Layout-pass race: on the first viewDidLayoutSubviews the inner
// scroll view may not have laid out yet, so contentHeight is 0 and
// diff is -visibleHeight. Without this guard we'd take the else
// branch below and complete() at one screen-height. Defer until we
// actually see a contentSize, capped at maxPendingContentSizeRetries
// so a truly empty scroll view eventually completes.
guard previousHeight != nil
|| scrollView.contentHeight > 0
|| pendingContentSizeRetries >= maxPendingContentSizeRetries else {
pendingContentSizeRetries += 1
setNeedsAnotherLayoutPass()
return
}
Comment thread
cursor[bot] marked this conversation as resolved.
let diff = Int(scrollView.contentHeight - scrollView.visibleContentHeight)
if abs(diff) > 0 {
if previousHeight != nil || diff > 0 {
Expand Down