Skip to content

fix(browser): stop close() from re-opening the last URL on Android - #2611

Open
pramoth wants to merge 1 commit into
ionic-team:mainfrom
pramoth:fix/browser-close-android
Open

pramoth wants to merge 1 commit into
ionic-team:mainfrom
pramoth:fix/browser-close-android

Conversation

@pramoth

@pramoth pramoth commented Oct 15, 2026

Copy link
Copy Markdown

Description

On Android, Browser.close() can re-open the URL from the last Browser.open(), and sometimes leaves a BrowserControllerActivity that launched nothing stuck on top of the app. This PR fixes the controller's lifecycle handling so neither can happen:

  • BrowserControllerActivity.onCreate finishes at once when it was started with the close extra. close() only creates a new instance when the previous one has already been cleared off the task (by a deep link into the app's singleTask activity) but not destroyed yet, so there is nothing left to close.
  • The listener set by open() is one-shot: the controller that serves it clears it, so no later controller can run the same open() again.
  • onDestroy forgets only its own instance (onControllerDestroyed) and leaves the listener alone. By the time an old controller is destroyed, the listener may already belong to a newer open() whose controller hasn't been created yet; clearing it is what left that controller with nothing to launch.
  • open() sets the listener before startActivity(), and both statics are volatile, since they are written on the plugin thread and read on the main thread.

No public API changes: setBrowserControllerListener() and the browserControllerListener field are still there.

Fixes #2610

Change Type

  • Fix
  • Feature
  • Refactor
  • Breaking Change
  • Documentation
  • Other (CI, chores, etc.)

Rationale / Problems Fixed

This is the usual OAuth sign-in or sign-out flow: Browser.open() a URL that redirects back to the app's custom scheme, and call Browser.close() from appUrlOpen. When the redirect comes back while the Custom Tab is still starting (about 0.5 s on an emulator, which a fast or local auth server easily beats), the deep link clears the old controller off the task, but Android destroys it only later. A close() in that window creates a new controller. Its onCreate ignored the close extra and ran the still-set listener, which launched the last URL again. The app then receives every deep link twice; in our app, every Android sign-out sent the OIDC end-session request twice.

When the old controller's onDestroy cleared the listener between close() starting a new controller and that controller's onCreate, the new controller launched nothing and stayed on top of the app. It is translucent, so the app looks normal, but taps no longer reach it, the next Browser.open() opens nothing and its promise never settles, and only Back gets rid of it.

The full trace, with logcat, is in #2610.

Tests or Reproductions

Minimal repro: https://github.com/pramoth/capacitor-browser-close-repro. It uses a local 302 server plus adb reverse, so the redirect comes back fast enough; the steps are in its README.

On an API 35 emulator (Chrome 124), @capacitor/browser from this branch, installed via npm pack:

delay before close() taps appUrlOpen redirect-server GETs Chrome launches stuck controllers
8.0.5, 0 ms 10 20 20 30 0
this PR, 0 ms 20 20 20 20 0
this PR, 80 ms 20 20 20 20 0
this PR, 100 ms 20 20 20 20 0

With this PR, close() still landed in the window on 55 of the 60 taps: logcat shows it creating a new BrowserControllerActivity (result code=0), and that instance now finishes without launching anything. An earlier, longer run of the same patch on 8.0.5 gave 150 deep links for 150 taps at 0, 80 and 100 ms. Unpatched, we saw 2 stuck controllers in about 180 taps, both at 80–100 ms; they are too rare to show up in a 10-tap baseline. Stuck controllers were checked with dumpsys activity activities after every tap.

A normal Browser.close() of a Custom Tab that is still showing (https://capacitorjs.com) still closes it and returns to the app, and browserFinished fires once per close (3 of 3 rounds).

npm run verify:android and npm run eslint / prettier --check pass in browser/.

Screenshots / Media

Platforms Affected

  • Android
  • iOS
  • Web

Notes / Comments

iOS isn't affected: SFSafariViewController stays presented until close(), so there is no equivalent window.

#2095 (closed, no repro) describes a Browser.open() shortly after returning through a deep link that closes again at once, with a 1.5 s setTimeout as the workaround. That timing fits the same "old controller not destroyed yet" window, but I haven't verified that it's the same cause.

🤖 Generated with Claude Code

When a deep link into the app's singleTask activity clears the controller
off the task, it is destroyed only later. A close() in that window creates
a new BrowserControllerActivity, whose onCreate ran the open() listener
again and launched the last URL a second time. If the old controller's
onDestroy cleared the listener first, the new one launched nothing and
stayed on top of the app.

- finish at once in onCreate when started with the close extra
- make the open() listener one-shot
- onDestroy forgets only its own instance, not the listener
- set the listener before startActivity(), and make both statics volatile

Fixes ionic-team#2610
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.

bug(browser): Browser.close() on Android re-opens the last URL after a deep link back into the app

1 participant