invertase / invertase/react-native-google-mobile-ads
[🐛] iOS: RewardedAd EARNED_REWARD and CLOSED events never reach JS while ad remains fully foregrounded (SDK 13.5.0)
- Dominant language
- TypeScript
- Stars
- 1k
- Forks
- 229
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 7
Description
### What happened?
On a physical iPhone development build, `RewardedAdEventType.EARNED_REWARD` and `AdEventType.CLOSED` are never delivered to JavaScript for a rewarded ad, even though Google's own native test-mode UI visually confirms the reward was granted ("Reward granted" banner), and even in a controlled reproduction where the app never leaves the foreground and never backgrounds. `AdEventType.LOADED`, `RewardedAdEventType.LOADED`, and all interstitial ad events (`loaded`, `shown`, `dismissed`) work correctly on the same build/device using the same wrapper library, so the native↔JS ad-event bridge is not broken in general — the failure appears specific to the rewarded ad's post-load event delivery.
#### Reproduction (foreground-only, no backgrounding)
1. Load and show a rewarded test ad (`ca-app-pub-3940256099942544/1712485313`, Google's official iOS rewarded test unit) via `RewardedAd.createForAdRequest` + `addAdEventListener`.
2. Watch the ad play. Google's native test-mode UI shows the "Reward granted" confirmation banner.
3. Do **not** interact with the end card, do **not** background the app, do **not** lock the screen.
4. Wait 15+ seconds while fully foregrounded.
**Expected:** `RewardedAdEventType.EARNED_REWARD` fires in JS at or shortly after step 2.
**Actual:** No event of any kind fires after `LOADED`. Custom diagnostic logging placed inside every `addAdEventListener` callback confirms zero JS invocations for `EARNED_REWARD`, `CLOSED`, or `ERROR` for the remainder of the session — the app was left running for several minutes with no further events. Sequence (trimmed to relevant lines):
```
attempt_start (currentAppState: active)
rewarded_ad_loaded
rewarded_ad_started
... app remains foregrounded, no AppState transitions occur ...
(no earned_reward_event_received)
(no closed_event_received)
(no finish_called)
```
There is also a second reproduction where the end card includes a "GET" button linking to an App-Store house ad, tapping which backgrounds the app; the same non-delivery occurs there too. **The foreground-only reproduction above shows backgrounding is not a required factor** — it happens even when the app never leaves the foreground at all.
#### Minimal relevant code
```ts
const rewarded = RewardedAd.createForAdRequest(adUnitId);
const unsubscribeLoaded = rewarded.addAdEventListener(RewardedAdEventType.LOADED, () => {
rewarded.show(); // fires reliably
});
const unsubscribeEarned = rewarded.addAdEventListener(
RewardedAdEventType.EARNED_REWARD,
() => { /* never invoked */ },
);
const unsubscribeClosed = rewarded.addAdEventListener(AdEventType.CLOSED, () => {
/* never invoked */
});
rewarded.load();
```
A fresh `RewardedAd` instance is created per attempt; all listeners are attached before `.load()` is called; `.show()` is only called from inside the `LOADED` handler, on the same instance whose listeners were just registered.
#### Source-level investigation already done
Before filing, I traced the library internals to rule out a usage mistake on our side:
- `addAdEventListener` (per-type) and `addAdEventsListener` (all-events, what `useRewardedAd()`/`useFullScreenAd()` uses internally) are both driven by the same single native listener registered in `MobileAd`'s constructor (`SharedEventEmitter.addListener('google_mobile_ads_${type}_event:${adUnitId}:${requestId}', this._handleAdEvent.bind(this))`), and both are dispatched from the same `_handleAdEvent` call. So this isn't caused by choosing the per-type listener over the hook/all-events listener — both share the identical delivery path, and `useRewardedAd()` would be expected to exhibit the same failure.
- On the iOS native side (`RNGoogleMobileAdsFullScreenAd.mm`), `EARNED_REWARD` originates from `GADRewardedAd`'s `presentFromRootViewController:userDidEarnRewardHandler:` closure, and `CLOSED` originates from `RNGoogleMobileAdsFullScreenContentDelegate`'s `adDidDismissFullScreenContent:` (the standard `GADFullScreenContentDelegate` method).
- Interstitial ads use the **same** `RNGoogleMobileAdsFullScreenContentDelegate` class for their own dismiss/`CLOSED` event, and that works correctly on this exact build/device (`interstitial_ad_dismissed` fires reliably). This suggests the wrapper's delegate assignment/dispatch machinery itself is sound, and the difference is specific to whether the native `GADRewardedAd` object ever calls back into either of its two completion mechanisms for this ad session.
This is offered as diagnostic evidence, not proof that the native SDK itself is at fault — it's equally possible something in how this specific test/house creative's end card interacts with the SDK's completion state is the trigger (see questions below).
#### Similar existing issue
#500 ("Rewarded callback not working") describes the same *symptom class* — reward visually granted, callback not firing — but on Android with an older version (12.4.0). Referencing as related context only, not as the same confirmed root cause; that issue was closed as "not planned" with no resolution documented.
#### Questions for maintainers
1. Is there a known issue on iOS with rewarded ads under Google Mobile Ads SDK 13.5.0 where `userDidEarnRewardHandler` and the full-screen dismissal delegate are never invoked for a specific ad session?
2. Are there known rewarded test creatives/end-card states (e.g. app-install house ads) that can visually display "Reward granted" without the SDK actually completing/delivering that callback?
3. Is there any recommended additional native configuration for Google Mobile Ads iOS SDK 13.5.0 relevant to this?
4. Is there a newer native SDK version (independent of this wrapper's JS-level releases, which as of 16.5.0 still bundle 13.5.0) known to address this?
5. Is there any native-side instrumentation/logging you'd recommend to confirm whether Google's native callback fires at all before the wrapper emits its own event — i.e., is the loss happening in Google's SDK, in the wrapper's Objective-C++ layer, or in the RN bridge?
6. Would a minimal standalone reproduction repository be useful, and if so is there a preferred minimal-repro template for this library?
### Platforms
Only on iOS
### React Native Info
```Shell
This is an Expo-managed project (expo 57.0.13, EAS Build for the native binary), so `react-native info` isn't directly applicable — Expo-equivalent info below.
expo-cli: 57.0.15
react-native: 0.86.2
node: v22.14.0
npm: 10.9.2
macOS (build machine): 15.4.1 (24E263)
iOS (test device): 26.6 (23G71)
Build: EAS Build (cloud), development profile, internal distribution
```
### Are your using Typescript?
- [ ] My project is using Typescript
### package.json
```JSON
{
"dependencies": {
"@expo/metro-runtime": "~57.0.10",
"@react-native-async-storage/async-storage": "2.2.0",
"@shopify/react-native-skia": "2.6.2",
"babel-preset-expo": "~57.0.0",
"expo": "~57.0.13",
"expo-asset": "~57.0.9",
"expo-audio": "~57.0.3",
"expo-constants": "~57.0.8",
"expo-dev-client": "~57.0.12",
"expo-haptics": "~57.0.1",
"expo-linking": "~57.0.6",
"expo-router": "~57.0.13",
"expo-status-bar": "~57.0.1",
"expo-tracking-transparency": "~57.0.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-gesture-handler": "~2.32.0",
"react-native-google-mobile-ads": "^16.4.0",
"react-native-purchases": "^10.7.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1"
}
}
```
### app.json
```JSON
{
"expo": {
"name": "REDACTED",
"version": "1.0.0",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.example.app"
},
"plugins": [
"expo-router",
["expo-audio", { "microphonePermission": false, "recordAudioAndroid": false }],
"expo-asset",
["react-native-google-mobile-ads", { "iosAppId": "ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX", "delayAppMeasurementInit": true }],
["expo-tracking-transparency", { "userTrackingPermission": "This identifier is used to show you more relevant ads and measure ad performance. You can change this anytime in Settings." }]
]
}
}
(RevenueCat keys, AdMob production ad-unit IDs, bundle identifier, and EAS project linkage redacted/placeholder'd — not relevant to this bug.)
```
### ios/Podfile
```Ruby
platform :ios, podfile_properties['ios.deploymentTarget'] || '16.4'
target 'UltimatePingPong' do
use_expo_modules!
config = use_native_modules!(config_command)
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true,
)
# react-native-google-mobile-ads autolinked via Expo modules; no manual pod pin.
# Resolves to Google-Mobile-Ads-SDK 13.5.0 / GoogleUserMessagingPlatform 3.1.0
# per react-native-google-mobile-ads@16.4.0's own podspec (sdkVersions.ios).
end
```
### android/build.gradle
```Groovy
```
### android/app/build.gradle
```Groovy
```
### android/settings.gradle
```Groovy
```
### AndroidManifest.xml
```XML
```
Contributor guide
Research direction
Start with the rewarded-ad paths in RNGoogleMobileAdsFullScreenAd.mm, including the userDidEarnRewardHandler closure and RNGoogleMobileAdsFullScreenContentDelegate dismissal callback, then trace MobileAd's SharedEventEmitter dispatch. Reproduce the foreground-only sequence and determine whether the native callbacks fire before the wrapper emits events; done means isolating the failing layer and confirming the behavior with a minimal regression case or documented native limitation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ios, objective-c, react-native, typescript
- Domain
- mobile, mobile-dev, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100