Skip to content

fix(android): skip Cordova lifecycle events when the page has no bridge - #8638

Draft
theproducer wants to merge 1 commit into
mainfrom
claude/capacitor-8637-guard-cordova-lifecycle-events
Draft

theproducer wants to merge 1 commit into
mainfrom
claude/capacitor-8637-guard-cordova-lifecycle-events

Conversation

@theproducer

Copy link
Copy Markdown
Contributor

Root cause

MockCordovaWebViewImpl.triggerDocumentEvent(String) (android/capacitor/src/main/java/com/getcapacitor/cordova/MockCordovaWebViewImpl.java) evaluated

eval("window.Capacitor.triggerEvent('" + eventName + "', 'document');", (s) -> {});

with no check that the page holds the bridge. Bridge.onPause() / Bridge.onResume() always forward to this (the only guard is isInitialized(), which is true for the whole life of the activity), so a pause or resume reaches whatever document the WebView currently holds.

That document is not guaranteed to have window.Capacitor:

  • A non-app origin. Bridge.loadWebView() registers the bridge script with WebViewCompat.addDocumentStartJavaScript(webView, …, Collections.singleton(allowedOrigin)), where allowedOrigin is derived from appUrl only. Any other origin the WebView navigates to — e.g. an appAllowNavigation host used for an OAuth hand-off — runs without the bridge. This is the shape reported in Breaking [Bug]: Capacitor is undefined, Capacitor.triggerEvent is not a function in allowNavigation routes #7957.
  • The server.errorPath page. WebViewLocalServer.handleLocalRequest returns the error page from the isLocalFile(...) || isErrorUrl(...) branch, which never calls jsInjector.getInjectedStream(...). Note this only strips the bridge on a WebView without DOCUMENT_START_SCRIPT, or when server.url puts the app on a different origin from the error page — on a current WebView with no server.url the error page is same-origin with appUrl and does get the script.
  • A load still in flight, including a reload after the renderer was killed. This is the Android counterpart of the iOS case fixed in fix(ios): do not forward scene lifecycle events to the page before it has loaded #8595.

The resulting TypeError is raised inside the page, so it only surfaces through BridgeWebChromeClient.onConsoleMessage as the Capacitor/Console line in the report. No functional breakage beyond the lost Cordova pause/resume on a page that could not have consumed them anyway.

The change

Guard the call inside the evaluated snippet and log a debug line when the event is dropped. Dropping rather than deferring matches #8595: a page without the bridge has nothing to pause or resume, and a deferred resume after a fresh load would make the page react to a transition it never went through. pause/resume on a loaded app page are unchanged.

The snippet stays ES5 (window.Capacitor && window.Capacitor.triggerEvent) rather than optional chaining, because android.minWebViewVersion allows WebViews older than the Chrome 80 that introduced ?..

How it was tested

New instrumented test android/capacitor/src/androidTest/java/com/getcapacitor/android/CordovaLifecycleEventTest.java, on a Pixel 9a API 36 emulator:

  • skipsLifecycleEventsWhenBridgeIsAbsent — loads about:blank into the bridge's WebView (asserting typeof window.Capacitor === 'undefined' first, so the test cannot pass vacuously), pumps onPause() / onResume(), and fails if the console records a triggerEvent error. Before the change it fails with the exact reported string:

    java.lang.AssertionError: lifecycle event reached a page with no bridge:
      ERROR: Uncaught TypeError: Cannot read properties of undefined (reading 'triggerEvent')
    

    After the change it passes, and Logcat shows D Capacitor: Skipped the 'pause' document event: no Capacitor bridge on the current page.

  • stillDeliversLifecycleEventsWhenBridgeIsPresent — installs a window.Capacitor.triggerEvent stub and asserts pause:document and resume:document both still arrive.

Suites run:

  • ./gradlew lint build test -b capacitor/build.gradle — BUILD SUCCESSFUL, "Lint found no new issues".
  • Full instrumented suite via adb am instrument — OK (5 tests) (CordovaLifecycleEventTest ×2, HttpInterceptorNavigationTest ×2, ExampleInstrumentedTest).
  • prettier with @ionic/prettier-config — both touched files clean.

connectedAndroidTest could not be used directly because the UTP host dependencies were not in the local Gradle cache; the APK was built with Gradle and driven with adb am instrument instead, which runs the same AndroidJUnitRunner.

What a reviewer should check by hand

  1. A real end-to-end background/foreground on a device, on an app page, to confirm Cordova plugins still see pause/resume — the test asserts this against a JS stub, not against core/native-bridge.ts's real triggerEvent.
  2. The server.errorPath path with server.url set, which is the configuration where the error page is genuinely cross-origin from the bridge script. I traced it but did not build that configuration.
  3. Whether the same guard is wanted on Bridge.triggerJSEvent(...) (Bridge.java:887-893), which has the identical unguarded pattern. It is public API called explicitly by plugins rather than by the lifecycle, so I left it alone — say the word and I will follow up.
  4. Whether iOS should also guard on bridge presence in addition to the load-state guard from fix(ios): do not forward scene lifecycle events to the page before it has loaded #8595, since the non-app-origin case applies there too.

Related

MockCordovaWebViewImpl.triggerDocumentEvent evaluated
window.Capacitor.triggerEvent(...) unconditionally. The document in the
WebView is not guaranteed to carry the bridge: the injected script is
scoped to the app origin, so an appAllowNavigation origin, the
server.errorPath page (WebViewLocalServer.handleLocalRequest returns the
error page without running the JS injector) and a load still in flight
all run without window.Capacitor. Backgrounding or foregrounding the app
on such a page raised an uncaught TypeError inside the page, surfacing
only as:

  Capacitor/Console: ... Uncaught TypeError: Cannot read properties of
  undefined (reading 'triggerEvent')

Guard the call in the evaluated snippet and log a debug line when the
event is dropped. Pause/resume on a loaded page is unchanged. This is the
Android counterpart of #8595, which guarded the iOS scene lifecycle
events on the page's load state.

Covered by a new instrumented test that reproduces the console error
before the change and asserts the events still reach a page that does
have the bridge.

Closes #8637

This branch has not been deployed

No deployments
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.

1 participant