Skip to content

fix: prevent duplicate onSPUIReady delivery from repeated rendering app events#649

Closed
sampaioroberto wants to merge 1 commit into
SourcePointUSA:developfrom
sampaioroberto:fix/generic-web-message-duplicate-loaded
Closed

fix: prevent duplicate onSPUIReady delivery from repeated rendering app events#649
sampaioroberto wants to merge 1 commit into
SourcePointUSA:developfrom
sampaioroberto:fix/generic-web-message-duplicate-loaded

Conversation

@sampaioroberto
Copy link
Copy Markdown

Problem

Crash reported via Firebase Crashlytics:

Application tried to present modally a view controller <ConsentViewController.GenericWebMessageViewController: 0x1318ffe00> that is already being presented by <HostApp.SomeViewController: 0x131913200>.

The same GenericWebMessageViewController instance was triggering delegate.onSPUIReady() more than once. Host apps following the documented pattern (present(controller, animated: true) inside onSPUIReady) would crash on the second call.

Root cause

SPJSReceiver.js converts a sp.showMessage (without fromPM) postMessage into an onMessageReady native event. If the rendering app fires this event more than once for the same view, the Swift side had no idempotency guard:

// before — no guard
func onMessageReady() {
    timeoutWorkItem.cancel()
    webview?.evaluateJavaScript(...)
    messageUIDelegate?.loaded(self)  // called N times if event fires N times
}

onPmReady() had a partial guard via isFirstLayerMessage, but that flag guards a state-machine transition (first layer → PM), not duplicate events on the same path.

Fix

Add a hasCalledLoaded boolean flag to GenericWebMessageViewController that ensures loaded() — and therefore onSPUIReady() — is delivered at most once per controller instance, regardless of how many JS events arrive.

private var hasCalledLoaded = false

func onMessageReady() {
    timeoutWorkItem.cancel()
    guard !hasCalledLoaded else { return }
    hasCalledLoaded = true
    webview?.evaluateJavaScript(...)
    messageUIDelegate?.loaded(self)
}

func onPmReady() {
    timeoutWorkItem.cancel()
    guard isFirstLayerMessage, !hasCalledLoaded else { return }
    hasCalledLoaded = true
    messageUIDelegate?.loaded(self)
}

The two guards are orthogonal: isFirstLayerMessage remains for its original purpose (suppress PM-ready when coming from a first-layer flow); hasCalledLoaded adds the missing idempotency invariant across both paths.

Test

Added DuplicatingRenderingAppMock — a WKWebView subclass that fires sp.showMessage twice in the same tick — and a regression test that asserts loadedCallCount == 1.

  • Before fix: loadedCallCount would be 2 → test fails
  • After fix: loadedCallCount is 1 → test passes ✅

All 5 tests in GenericWebMessageViewControllerSpec pass.

🤖 Generated with Claude Code

…pp events

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 <noreply@anthropic.com>
@sampaioroberto sampaioroberto marked this pull request as ready for review May 14, 2026 19:31
@andresilveirah
Copy link
Copy Markdown
Member

Hi @sampaioroberto thank you for the PR. I've made a small modification, instead of relying on a boolean flag, I check if the ViewController is currently being presented or its view is != nil. I've used your tests and created #650 . I'm closing this PR in favour of that one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants