diff --git a/Sources/FluidStack/Transition/BatchRemovingTransitionContext.swift b/Sources/FluidStack/Transition/BatchRemovingTransitionContext.swift index fc03e5f6c..6e1f31e1c 100644 --- a/Sources/FluidStack/Transition/BatchRemovingTransitionContext.swift +++ b/Sources/FluidStack/Transition/BatchRemovingTransitionContext.swift @@ -36,6 +36,7 @@ public final class BatchRemovingTransitionContext: TransitionContext { private let onCompleted: (BatchRemovingTransitionContext) -> Void private var childContexts: [ChildContext] = [] + private var hasNotifiedCompletion: Bool = false private var callbacks: [(CompletionEvent) -> Void] = [] init( @@ -56,6 +57,7 @@ public final class BatchRemovingTransitionContext: TransitionContext { public func notifyCompleted() { assert(Thread.isMainThread) + guard isInvalidated == false, isCompleted == false else { return } isCompleted = true onCompleted(self) } @@ -91,6 +93,8 @@ public final class BatchRemovingTransitionContext: TransitionContext { override func invalidate() { assert(Thread.isMainThread) isInvalidated = true + guard hasNotifiedCompletion == false else { return } + hasNotifiedCompletion = true callbacks.forEach { $0(.interrupted) } } @@ -106,6 +110,8 @@ public final class BatchRemovingTransitionContext: TransitionContext { Triggers ``addCompletionEventHandler(_:)`` with ``TransitionContext/CompletionEvent/succeeded`` */ func transitionSucceeded() { - callbacks.forEach{ $0(.succeeded) } + guard hasNotifiedCompletion == false else { return } + hasNotifiedCompletion = true + callbacks.forEach { $0(.succeeded) } } } diff --git a/Sources/FluidStack/Transition/RemovingTransitionContext.swift b/Sources/FluidStack/Transition/RemovingTransitionContext.swift index 4b2666283..2c80d1091 100644 --- a/Sources/FluidStack/Transition/RemovingTransitionContext.swift +++ b/Sources/FluidStack/Transition/RemovingTransitionContext.swift @@ -26,6 +26,7 @@ public final class RemovingTransitionContext: TransitionContext { private let onRequestedDisplayOnTop: (DisplaySource) -> FluidStackController.DisplayingOnTopSubscription + private var hasNotifiedCompletion: Bool = false private var callbacks: [(CompletionEvent) -> Void] = [] init( @@ -57,6 +58,7 @@ public final class RemovingTransitionContext: TransitionContext { assert(Thread.isMainThread) guard isInvalidated == false, isCompleted == false else { return } isInvalidated = true + hasNotifiedCompletion = true callbacks.forEach { $0(.cancelled) } onAnimationCompleted(self) } @@ -97,6 +99,8 @@ public final class RemovingTransitionContext: TransitionContext { override func invalidate() { assert(Thread.isMainThread) isInvalidated = true + guard hasNotifiedCompletion == false else { return } + hasNotifiedCompletion = true callbacks.forEach { $0(.interrupted) } } @@ -115,6 +119,8 @@ public final class RemovingTransitionContext: TransitionContext { Triggers ``addCompletionEventHandler(_:)`` with ``TransitionContext/CompletionEvent/succeeded`` */ func transitionSucceeded() { + guard hasNotifiedCompletion == false else { return } + hasNotifiedCompletion = true callbacks.forEach { $0(.succeeded) } } diff --git a/Sources/FluidStack/ViewController/FluidStackController.swift b/Sources/FluidStack/ViewController/FluidStackController.swift index 90e2582da..a41e4d120 100644 --- a/Sources/FluidStack/ViewController/FluidStackController.swift +++ b/Sources/FluidStack/ViewController/FluidStackController.swift @@ -718,13 +718,17 @@ open class FluidStackController: UIViewController { fromViewControllers: itemsToRemove.map(\.viewController), toViewController: targetTopItem?.viewController, onCompleted: { [weak self] context in - + assert(Thread.isMainThread) - + guard let self = self else { - return + return } - + + guard context.isInvalidated == false else { + return + } + /** Completion of transition, cleaning up */ diff --git a/Tests/FluidStackTests/FluidStackControllerTests.swift b/Tests/FluidStackTests/FluidStackControllerTests.swift index 0d1194a71..11a7b78a0 100644 --- a/Tests/FluidStackTests/FluidStackControllerTests.swift +++ b/Tests/FluidStackTests/FluidStackControllerTests.swift @@ -565,6 +565,68 @@ final class FluidStackControllerTests: XCTestCase { } + /// Tests that batch removing completion is called exactly once. + /// When fluidPop triggers batch removing (VC is not on top), + /// the completion must fire exactly once after the batch transition completes. + func testBatchRemovingCompletionCalledOnce() { + + let window = UIWindow() + let stack = FluidStackController() + window.rootViewController = stack + window.makeKeyAndVisible() + + let vc1 = UIViewController() + let vc2 = UIViewController() + let vc3 = UIViewController() + let vc4 = UIViewController() + + stack.addContentViewController(vc1, transition: .disabled) + stack.addContentViewController(vc2, transition: .disabled) + stack.addContentViewController(vc3, transition: .disabled) + stack.addContentViewController(vc4, transition: .disabled) + + XCTAssertEqual(stack.stackingViewControllers.count, 4) + + let exp = expectation(description: "completion called") + exp.assertForOverFulfill = true + var completionCallCount = 0 + + vc2.fluidPop { _ in + completionCallCount += 1 + exp.fulfill() + } + + wait(for: [exp], timeout: 2) + + XCTAssertEqual(stack.stackingViewControllers.count, 1) + XCTAssertEqual(completionCallCount, 1, "Completion should be called exactly once") + } + + /// Tests that removeAllViewController completion is called exactly once. + func testRemoveAllViewControllerCompletionCalledOnce() { + + let stack = FluidStackController() + + stack.addContentViewController(UIViewController(), transition: .disabled) + stack.addContentViewController(UIViewController(), transition: .disabled) + stack.addContentViewController(UIViewController(), transition: .disabled) + stack.addContentViewController(UIViewController(), transition: .disabled) + + XCTAssertEqual(stack.stackingViewControllers.count, 4) + + var completionCallCount = 0 + + stack.removeAllViewController( + transition: .disabled, + completion: { event in + completionCallCount += 1 + } + ) + + XCTAssertEqual(stack.stackingViewControllers.count, 1) + XCTAssertEqual(completionCallCount, 1, "Completion should be called exactly once") + } + final class ContentTypeOption: UIViewController { init(contentType: FluidStackContentConfiguration.ContentType) { super.init(nibName: nil, bundle: nil)