invertase / invertase/react-native-google-mobile-ads
BannerAd leaks one Activity per configuration change (no BACK, no backgrounding) — `onDropViewInstance` never runs, OOM in ~5 minutes
- Dominant language
- TypeScript
- Stars
- 1k
- Forks
- 229
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 7
Description
**Version:** react-native-google-mobile-ads 16.3.4 · RN 0.86.3 · Expo SDK 57 · targetSdk 36 ·
reproduced on API 34 and API 36 emulators
### What happens
A `` mounted in an app whose Activity is recreated by a **configuration change** leaks
the entire Activity, once per change, without bound. On a stock Expo manifest (`configChanges`
does not include `fontScale`), changing the system font size or display size is enough:
```
settings put system font_scale 2.0
settings put system font_scale 1.0 # repeat, ~3.5s apart
```
Measured on a plain banner-only app, sampling `dumpsys meminfo` (2 config changes per cycle):
| cycle | Activities | Views | WebViews | Java heap |
|---:|---:|---:|---:|---:|
| 0 | 1 | 94 | 3 | 7.8 MB |
| 20 | 41 | 917 | 43 | 46.7 MB |
| 80 | 158 | 3546 | 156 | 172.5 MB |
| 90 | — | — | — | **process dead** |
```
FATAL EXCEPTION: main
java.lang.OutOfMemoryError: Failed to allocate a 32 byte allocation ...
target footprint 201326592, growth limit 201326592
Force finishing activity /.MainActivity
```
Control: same 50 cycles with the network off (no ad renders) leaves the Java heap flat
(8.3 → 10.5 MB), so it is the ad path.
### Why the usual workaround does not apply
The documented mitigation for the sibling BACK-exit leak is to unmount `` when
`AppState` leaves `active`. That works for backgrounding and we verified it (BACK-exit relaunch is
flat at 2 Activities over 20 cycles on the same build).
It cannot work here. A configuration change destroys and recreates the Activity **while the app
stays in the foreground**, so `AppState` never changes; and even where the Activity does pause, the
JS-side unmount is asynchronous and `onDestroy` arrives first. Either way
`onDropViewInstance()` — the only place `setAdListener(null)` + `destroy()` happen — never runs.
### Retention chain (from an HPROF, not inferred)
60 leaked `MainActivity` instances, all reachable from one process-wide GMA static:
```
static com.google.android.gms.internal.ads.zzfuj.zza
→ ArrayList → zzftp → zzftn → ad WebView (zzclc/zzckv/zzdvq)
→ mParent → FrameLayout → com.google.android.gms.ads.AdView
├─ .zza → zzek.zzp → ReactNativeGoogleMobileAdsBannerAdViewManager$1.val$reactViewGroup
└─ .mContext
→ io.invertase.googlemobileads.common.ReactNativeAdView.mContext
→ com.facebook.react.uimanager.ThemedReactContext.mBase
→ MainActivity
```
The important part is that there is **more than one path**. We severed them one at a time, then all
at once, and the leak rate never changed — the last three attempts produced a byte-identical
measurement (`cycle 10: Activities=21`, same as the unfixed build):
1. `setAdListener(null)` + `destroy()` from `Activity.onDestroy()` — removed the `zzek.zzp` path;
the heap then showed `AdView.mContext` holding the Activities directly. Leak unchanged.
2. Building the ad view from the application context instead of the Activity
(`ReactNativeGoogleMobileAdsBannerAdViewManager` ~L218) — removed `AdView.mContext`; the heap
went back to showing the listener path. Leak unchanged, ads still served normally.
3. `destroy()` plus `removeView()` from the parent, to cut `mParent` as well. Leak unchanged.
4. **All three cuts applied simultaneously** — reflective `setAdListener(null)`, `destroy()` +
`removeView()`, and the application-context patch — with the banner confirmed still serving
(`WebViews` > 0 throughout, so this is not a "the ads stopped" false pass). Leak unchanged,
identical to attempts 2 and 3 and to the unfixed baseline.
Severing all three paths at once changing nothing is the part we think matters: as long as the
SDK's static pool keeps the view subtree reachable, it finds another route into the React tree.
We do not think there is an app-side fix.
`ReactNativeAdView` is constructed from `ThemedReactContext` (`createViewInstance`), and
`ThemedReactContext.mBase` **is** the Activity — so the React wrapper carries the Activity
regardless of what the leaf `AdView` holds, for as long as the SDK's static pool keeps the subtree
reachable.
### Suggested direction
The library needs a teardown that does not depend on React noticing the unmount — e.g. registering
an `Application.ActivityLifecycleCallbacks` and running the existing `onDropViewInstance` teardown
for any ad view belonging to an Activity being destroyed. An app cannot do this reliably from the
outside because the retention is multi-path and partly through the library's own view wrapper.
### Possibly related
#496 (closed) reports a banner memory leak on 12.6.0, but through a different trigger — a React
unmount where the banner is conditionally rendered. This one needs no unmount and no navigation:
the Activity is destroyed underneath a mounted banner, so the JS side never learns about it and
`onDropViewInstance` is never called at all.
### Repro harness
Happy to share the driver: it sets `font_scale`, samples `dumpsys meminfo` to a table, watches for
process death, and drives the BACK-exit trigger too so the two can be told apart.
Contributor guide
Research direction
Start with ReactNativeGoogleMobileAdsBannerAdViewManager around L218 and ReactNativeAdView, then trace the existing onDropViewInstance teardown. Use the provided font-scale repro and dumpsys meminfo measurements to validate Activity retention across configuration changes. Done means destroyed Activities are released without relying on a React unmount, while banners continue serving.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, react-native
- Domain
- mobile-dev, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100