Skip to content

Keep bridge destinations active while attached#253

Open
svara wants to merge 2 commits into
mainfrom
bridge-delegate-destination-lifecycle
Open

Keep bridge destinations active while attached#253
svara wants to merge 2 commits into
mainfrom
bridge-delegate-destination-lifecycle

Conversation

@svara

@svara svara commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Bridge destinations should remain eligible to receive bridge messages for as long as their web view is still attached to the bridge.

This PR changes BridgeDelegate so destinationIsActive is derived from bridge ownership (bridge != nil) instead of UIKit visibility callbacks. A destination can now receive viewDidDisappear and still process bridge messages when its web view is still active, which is the case for tab bar/container-controller flows.

The bug

BridgeDelegate previously treated UIKit visibility as bridge activity:

  • onViewDidLoad, onViewWillAppear, and onViewDidAppear set destinationIsActive = true.
  • onViewDidDisappear set destinationIsActive = false.
  • bridgeDidReceiveMessage dropped every message unless destinationIsActive was true and the message URL matched the current destination URL.

That makes viewDidDisappear a hard bridge cutoff. But UIKit visibility is not the same thing as web view ownership.

In tab bar/container-controller setups, a VisitableViewController can receive normal UIKit disappearance callbacks while its web view remains active and attached. If the web page sends a one-shot bridge message during that window, such as a component connect, native ignores it as an "inactive" destination even though the bridge is still the correct communication channel.

Root cause

destinationIsActive mixed two different concepts:

  1. Visibility lifecycle: UIKit view callbacks used to notify already-initialized components about presentation changes.
  2. Bridge lifecycle: whether this delegate is still attached to the Bridge for the active web view and can send/receive messages.

bridgeDidReceiveMessage needs the second concept. Using the first creates a race where a valid bridge message can be dropped after viewDidDisappear but before the web view/bridge has actually been deactivated.

The fix

  • Stop mutating destinationIsActive from view lifecycle callbacks.

  • Stop mutating destinationIsActive from webViewDidBecomeActive(_:) / webViewDidBecomeDeactivated() directly.

  • Make destinationIsActive a computed bridge-ownership check:

    private var destinationIsActive: Bool {
        bridge != nil
    }
  • Keep webViewDidBecomeDeactivated() as the real deactivation point by clearing the bridge delegate and niling out the bridge reference.

  • Keep activeComponents guarded by destinationIsActive, but make the guard explicit so components are exposed only while replies can still go through the bridge.

View lifecycle callbacks still forward viewDidLoad, viewWillAppear, viewDidAppear, viewWillDisappear, and viewDidDisappear to initialized components. They just no longer decide whether future bridge messages are accepted.

Behavior

Scenario Before After
Destination appears with active bridge messages accepted messages accepted
Destination receives viewDidDisappear while web view remains attached, e.g. tab/container lifecycle messages dropped messages accepted
Web view becomes deactivated messages dropped messages dropped
Bridge is missing for a web view messages dropped messages dropped

@svara
svara marked this pull request as ready for review July 7, 2026 14:39
@joemasilotti

Copy link
Copy Markdown
Member

Is this a fix for non-active tabs to receive bridge messages? Or screens "under" modals? Or something else entirely?

I'm just trying to wrap my head around what the screens look like when the bug appears.

@svara

svara commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Is this a fix for non-active tabs to receive bridge messages? Or screens "under" modals? Or something else entirely?

I'm just trying to wrap my head around what the screens look like when the bug appears.

This fixes the issue where a VisitableViewController is embedded inside a HotwireTabBarController. In this setup, when switching tabs, UIKit sends viewDidDisappear for a VisitableViewController(this is correct and excepted). The view controller forwards the callbacks to the BridgeDelegate which marks the destination as inactive (destinationIsActive = false). However, that view controller's web view is still the active web view (because each tab has its own Navigator instance). So the view controller still "owns" the bridge, and the bridge is active, but UIKit visibility callbacks temporarily disable the BridgeDelegate causing the messages to be dropped. That's the mismatch.

One way to reproduce the issue in the demo app is to slow down a bridge enabled page response:

# app/controllers/components_controller.rb
def overflow
  sleep 1.5
end

Switch to the Bridge Components tab, open the Overflow menu example, and then quickly switch to another tab. Switch bar to the Bridge Components tab, and the overflow icon will be missing and/or not working.

@joemasilotti

Copy link
Copy Markdown
Member

Got it, thanks @svara!

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants