From 64fbb7baabcc35f3531beabf166eae7df853da5f Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Mon, 8 Jun 2026 17:16:11 -0500 Subject: [PATCH 1/4] fix: defer reentrant view hierarchy updates --- .../Sources/BlueprintView/BlueprintView.swift | 41 ++++++-- BlueprintUI/Tests/BlueprintViewTests.swift | 93 +++++++++++++++++++ Package.swift | 5 +- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index c934db457..6f1fd57f8 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -33,6 +33,7 @@ public final class BlueprintView: UIView { /// Used to detect reentrant updates private var isInsideUpdate: Bool = false + private var hasScheduledDeferredViewHierarchyUpdate: Bool = false private let rootController: NativeViewController @@ -364,13 +365,40 @@ public final class BlueprintView: UIView { invalidateIntrinsicContentSize() sizesThatFit.removeAll() - if needsViewHierarchyUpdate { return } + if needsViewHierarchyUpdate { + if isInsideUpdate { + scheduleDeferredViewHierarchyUpdate() + } + + return + } needsViewHierarchyUpdate = true /// We use `UIView`'s layout pass to actually perform a hierarchy update. /// If a manual update is required, call `layoutIfNeeded()`. - setNeedsLayout() + if isInsideUpdate { + scheduleDeferredViewHierarchyUpdate() + } else { + setNeedsLayout() + } + } + + @MainActor + private func scheduleDeferredViewHierarchyUpdate() { + guard hasScheduledDeferredViewHierarchyUpdate == false else { return } + + hasScheduledDeferredViewHierarchyUpdate = true + + Task { @MainActor [weak self] in + guard let self else { return } + + hasScheduledDeferredViewHierarchyUpdate = false + + guard needsViewHierarchyUpdate else { return } + + setNeedsLayout() + } } @MainActor @@ -386,10 +414,10 @@ public final class BlueprintView: UIView { guard needsViewHierarchyUpdate || bounds != lastViewHierarchyUpdateBounds else { return } - precondition( - !isInsideUpdate, - "Reentrant updates are not supported in BlueprintView. Ensure that view events from within the hierarchy are not synchronously triggering additional updates." - ) + if isInsideUpdate { + setNeedsViewHierarchyUpdate() + return + } isInsideUpdate = true @@ -830,4 +858,3 @@ extension BlueprintView.NativeViewController { } } } - diff --git a/BlueprintUI/Tests/BlueprintViewTests.swift b/BlueprintUI/Tests/BlueprintViewTests.swift index 0ee4aa674..75f424275 100755 --- a/BlueprintUI/Tests/BlueprintViewTests.swift +++ b/BlueprintUI/Tests/BlueprintViewTests.swift @@ -227,6 +227,99 @@ class BlueprintViewTests: XCTestCase { XCTAssertTrue(layoutRecursed) } + func test_safeAreaChangeDuringUpdateDefersNestedUpdate() { + let viewController = UIViewController() + let view = BlueprintView(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + + viewController.view.frame = view.bounds + viewController.view.addSubview(view) + + let window = UIWindow(frame: view.bounds) + window.rootViewController = viewController + window.makeKeyAndVisible() + + let safeAreaInvalidation = SafeAreaInvalidation() + + struct SafeAreaInvalidatingElement: Element { + weak var viewController: UIViewController? + let safeAreaInvalidation: SafeAreaInvalidation + + var content: ElementContent { + ElementContent(measuring: Spacer(width: 1, height: 1)) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + SafeAreaInvalidatingView.describe { config in + config.builder = { + SafeAreaInvalidatingView( + frame: context.bounds, + viewController: viewController, + safeAreaInvalidation: safeAreaInvalidation + ) + } + + config.apply { view in + view.viewController = viewController + view.safeAreaInvalidation = safeAreaInvalidation + } + } + } + } + + final class SafeAreaInvalidation { + var hasInvalidatedSafeArea = false + } + + final class SafeAreaInvalidatingView: UIView { + weak var viewController: UIViewController? + var safeAreaInvalidation: SafeAreaInvalidation + + init( + frame: CGRect, + viewController: UIViewController?, + safeAreaInvalidation: SafeAreaInvalidation + ) { + self.viewController = viewController + self.safeAreaInvalidation = safeAreaInvalidation + super.init(frame: frame) + } + + required init?(coder: NSCoder) { fatalError() } + + override func didMoveToWindow() { + super.didMoveToWindow() + invalidateSafeAreaOnce() + } + + override func layoutSubviews() { + super.layoutSubviews() + invalidateSafeAreaOnce() + } + + private func invalidateSafeAreaOnce() { + guard + safeAreaInvalidation.hasInvalidatedSafeArea == false, + window != nil, + let viewController + else { + return + } + + safeAreaInvalidation.hasInvalidatedSafeArea = true + + viewController.additionalSafeAreaInsets.bottom += 1 + viewController.view.setNeedsLayout() + viewController.view.layoutIfNeeded() + } + } + + view.element = SafeAreaInvalidatingElement( + viewController: viewController, + safeAreaInvalidation: safeAreaInvalidation + ) + view.layoutIfNeeded() + } + func test_baseEnvironment() { enum TestValue { case defaultValue diff --git a/Package.swift b/Package.swift index b122d6e25..9641fc3e9 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,10 @@ let package = Package( ), .testTarget( name: "BlueprintUITests", - dependencies: ["BlueprintUI"], + dependencies: [ + "BlueprintUI", + "BlueprintUICommonControls", + ], path: "BlueprintUI/Tests" ), .target( From 1216a0c0fe86a9c0b8cd6d1ed1bcc3c2fea0dbd0 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Thu, 11 Jun 2026 15:53:12 -0500 Subject: [PATCH 2/4] Add forced-layout regression test, changelog, and comments Add a regression test mirroring the navigation-bar crash shape from production: a view callback replaces `element` and forces a synchronous layout pass while the view hierarchy update is still in progress. The test crashes on the reentrant-update precondition without the deferral fix, and additionally asserts that the deferred update converges (the replacement element's backing view is applied on a following main run loop turn). Also add the CHANGELOG entry, document why the deferred re-arm is required (an immediate setNeedsLayout re-arm would prevent a forcing layoutIfNeeded from terminating while the in-flight update still holds isInsideUpdate), and update the lifecycle-callback comment that still referenced the removed precondition. Co-Authored-By: Claude Fable 5 --- .../Sources/BlueprintView/BlueprintView.swift | 16 ++++- BlueprintUI/Tests/BlueprintViewTests.swift | 60 +++++++++++++++++++ CHANGELOG.md | 2 + 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 6f1fd57f8..b9921dd8f 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -384,6 +384,14 @@ public final class BlueprintView: UIView { } } + /// Schedules a coalesced view hierarchy update on a following main run loop turn. + /// + /// Used when an invalidation arrives while an update is already in progress — for example, + /// UIKit keyboard and safe-area callbacks can fire synchronously mid-update and force a + /// layout pass. Re-arming with `setNeedsLayout()` immediately would let a forcing + /// `layoutIfNeeded()` re-dirty this view while the in-flight update still holds + /// `isInsideUpdate`, preventing that forced layout from ever terminating, so the re-arm + /// is deferred until the current update has unwound. @MainActor private func scheduleDeferredViewHierarchyUpdate() { guard hasScheduledDeferredViewHierarchyUpdate == false else { return } @@ -415,6 +423,9 @@ public final class BlueprintView: UIView { guard needsViewHierarchyUpdate || bounds != lastViewHierarchyUpdateBounds else { return } if isInsideUpdate { + // A reentrant update was triggered from within the in-flight update, e.g. by a + // safe-area or keyboard change synchronously forcing a layout pass. Defer it so + // the current update can finish; the deferred pass picks up the latest state. setNeedsViewHierarchyUpdate() return } @@ -485,8 +496,9 @@ public final class BlueprintView: UIView { /// We intentionally deliver our lifecycle callbacks (eg, `onAppear`, /// `onDisappear`, etc, _after_ we've marked our view as updated. - /// This is in case the `onAppear` callback triggers a re-render, - /// we don't hit our recurisve update precondition. + /// This way, if an `onAppear` callback triggers a re-render, it is + /// applied synchronously on the next layout pass rather than being + /// treated as a reentrant update and deferred. for callback in updateResult.lifecycleCallbacks { callback() diff --git a/BlueprintUI/Tests/BlueprintViewTests.swift b/BlueprintUI/Tests/BlueprintViewTests.swift index 75f424275..ab314385f 100755 --- a/BlueprintUI/Tests/BlueprintViewTests.swift +++ b/BlueprintUI/Tests/BlueprintViewTests.swift @@ -320,6 +320,52 @@ class BlueprintViewTests: XCTestCase { view.layoutIfNeeded() } + func test_elementChangeAndForcedLayoutDuringUpdateDefersNestedUpdate() { + let view = BlueprintView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + + var didTriggerReentrantUpdate = false + + // Mirrors the navigation-bar crash shape: while the view is mid-update, a view + // callback replaces `element` and forces a synchronous layout pass. + view.element = CallbackElement { + guard !didTriggerReentrantUpdate else { return } + didTriggerReentrantUpdate = true + + view.element = IdentifiedElement(identifier: "replacement") + view.setNeedsLayout() + view.layoutIfNeeded() + } + + view.layoutIfNeeded() + + XCTAssertTrue(didTriggerReentrantUpdate) + + // The deferred update applies the replacement element on a following + // main run loop turn. + let deadline = Date(timeIntervalSinceNow: 5) + + while findView(withAccessibilityIdentifier: "replacement", in: view) == nil, Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + view.layoutIfNeeded() + } + + XCTAssertNotNil(findView(withAccessibilityIdentifier: "replacement", in: view)) + } + + private func findView(withAccessibilityIdentifier identifier: String, in root: UIView) -> UIView? { + if root.accessibilityIdentifier == identifier { + return root + } + + for subview in root.subviews { + if let match = findView(withAccessibilityIdentifier: identifier, in: subview) { + return match + } + } + + return nil + } + func test_baseEnvironment() { enum TestValue { case defaultValue @@ -939,6 +985,20 @@ private struct TestContainer: Element { } } +private struct IdentifiedElement: Element { + var identifier: String + + var content: ElementContent { + ElementContent(intrinsicSize: .zero) + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + UIView.describe { config in + config[\.accessibilityIdentifier] = identifier + } + } +} + private struct CallbackElement: Element { var onViewDescriptionApplied: () -> Void diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e476030..3e8c22e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `BlueprintView` now defers view hierarchy updates that are triggered while an update is already in progress, instead of crashing with a "Reentrant updates are not supported" precondition failure. Reentrant invalidations (e.g. keyboard or safe-area changes that synchronously force a layout pass mid-update) are coalesced and applied on a following main run loop turn. + ### Added ### Removed From bd980ae9f52eb9b3c061b2dc94bdcf37bf8dd783 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Tue, 16 Jun 2026 10:36:31 -0500 Subject: [PATCH 3/4] Add deferred update diagnostics --- .../Sources/BlueprintView/BlueprintView.swift | 44 ++++++++++++++++++- BlueprintUI/Sources/Internal/Logger.swift | 25 +++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index b9921dd8f..7f42df561 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -34,6 +34,10 @@ public final class BlueprintView: UIView { /// Used to detect reentrant updates private var isInsideUpdate: Bool = false private var hasScheduledDeferredViewHierarchyUpdate: Bool = false +#if DEBUG + private var consecutiveDeferredViewHierarchyUpdateCount = 0 + private static let maximumConsecutiveDeferredViewHierarchyUpdateCount = 10 +#endif private let rootController: NativeViewController @@ -397,18 +401,52 @@ public final class BlueprintView: UIView { guard hasScheduledDeferredViewHierarchyUpdate == false else { return } hasScheduledDeferredViewHierarchyUpdate = true + recordDeferredViewHierarchyUpdate() Task { @MainActor [weak self] in guard let self else { return } hasScheduledDeferredViewHierarchyUpdate = false - guard needsViewHierarchyUpdate else { return } + guard needsViewHierarchyUpdate else { + resetDeferredViewHierarchyUpdateCount() + return + } setNeedsLayout() } } + private func recordDeferredViewHierarchyUpdate() { + Logger.logDeferredViewHierarchyUpdate(view: self) + +#if DEBUG + consecutiveDeferredViewHierarchyUpdateCount += 1 + + guard consecutiveDeferredViewHierarchyUpdateCount >= Self.maximumConsecutiveDeferredViewHierarchyUpdateCount else { + return + } + + Logger.logExcessiveDeferredViewHierarchyUpdates( + view: self, + count: consecutiveDeferredViewHierarchyUpdateCount + ) + + assertionFailure( + """ + BlueprintView deferred \(consecutiveDeferredViewHierarchyUpdateCount) consecutive reentrant view hierarchy updates. + Check for view callbacks that synchronously invalidate and force layout during Blueprint view updates. + """ + ) +#endif + } + + private func resetDeferredViewHierarchyUpdateCount() { +#if DEBUG + consecutiveDeferredViewHierarchyUpdateCount = 0 +#endif + } + @MainActor private func updateViewHierarchyIfNeeded() { MainActor.preconditionIsolated( @@ -504,6 +542,10 @@ public final class BlueprintView: UIView { callback() } + if hasScheduledDeferredViewHierarchyUpdate == false { + resetDeferredViewHierarchyUpdateCount() + } + Logger.logViewUpdateEnd(view: self) let viewUpdateEndTime = CACurrentMediaTime() diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..bd5625cd1 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -72,6 +72,31 @@ extension Logger { ) } + static func logDeferredViewHierarchyUpdate(view: BlueprintView) { + + guard BlueprintLogging.isEnabled else { return } + + os_signpost( + .event, + log: .active, + name: "Deferred View Hierarchy Update", + signpostID: OSSignpostID(log: .active, object: view), + "Deferred reentrant view hierarchy update for %{public}s", + view.name ?? "BlueprintView" + ) + } + + static func logExcessiveDeferredViewHierarchyUpdates(view: BlueprintView, count: Int) { + + os_log( + .fault, + log: .blueprint, + "BlueprintView deferred %{public}d consecutive reentrant view hierarchy updates for %{public}s", + count, + view.name ?? "BlueprintView" + ) + } + static func logSizeThatFitsStart( view: BlueprintView, description: @autoclosure () -> String From b23b3f14e4d4eab3829246051411a00b914e4515 Mon Sep 17 00:00:00 2001 From: Rob MacEachern Date: Tue, 16 Jun 2026 10:59:25 -0500 Subject: [PATCH 4/4] Format deferred update diagnostics --- .../Sources/BlueprintView/BlueprintView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 7f42df561..64d6b1bc6 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -34,10 +34,10 @@ public final class BlueprintView: UIView { /// Used to detect reentrant updates private var isInsideUpdate: Bool = false private var hasScheduledDeferredViewHierarchyUpdate: Bool = false -#if DEBUG + #if DEBUG private var consecutiveDeferredViewHierarchyUpdateCount = 0 private static let maximumConsecutiveDeferredViewHierarchyUpdateCount = 10 -#endif + #endif private let rootController: NativeViewController @@ -420,7 +420,7 @@ public final class BlueprintView: UIView { private func recordDeferredViewHierarchyUpdate() { Logger.logDeferredViewHierarchyUpdate(view: self) -#if DEBUG + #if DEBUG consecutiveDeferredViewHierarchyUpdateCount += 1 guard consecutiveDeferredViewHierarchyUpdateCount >= Self.maximumConsecutiveDeferredViewHierarchyUpdateCount else { @@ -438,13 +438,13 @@ public final class BlueprintView: UIView { Check for view callbacks that synchronously invalidate and force layout during Blueprint view updates. """ ) -#endif + #endif } private func resetDeferredViewHierarchyUpdateCount() { -#if DEBUG + #if DEBUG consecutiveDeferredViewHierarchyUpdateCount = 0 -#endif + #endif } @MainActor