From d846795d4fa6fc615183099c2b5df617a86a1254 Mon Sep 17 00:00:00 2001 From: Roberto Sampaio Date: Thu, 14 May 2026 15:36:55 -0300 Subject: [PATCH] fix: prevent duplicate onSPUIReady delivery from repeated rendering app events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenericWebMessageViewController had no idempotency guard on onMessageReady() and onPmReady(), so a rendering app firing sp.showMessage more than once for the same instance caused loaded() — and ultimately delegate.onSPUIReady() — to be called multiple times. Host apps following the documented pattern of present(controller) in onSPUIReady would crash with "tried to present a view controller already being presented". Adds a hasCalledLoaded flag that ensures loaded() is delivered at most once per controller instance regardless of how many JS events arrive. Includes a regression test (DuplicatingRenderingAppMock) that fires sp.showMessage twice and asserts loadedCallCount == 1. Co-Authored-By: Claude Sonnet 4.6 --- .../iOS/SPWebMessageViewController.swift | 9 ++++-- .../GenericWebMessageViewControllerSpec.swift | 29 +++++++++++++++++++ .../MessageUIDelegateMock.swift | 2 ++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ConsentViewController/Classes/Views/iOS/SPWebMessageViewController.swift b/ConsentViewController/Classes/Views/iOS/SPWebMessageViewController.swift index 7cf6f9b8c..3fb261577 100644 --- a/ConsentViewController/Classes/Views/iOS/SPWebMessageViewController.swift +++ b/ConsentViewController/Classes/Views/iOS/SPWebMessageViewController.swift @@ -178,6 +178,7 @@ import WebKit } var isFirstLayerMessage = true + private var hasCalledLoaded = false lazy var timeoutWorkItem: DispatchWorkItem = { DispatchWorkItem { [weak self] in @@ -288,14 +289,16 @@ import WebKit func onMessageReady() { timeoutWorkItem.cancel() + guard !hasCalledLoaded else { return } + hasCalledLoaded = true webview?.evaluateJavaScript("window.spLegislation=\"\(self.campaignType.rawValue)\"") messageUIDelegate?.loaded(self) } func onPmReady() { timeoutWorkItem.cancel() - if isFirstLayerMessage { - messageUIDelegate?.loaded(self) - } + guard isFirstLayerMessage, !hasCalledLoaded else { return } + hasCalledLoaded = true + messageUIDelegate?.loaded(self) } } diff --git a/Example/ConsentViewController_ExampleTests/GenericWebMessageViewControllerSpec.swift b/Example/ConsentViewController_ExampleTests/GenericWebMessageViewControllerSpec.swift index f63dda709..960ca853c 100644 --- a/Example/ConsentViewController_ExampleTests/GenericWebMessageViewControllerSpec.swift +++ b/Example/ConsentViewController_ExampleTests/GenericWebMessageViewControllerSpec.swift @@ -33,6 +33,28 @@ func renderingAppMock(messageReadyDelayInSeconds: Int) -> String { """ } +// Simulates a rendering app that fires sp.showMessage twice in sequence for the same message, +// reproducing the crash: "Application tried to present a view controller already being presented." +class DuplicatingRenderingAppMock: WKWebView { + override func load(_ request: URLRequest) -> WKNavigation? { + loadHTMLString(""" + +
+ +
+ + + """, baseURL: URL(string: "https://example.com")!) + } +} + class FaultyRenderingAppMock: WKWebView { override func load(_ request: URLRequest) -> WKNavigation? { loadHTMLString( @@ -100,6 +122,13 @@ class GenericWebMessageViewControllerSpec: QuickSpec { expect(delegate.onErrorWasCalled).to(beFalse()) } + it("calls loaded exactly once even if the rendering app fires sp.showMessage twice") { + loadMessage(with: DuplicatingRenderingAppMock.self, delegate: delegate) + // Wait until at least one loaded call arrives, then verify no duplicate was delivered. + expect(delegate.loadedCallCount).toEventually(beGreaterThanOrEqualTo(1), timeout: .seconds(15)) + expect(delegate.loadedCallCount).to(equal(1)) + } + it("calls onError if .loaded() is not called on the delegate before the timeout") { loadMessage(with: FaultyRenderingAppMock.self, delegate: delegate, timeout: 2.0) expect(delegate.onErrorWasCalled).toEventually(beTrue(), timeout: .seconds(10)) diff --git a/Example/ConsentViewController_ExampleTests/MessageUIDelegateMock.swift b/Example/ConsentViewController_ExampleTests/MessageUIDelegateMock.swift index 37702cd53..fb2a3b528 100644 --- a/Example/ConsentViewController_ExampleTests/MessageUIDelegateMock.swift +++ b/Example/ConsentViewController_ExampleTests/MessageUIDelegateMock.swift @@ -11,12 +11,14 @@ import Foundation class MessageUIDelegateSpy: SPMessageUIDelegate { var loadedWasCalled = false + var loadedCallCount = 0 var onErrorWasCalled = false var actionCalledWith: SPAction? var onLoaded: ((UIViewController?) -> Void)? func loaded(_ controller: UIViewController) { loadedWasCalled = true + loadedCallCount += 1 onLoaded?(controller) }