From 7b428feeae542a9c7a403598bf038dceef9a26c4 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 15 Sep 2026 10:43:11 -0700 Subject: [PATCH 1/4] feat(core): Freeze UIActivityIndicatorView spinners during snapshots Disabling UIView and SwiftUI animations does not stop UIActivityIndicatorView, which drives its own layer animation. Indeterminate spinners, including the default SwiftUI ProgressView, were captured mid-rotation and produced flaky diffs. Swizzle startAnimating to a no-op from RenderingStrategy.setup so every rendering path skips spinners. Because hidesWhenStopped defaults to true, the indicator is hidden rather than frozen at a fixed frame. This matches the behavior the hosted Emerge runner previously injected outside the library. --- README.md | 2 ++ .../ActivityIndicatorFreezing.swift | 22 +++++++++++++++++++ .../RenderingStrategy.swift | 3 +++ .../ActivityIndicatorFreezingTests.swift | 17 ++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift create mode 100644 Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift diff --git a/README.md b/README.md index 2906eab8..9aeed058 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ Display names should be unique within each `PreviewProvider`, or within a file w Snapshot previews should be deterministic. Avoid live network calls, timers, animations that do not settle, locale-dependent data, and dates generated from the current clock. Prefer fixed fixtures and mocked dependencies so the same preview renders the same pixels in Xcode, local test runs, and CI. +During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. + ### Detecting the snapshot environment Set `SNAPSHOTS_RUNNING_FOR_PREVIEWS=1` in your unit test scheme to mirror the variable Xcode sets when rendering live previews. You can then disable preview-unfriendly behavior (logging, analytics, network calls) with a single check: diff --git a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift new file mode 100644 index 00000000..2acb4df4 --- /dev/null +++ b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift @@ -0,0 +1,22 @@ +#if canImport(UIKit) +import UIKit +import ObjectiveC + +enum ActivityIndicatorFreezing { + private static let installed: Void = { + guard + let original = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.startAnimating)), + let replacement = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.emg_frozenStartAnimating)) + else { return } + method_exchangeImplementations(original, replacement) + }() + + static func install() { + _ = installed + } +} + +private extension UIActivityIndicatorView { + @objc func emg_frozenStartAnimating() {} +} +#endif diff --git a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift index 15704733..92783f70 100644 --- a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift @@ -68,6 +68,9 @@ private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as extension RenderingStrategy { static func setup() { + #if canImport(UIKit) + ActivityIndicatorFreezing.install() + #endif testHandler?.perform(NSSelectorFromString("setup")) } } diff --git a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift new file mode 100644 index 00000000..e88cfbcf --- /dev/null +++ b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift @@ -0,0 +1,17 @@ +#if canImport(UIKit) +import XCTest +import UIKit +@testable import SnapshotPreviewsCore + +final class ActivityIndicatorFreezingTests: XCTestCase { + @MainActor + func testStartAnimatingIsNoOpAfterRenderingSetup() { + UIKitRenderingStrategy.setup() + + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + + XCTAssertFalse(indicator.isAnimating) + } +} +#endif From 64c784dcbcf970ab04e6a4924346a3435645d9f6 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 15 Sep 2026 10:56:17 -0700 Subject: [PATCH 2/4] feat(core): Add opt-out for spinner freezing and exclude watchOS Set EMERGE_DISABLE_FREEZE_SPINNERS=1 to keep UIActivityIndicatorView animating, mirroring the existing EMERGE_DISABLE_FIX_TIME opt-out for the clock hook. watchOS ships UIKit without UIActivityIndicatorView, so guard the swizzle the same way other UIKit-only code in this target does. Mark the replacement method dynamic to follow the standard swizzling idiom. --- README.md | 2 +- .../ActivityIndicatorFreezing.swift | 9 +++++++-- Sources/SnapshotPreviewsCore/RenderingStrategy.swift | 2 +- .../ActivityIndicatorFreezingTests.swift | 10 +++++++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9aeed058..149c1edc 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ Display names should be unique within each `PreviewProvider`, or within a file w Snapshot previews should be deterministic. Avoid live network calls, timers, animations that do not settle, locale-dependent data, and dates generated from the current clock. Prefer fixed fixtures and mocked dependencies so the same preview renders the same pixels in Xcode, local test runs, and CI. -During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. +During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. Set `EMERGE_DISABLE_FREEZE_SPINNERS=1` in the test environment to opt out. ### Detecting the snapshot environment diff --git a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift index 2acb4df4..bf4eb7de 100644 --- a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift +++ b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift @@ -1,4 +1,4 @@ -#if canImport(UIKit) +#if canImport(UIKit) && !os(watchOS) import UIKit import ObjectiveC @@ -11,12 +11,17 @@ enum ActivityIndicatorFreezing { method_exchangeImplementations(original, replacement) }() + static func isEnabled(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["EMERGE_DISABLE_FREEZE_SPINNERS"] != "1" + } + static func install() { + guard isEnabled() else { return } _ = installed } } private extension UIActivityIndicatorView { - @objc func emg_frozenStartAnimating() {} + @objc dynamic func emg_frozenStartAnimating() {} } #endif diff --git a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift index 92783f70..0a5c9816 100644 --- a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift @@ -68,7 +68,7 @@ private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as extension RenderingStrategy { static func setup() { - #if canImport(UIKit) + #if canImport(UIKit) && !os(watchOS) ActivityIndicatorFreezing.install() #endif testHandler?.perform(NSSelectorFromString("setup")) diff --git a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift index e88cfbcf..9c7e9698 100644 --- a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift +++ b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift @@ -1,4 +1,4 @@ -#if canImport(UIKit) +#if canImport(UIKit) && !os(watchOS) import XCTest import UIKit @testable import SnapshotPreviewsCore @@ -13,5 +13,13 @@ final class ActivityIndicatorFreezingTests: XCTestCase { XCTAssertFalse(indicator.isAnimating) } + + func testFreezingIsEnabledByDefault() { + XCTAssertTrue(ActivityIndicatorFreezing.isEnabled(environment: [:])) + } + + func testFreezingIsDisabledByEnvironmentVariable() { + XCTAssertFalse(ActivityIndicatorFreezing.isEnabled(environment: ["EMERGE_DISABLE_FREEZE_SPINNERS": "1"])) + } } #endif From 9889f672d2ac16c3dbb7bc6d52da45a4467c81aa Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 15 Sep 2026 11:09:15 -0700 Subject: [PATCH 3/4] test(demo): Add loading indicator preview to MultiModuleDemo Add a ModuleA preview containing an indeterminate ProgressView so the CI snapshot upload renders a spinner case. With spinner freezing in place the image contains only the label, and any regression shows up as a diff. --- .../MultiModuleDemo/ModuleA/ModuleAViews.swift | 17 +++++++++++++++++ .../ModuleFilterAssertionTests.swift | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift b/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift index e07d1c76..c302e802 100644 --- a/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift +++ b/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift @@ -29,6 +29,19 @@ public struct ModuleALabel: View { } } +public struct ModuleALoadingIndicator: View { + public init() {} + + public var body: some View { + VStack(spacing: 12) { + ProgressView() + Text("Loading") + .font(.subheadline) + } + .padding() + } +} + #Preview("ModuleA Button") { ModuleAButton() } @@ -42,3 +55,7 @@ public struct ModuleALabel: View { ModuleALabel() .preferredColorScheme(.dark) } + +#Preview("ModuleA Loading Indicator") { + ModuleALoadingIndicator() +} diff --git a/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift b/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift index e534a7f6..30f82807 100644 --- a/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift +++ b/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift @@ -14,7 +14,7 @@ final class ModuleFilterAssertionTests: XCTestCase { excludedModules: nil ) - XCTAssertEqual(previews.count, 8) + XCTAssertEqual(previews.count, 9) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleA.") }) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleB.") }) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleC.") }) From 9ee063c59218dfbb61c40dbc54c6dc0364130523 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 15 Sep 2026 12:14:55 -0700 Subject: [PATCH 4/4] ref(core): Use snapshot-specific spinner control names Replace company-branded names in the new spinner opt-out and swizzled selector. Keep the Objective-C hook product-specific to avoid collisions, and update the environment-variable test and documentation together. --- README.md | 2 +- .../SnapshotPreviewsCore/ActivityIndicatorFreezing.swift | 6 +++--- .../ActivityIndicatorFreezingTests.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 149c1edc..9ce6b866 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ Display names should be unique within each `PreviewProvider`, or within a file w Snapshot previews should be deterministic. Avoid live network calls, timers, animations that do not settle, locale-dependent data, and dates generated from the current clock. Prefer fixed fixtures and mocked dependencies so the same preview renders the same pixels in Xcode, local test runs, and CI. -During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. Set `EMERGE_DISABLE_FREEZE_SPINNERS=1` in the test environment to opt out. +During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. Set `SNAPSHOTS_DISABLE_FREEZE_SPINNERS=1` in the test environment to opt out. ### Detecting the snapshot environment diff --git a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift index bf4eb7de..2dfd136c 100644 --- a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift +++ b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift @@ -6,13 +6,13 @@ enum ActivityIndicatorFreezing { private static let installed: Void = { guard let original = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.startAnimating)), - let replacement = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.emg_frozenStartAnimating)) + let replacement = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.snapshotPreviews_frozenStartAnimating)) else { return } method_exchangeImplementations(original, replacement) }() static func isEnabled(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { - environment["EMERGE_DISABLE_FREEZE_SPINNERS"] != "1" + environment["SNAPSHOTS_DISABLE_FREEZE_SPINNERS"] != "1" } static func install() { @@ -22,6 +22,6 @@ enum ActivityIndicatorFreezing { } private extension UIActivityIndicatorView { - @objc dynamic func emg_frozenStartAnimating() {} + @objc dynamic func snapshotPreviews_frozenStartAnimating() {} } #endif diff --git a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift index 9c7e9698..290f9500 100644 --- a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift +++ b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift @@ -19,7 +19,7 @@ final class ActivityIndicatorFreezingTests: XCTestCase { } func testFreezingIsDisabledByEnvironmentVariable() { - XCTAssertFalse(ActivityIndicatorFreezing.isEnabled(environment: ["EMERGE_DISABLE_FREEZE_SPINNERS": "1"])) + XCTAssertFalse(ActivityIndicatorFreezing.isEnabled(environment: ["SNAPSHOTS_DISABLE_FREEZE_SPINNERS": "1"])) } } #endif