diff --git a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift index e9c3654..a810efb 100644 --- a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift @@ -115,6 +115,23 @@ final class AppKitContainer: NSHostingController, ScrollExpa } var heightAnchor: NSLayoutConstraint? var previousHeight: CGFloat? + var pendingContentSizeRetries: Int = 0 + var lastObservedContentHeight: CGFloat? + var pendingIntrinsicSizeRetries: Int = 0 + var lastObservedIntrinsicSize: CGSize? + + var hostFittingSize: CGSize? { + // NSView's `fittingSize` is the AppKit equivalent of UIKit's + // systemLayoutSizeFitting(compressedSize) and honors active layout + // constraints — same role in the settle loop. + view.fittingSize + } + + // No setNeedsAnotherLayoutPass override — AppKit isn't exercised by our + // CI (we run iOS simulator only), so we accept the protocol's no-op default + // and the AppKit settle loop falls through to immediate completion. Avoids + // having to maintain an AppKit-correct re-entry mechanism for code we + // don't actually run. public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?) -> Void)? { didSet { didCall = false } @@ -143,6 +160,11 @@ final class AppKitContainer: NSHostingController, ScrollExpa widthAnchor?.isActive = false heightAnchor = nil widthAnchor = nil + previousHeight = nil + pendingContentSizeRetries = 0 + lastObservedContentHeight = nil + pendingIntrinsicSizeRetries = 0 + lastObservedIntrinsicSize = nil } public func setupView(layout: PreviewLayout) { diff --git a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift index 8f8110c..65b6423 100644 --- a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift +++ b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift @@ -24,6 +24,10 @@ public final class ExpandingViewController: UIHostingController Void)) { + // Cap on contentSize-not-stabilized retries before we give up and complete + // anyway. Prevents an infinite wait on a genuinely empty / animating view. + let maxPendingContentSizeRetries = 10 + // Symmetric cap for the intrinsic-size settle loop. + let maxPendingIntrinsicSizeRetries = 10 // If heightAnchor isn't set, this was a fixed size and we don't expand the scroll view guard let heightAnchor else { complete() @@ -42,6 +78,21 @@ extension ScrollExpansionProviding { let supportsExpansion = supportsExpansion let scrollView = firstScrollView if let scrollView, supportsExpansion { + // Stabilization: scroll content can land at a partial height before its + // final size when SwiftUI hosting / UIKit child layout completes in + // multiple passes. We retry until we see the same non-zero contentHeight + // on two consecutive passes — a stable observation — before committing. + // Without this, we capture at intermediate layout states and produce + // run-to-run dimension drift on iOS 18 / Apple Silicon. + let currentContentHeight = scrollView.contentHeight + guard previousHeight != nil + || (currentContentHeight > 0 && lastObservedContentHeight == currentContentHeight) + || pendingContentSizeRetries >= maxPendingContentSizeRetries else { + lastObservedContentHeight = currentContentHeight + pendingContentSizeRetries += 1 + setNeedsAnotherLayoutPass() + return + } let diff = Int(scrollView.contentHeight - scrollView.visibleContentHeight) if abs(diff) > 0 { if previousHeight != nil || diff > 0 { @@ -62,6 +113,27 @@ extension ScrollExpansionProviding { complete() } } else { + // Non-scroll path. UIHostingController's two-pass layout can also 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. Wait until two consecutive passes report the same + // fitting size before committing. + guard let fittingSize = hostFittingSize, + fittingSize.width > 0 || fittingSize.height > 0 else { + // Either the host doesn't expose a fitting size, or it returned + // (noIntrinsicMetric, noIntrinsicMetric)-equivalent zeros — there's + // nothing meaningful to wait on, complete immediately. + complete() + return + } + guard lastObservedIntrinsicSize == fittingSize + || pendingIntrinsicSizeRetries >= maxPendingIntrinsicSizeRetries else { + lastObservedIntrinsicSize = fittingSize + pendingIntrinsicSizeRetries += 1 + setNeedsAnotherLayoutPass() + return + } complete() } }