@chipmobilesdk/rn-ads 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,337 +1,340 @@
1
- /**
2
- * Adaptive banner host (FR-038 – FR-042).
3
- *
4
- * The app puts this where it wants a banner. The component never inserts itself
5
- * into the tree, never overlays app content, and ships no placeholder — the
6
- * surrounding layout, and any loading or fallback presentation, belong to the
7
- * app and its theme (FR-040, UX-001).
8
- *
9
- * When ads are disabled or unavailable it renders `null` rather than a
10
- * zero-height view. That is not a micro-optimization: a rendered empty view
11
- * still sits in the accessibility tree and can take screen-reader focus ahead of
12
- * real content. `null` leaves the tree entirely (UX-003).
13
- *
14
- * The provider owns the banner's whole lifecycle through its own component, so
15
- * this adapts those callbacks into the normalized event model instead of routing
16
- * a banner through the load/show handle model, which does not fit it.
17
- */
18
-
19
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
- import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
21
-
22
- import type { PlacementState } from '../config/types';
23
- import { AdsError, normalizeProviderError } from '../diagnostics/errors';
24
- import { useAds } from './AdsProvider';
25
- import type { ThrottledResult } from '../core/adsInstance';
26
- import { useAdsStatus } from './hooks';
27
-
28
- export interface AdsBannerProps {
29
- readonly placementKey: string;
30
- /** Called on every state change so the app can hold or reclaim the space. */
31
- readonly onStateChange?: (state: PlacementState) => void;
32
- /** Applied to the container only. Never to ad content, which is not ours. */
33
- readonly style?: ViewStyle;
34
- }
35
-
36
- interface ProviderBannerModule {
37
- BannerAd: React.ComponentType<Record<string, unknown>>;
38
- BannerAdSize: Record<string, string>;
39
- }
40
-
41
- function loadBannerModule(): ProviderBannerModule | null {
42
- try {
43
- return require('react-native-google-mobile-ads') as ProviderBannerModule;
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
- export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
50
- const ads = useAds();
51
- // The reported state lives in a ref rather than in component state: the host
52
- // itself renders the same tree for every state, so storing it would only
53
- // force re-renders the PERF-002 budget does not need to spend. The instance
54
- // holds the authoritative value; `onStateChange` tells the app.
55
- const stateRef = useRef<PlacementState>('idle');
56
- const [containerWidth, setContainerWidth] = useState(0);
57
- // A new cycle id remounts the provider component, which is how a width change
58
- // produces a genuinely re-adapted banner rather than a stretched old one.
59
- const [cycle, setCycle] = useState(0);
60
- const correlationRef = useRef<string | null>(null);
61
- const startedAtRef = useRef(0);
62
- // Whether this mount has already taken a throttle slot, and the refusal if it
63
- // was refused. Resets with the mount, which is exactly the granularity FR-140
64
- // needs.
65
- const throttleRef = useRef<{
66
- decided: boolean;
67
- refusal: ThrottledResult | null;
68
- }>({ decided: false, refusal: null });
69
-
70
- const provider = useMemo(loadBannerModule, []);
71
- // Initialization is asynchronous and a banner is almost always mounted before
72
- // it finishes. Without this the resolution below would be computed once, while
73
- // the instance was still `not_started`, be refused, and never be retried — the
74
- // banner would sit at `unavailable` forever on a perfectly healthy app.
75
- const status = useAdsStatus();
76
-
77
- // Keyed on `cycle` so a genuine width change mints a fresh correlation id and
78
- // starts a new, separately traceable load (FR-038, FR-052), and on `status` so
79
- // the banner recovers the moment the instance becomes able to request.
80
- const resolved = useMemo(
81
- () =>
82
- ads && status === 'ready'
83
- ? { cycle, value: ads.internals.resolveBanner(placementKey) }
84
- : null,
85
- [ads, placementKey, cycle, status],
86
- );
87
- const resolution = resolved?.value ?? null;
88
-
89
- const move = useCallback(
90
- (next: PlacementState) => {
91
- if (stateRef.current === next) {
92
- return;
93
- }
94
- stateRef.current = next;
95
- ads?.internals.setPlacementState(placementKey, next);
96
- onStateChange?.(next);
97
- },
98
- [ads, onStateChange, placementKey],
99
- );
100
-
101
- // FR-140. Decided **during render**, not in the effect below, because the
102
- // provider component requests an ad as part of this very render — an effect
103
- // would refuse a request that had already gone out.
104
- //
105
- // Charged once per mount, guarded by the ref. That granularity is the whole
106
- // distinction the throttle turns on: a fresh mount is the navigation burst it
107
- // exists to stop (swiping between days, switching tabs, a row scrolling in and
108
- // out), while a later cycle in the *same* mount is a width re-adaptation —
109
- // rotation — which must not be refused or a rotated screen loses its banner
110
- // for up to a minute.
111
- if (ads && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
112
- throttleRef.current = {
113
- decided: true,
114
- refusal: ads.internals.commitBannerRequest(placementKey, resolution.correlationId),
115
- };
116
- }
117
- const throttleRefusal = throttleRef.current.refusal;
118
-
119
- const usable = Boolean(
120
- ads && provider && resolution?.ok && containerWidth > 0 && !throttleRefusal,
121
- );
122
-
123
- useEffect(() => {
124
- if (!ads || !provider) {
125
- move('unavailable');
126
- return;
127
- }
128
- // `not_started` and `initializing` are transient: the app is still on its
129
- // way and the banner will resolve shortly, so report `idle` rather than
130
- // `unavailable` and let the app keep its layout decision open. Every other
131
- // non-ready status — disabled, blocked, failed, unsupported — means there
132
- // will be no ad, and the app should reclaim the space (FR-039, FR-042).
133
- //
134
- // `undecided` is the exception added in 0.2.0, and it is the entire reason
135
- // the third state exists. It is reported as itself rather than folded into
136
- // either neighbour: `idle` would say "an ad is coming", which is not known
137
- // yet, and `unavailable` would say "there will be none", which is also not
138
- // known. Only by reporting it honestly can the app decide whether to hold
139
- // the space — the choice that keeps a paying user from watching an ad
140
- // flash in and out (FR-116).
141
- if (status !== 'ready') {
142
- move(
143
- status === 'undecided'
144
- ? 'undecided'
145
- : status === 'not_started' || status === 'initializing'
146
- ? 'idle'
147
- : 'unavailable',
148
- );
149
- return;
150
- }
151
- if (!resolution?.ok) {
152
- move('unavailable');
153
- return;
154
- }
155
- if (containerWidth <= 0) {
156
- // FR-039: a zero-width container is reported rather than turned into a
157
- // request for an ad at a nonsensical size.
158
- move('idle');
159
- return;
160
- }
161
- // The slot was taken during render; here is where the refusal becomes
162
- // observable. Reported as `throttled` rather than folded into
163
- // `unavailable`: that would say "there will be no ad here", when in fact
164
- // this placement asked too recently and will work again shortly. Most apps
165
- // collapse the space for both, but only one is worth investigating.
166
- if (throttleRefusal) {
167
- move('throttled');
168
- ads.internals.emit({
169
- type: 'request_throttled',
170
- timestamp: Date.now(),
171
- placementKey,
172
- format: 'banner',
173
- retryAfterMs: throttleRefusal.retryAfterMs,
174
- minRequestIntervalMs: ads.internals.throttleFloorFor(placementKey),
175
- });
176
- return;
177
- }
178
- correlationRef.current = resolution.correlationId;
179
- startedAtRef.current = Date.now();
180
- move('loading');
181
- ads.internals.emit({
182
- type: 'load_started',
183
- timestamp: Date.now(),
184
- placementKey,
185
- format: 'banner',
186
- correlationId: resolution.correlationId,
187
- });
188
- // Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
189
- // re-render from producing a second uncontrolled request.
190
- // eslint-disable-next-line react-hooks/exhaustive-deps
191
- }, [ads, provider, status, resolution, containerWidth, cycle]);
192
-
193
- const onLayout = useCallback(
194
- (event: LayoutChangeEvent) => {
195
- const width = Math.round(event.nativeEvent.layout.width);
196
- setContainerWidth(previous => {
197
- if (previous === width) {
198
- return previous;
199
- }
200
- // FR-038: a genuine width or orientation change re-adapts. The
201
- // rounding above stops sub-pixel layout jitter from doing so.
202
- if (previous > 0 && width > 0) {
203
- setCycle(value => value + 1);
204
- }
205
- return width;
206
- });
207
- },
208
- [],
209
- );
210
-
211
- const emit = useCallback(
212
- (event: Parameters<NonNullable<typeof ads>['internals']['emit']>[0]) => {
213
- ads?.internals.emit(event);
214
- },
215
- [ads],
216
- );
217
-
218
- const handleLoaded = useCallback(() => {
219
- move('ready');
220
- emit({
221
- type: 'loaded',
222
- timestamp: Date.now(),
223
- placementKey,
224
- format: 'banner',
225
- correlationId: correlationRef.current ?? undefined,
226
- durationMs: Date.now() - startedAtRef.current,
227
- });
228
- }, [emit, move, placementKey]);
229
-
230
- const handleFailed = useCallback(
231
- (error: unknown) => {
232
- const normalized = normalizeProviderError(error, {
233
- operation: 'load',
234
- placementKey,
235
- format: 'banner',
236
- });
237
- move('failed');
238
- emit({
239
- type: 'load_failed',
240
- timestamp: Date.now(),
241
- placementKey,
242
- format: 'banner',
243
- correlationId: correlationRef.current ?? undefined,
244
- error: normalized,
245
- });
246
- },
247
- [emit, move, placementKey],
248
- );
249
-
250
- const handleImpression = useCallback(() => {
251
- emit({
252
- type: 'impression',
253
- timestamp: Date.now(),
254
- placementKey,
255
- format: 'banner',
256
- correlationId: correlationRef.current ?? undefined,
257
- // A real provider impression, unlike the derived one full-screen
258
- // formats get. The distinction is load-bearing for revenue
259
- // reconciliation, so it is recorded rather than flattened (R5).
260
- source: 'provider',
261
- });
262
- }, [emit, placementKey]);
263
-
264
- const handleClicked = useCallback(() => {
265
- emit({
266
- type: 'click',
267
- timestamp: Date.now(),
268
- placementKey,
269
- format: 'banner',
270
- correlationId: correlationRef.current ?? undefined,
271
- });
272
- }, [emit, placementKey]);
273
-
274
- // FR-041: nothing survives unmount. No retained native view, no dangling
275
- // correlation, no state written into a disposed instance.
276
- useEffect(() => {
277
- return () => {
278
- correlationRef.current = null;
279
- };
280
- }, []);
281
-
282
- if (!ads || !provider) {
283
- // FR-042, UX-003 — out of the tree entirely, blocking no touch.
284
- return null;
285
- }
286
-
287
- // A terminal non-ready status means there will be no ad at all, so leave the
288
- // tree rather than render an empty host. `not_started` and `initializing` are
289
- // excluded on purpose: the host must stay mounted through them, because it is
290
- // what measures the container and starts the load once the instance is ready.
291
- //
292
- // `undecided` is excluded for the same reason: the app may still resolve it to
293
- // enabled, and a host that unmounted would have to re-measure the container
294
- // from scratch when it did. It renders no ad meanwhile — it renders the same
295
- // empty measuring host it renders while initializing — and it reports
296
- // `undecided` so the app can size the space it decided to keep.
297
- if (status === 'disabled' || status === 'unavailable' || status === 'failed') {
298
- return null;
299
- }
300
-
301
- if (resolution && !resolution.ok) {
302
- const refusal = resolution.result;
303
- // `undecided` deliberately absent: unlike `disabled`, it is not a settled
304
- // no, so the host stays mounted (FR-116).
305
- if (refusal.status === 'disabled' || refusal.status === 'unavailable') {
306
- return null;
307
- }
308
- }
309
-
310
- const { BannerAd, BannerAdSize } = provider;
311
- const adUnitId = resolution?.ok ? resolution.adUnitId : null;
312
-
313
- return (
314
- <View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
315
- {usable && adUnitId ? (
316
- <BannerAd
317
- key={`${placementKey}-${cycle}`}
318
- unitId={adUnitId}
319
- size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
320
- onAdLoaded={handleLoaded}
321
- onAdFailedToLoad={handleFailed}
322
- onAdImpression={handleImpression}
323
- onAdClicked={handleClicked}
324
- />
325
- ) : null}
326
- </View>
327
- );
328
- }
329
-
330
- /** Exposed for tests that assert the failure taxonomy without a renderer. */
331
- export function bannerUnavailableError(placementKey: string): AdsError {
332
- return new AdsError('unsupported_platform', {
333
- operation: 'load',
334
- placementKey,
335
- format: 'banner',
336
- });
337
- }
1
+ /**
2
+ * Adaptive banner host (FR-038 – FR-042).
3
+ *
4
+ * The app puts this where it wants a banner. The component never inserts itself
5
+ * into the tree, never overlays app content, and ships no placeholder — the
6
+ * surrounding layout, and any loading or fallback presentation, belong to the
7
+ * app and its theme (FR-040, UX-001).
8
+ *
9
+ * When ads are disabled or unavailable it renders `null` rather than a
10
+ * zero-height view. That is not a micro-optimization: a rendered empty view
11
+ * still sits in the accessibility tree and can take screen-reader focus ahead of
12
+ * real content. `null` leaves the tree entirely (UX-003).
13
+ *
14
+ * The provider owns the banner's whole lifecycle through its own component, so
15
+ * this adapts those callbacks into the normalized event model instead of routing
16
+ * a banner through the load/show handle model, which does not fit it.
17
+ */
18
+
19
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
+ import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
21
+
22
+ import type { PlacementState } from '../config/types';
23
+ import { AdsError, normalizeProviderError } from '../diagnostics/errors';
24
+ import { useAds } from './AdsProvider';
25
+ import type { ThrottledResult } from '../core/adsInstance';
26
+ import { useAdsStatus } from './hooks';
27
+
28
+ export interface AdsBannerProps {
29
+ readonly placementKey: string;
30
+ /** Called on every state change so the app can hold or reclaim the space. */
31
+ readonly onStateChange?: (state: PlacementState) => void;
32
+ /** Applied to the container only. Never to ad content, which is not ours. */
33
+ readonly style?: ViewStyle;
34
+ }
35
+
36
+ interface ProviderBannerModule {
37
+ BannerAd: React.ComponentType<Record<string, unknown>>;
38
+ BannerAdSize: Record<string, string>;
39
+ }
40
+
41
+ function loadBannerModule(): ProviderBannerModule | null {
42
+ try {
43
+ return require('react-native-google-mobile-ads') as ProviderBannerModule;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
50
+ const ads = useAds();
51
+ // The reported state lives in a ref rather than in component state: the host
52
+ // itself renders the same tree for every state, so storing it would only
53
+ // force re-renders the PERF-002 budget does not need to spend. The instance
54
+ // holds the authoritative value; `onStateChange` tells the app.
55
+ const stateRef = useRef<PlacementState>('idle');
56
+ const [containerWidth, setContainerWidth] = useState(0);
57
+ // A new cycle id remounts the provider component, which is how a width change
58
+ // produces a genuinely re-adapted banner rather than a stretched old one.
59
+ const [cycle, setCycle] = useState(0);
60
+ const correlationRef = useRef<string | null>(null);
61
+ const startedAtRef = useRef(0);
62
+ // Whether this mount has already taken a throttle slot, and the refusal if it
63
+ // was refused. Resets with the mount, which is exactly the granularity FR-140
64
+ // needs.
65
+ const throttleRef = useRef<{
66
+ decided: boolean;
67
+ refusal: ThrottledResult | null;
68
+ }>({ decided: false, refusal: null });
69
+
70
+ const provider = useMemo(loadBannerModule, []);
71
+ // Initialization is asynchronous and a banner is almost always mounted before
72
+ // it finishes. Without this the resolution below would be computed once, while
73
+ // the instance was still `not_started`, be refused, and never be retried — the
74
+ // banner would sit at `unavailable` forever on a perfectly healthy app.
75
+ const status = useAdsStatus();
76
+
77
+ // Keyed on `cycle` so a genuine width change mints a fresh correlation id and
78
+ // starts a new, separately traceable load (FR-038, FR-052), and on `status` so
79
+ // the banner recovers the moment the instance becomes able to request.
80
+ const resolved = useMemo(
81
+ () =>
82
+ ads && status === 'ready'
83
+ ? { cycle, value: ads.internals.resolveBanner(placementKey) }
84
+ : null,
85
+ [ads, placementKey, cycle, status],
86
+ );
87
+ const resolution = resolved?.value ?? null;
88
+
89
+ const move = useCallback(
90
+ (next: PlacementState) => {
91
+ if (stateRef.current === next) {
92
+ return;
93
+ }
94
+ stateRef.current = next;
95
+ ads?.internals.setPlacementState(placementKey, next);
96
+ onStateChange?.(next);
97
+ },
98
+ [ads, onStateChange, placementKey],
99
+ );
100
+
101
+ // FR-140. Decided **during render**, not in the effect below, because the
102
+ // provider component requests an ad as part of this very render — an effect
103
+ // would refuse a request that had already gone out.
104
+ //
105
+ // Charged once per mount, guarded by the ref. That granularity is the whole
106
+ // distinction the throttle turns on: a fresh mount is the navigation burst it
107
+ // exists to stop (swiping between days, switching tabs, a row scrolling in and
108
+ // out), while a later cycle in the *same* mount is a width re-adaptation —
109
+ // rotation — which must not be refused or a rotated screen loses its banner
110
+ // for up to a minute.
111
+ if (ads && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
112
+ throttleRef.current = {
113
+ decided: true,
114
+ refusal: ads.internals.commitBannerRequest(placementKey, resolution.correlationId),
115
+ };
116
+ }
117
+ const throttleRefusal = throttleRef.current.refusal;
118
+
119
+ const usable = Boolean(
120
+ ads && provider && resolution?.ok && containerWidth > 0 && !throttleRefusal,
121
+ );
122
+
123
+ useEffect(() => {
124
+ if (!ads || !provider) {
125
+ move('unavailable');
126
+ return;
127
+ }
128
+ // `not_started` and `initializing` are transient: the app is still on its
129
+ // way and the banner will resolve shortly, so report `idle` rather than
130
+ // `unavailable` and let the app keep its layout decision open. Every other
131
+ // non-ready status — disabled, blocked, failed, unsupported — means there
132
+ // will be no ad, and the app should reclaim the space (FR-039, FR-042).
133
+ //
134
+ // `undecided` is the exception added in 0.2.0, and it is the entire reason
135
+ // the third state exists. It is reported as itself rather than folded into
136
+ // either neighbour: `idle` would say "an ad is coming", which is not known
137
+ // yet, and `unavailable` would say "there will be none", which is also not
138
+ // known. Only by reporting it honestly can the app decide whether to hold
139
+ // the space — the choice that keeps a paying user from watching an ad
140
+ // flash in and out (FR-116).
141
+ if (status !== 'ready') {
142
+ move(
143
+ status === 'undecided'
144
+ ? 'undecided'
145
+ : status === 'not_started' || status === 'initializing'
146
+ ? 'idle'
147
+ : 'unavailable',
148
+ );
149
+ return;
150
+ }
151
+ if (!resolution?.ok) {
152
+ move('unavailable');
153
+ return;
154
+ }
155
+ if (containerWidth <= 0) {
156
+ // FR-039: a zero-width container is reported rather than turned into a
157
+ // request for an ad at a nonsensical size.
158
+ move('idle');
159
+ return;
160
+ }
161
+ // The slot was taken during render; here is where the refusal becomes
162
+ // observable. Reported as `throttled` rather than folded into
163
+ // `unavailable`: that would say "there will be no ad here", when in fact
164
+ // this placement asked too recently and will work again shortly. Most apps
165
+ // collapse the space for both, but only one is worth investigating.
166
+ if (throttleRefusal) {
167
+ move('throttled');
168
+ ads.internals.emit({
169
+ type: 'request_throttled',
170
+ timestamp: Date.now(),
171
+ placementKey,
172
+ format: 'banner',
173
+ retryAfterMs: throttleRefusal.retryAfterMs,
174
+ minRequestIntervalMs: ads.internals.throttleFloorFor(placementKey),
175
+ });
176
+ return;
177
+ }
178
+ correlationRef.current = resolution.correlationId;
179
+ startedAtRef.current = Date.now();
180
+ move('loading');
181
+ ads.internals.emit({
182
+ type: 'load_started',
183
+ timestamp: Date.now(),
184
+ placementKey,
185
+ format: 'banner',
186
+ correlationId: resolution.correlationId,
187
+ });
188
+ // Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
189
+ // re-render from producing a second uncontrolled request.
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, [ads, provider, status, resolution, containerWidth, cycle]);
192
+
193
+ const onLayout = useCallback(
194
+ (event: LayoutChangeEvent) => {
195
+ const width = Math.round(event.nativeEvent.layout.width);
196
+ setContainerWidth(previous => {
197
+ if (previous === width) {
198
+ return previous;
199
+ }
200
+ // FR-038: a genuine width or orientation change re-adapts. The
201
+ // rounding above stops sub-pixel layout jitter from doing so.
202
+ if (previous > 0 && width > 0) {
203
+ setCycle(value => value + 1);
204
+ }
205
+ return width;
206
+ });
207
+ },
208
+ [],
209
+ );
210
+
211
+ const emit = useCallback(
212
+ (event: Parameters<NonNullable<typeof ads>['internals']['emit']>[0]) => {
213
+ ads?.internals.emit(event);
214
+ },
215
+ [ads],
216
+ );
217
+
218
+ const handleLoaded = useCallback(() => {
219
+ move('ready');
220
+ emit({
221
+ type: 'loaded',
222
+ timestamp: Date.now(),
223
+ placementKey,
224
+ format: 'banner',
225
+ correlationId: correlationRef.current ?? undefined,
226
+ durationMs: Date.now() - startedAtRef.current,
227
+ });
228
+ }, [emit, move, placementKey]);
229
+
230
+ const handleFailed = useCallback(
231
+ (error: unknown) => {
232
+ const normalized = normalizeProviderError(error, {
233
+ operation: 'load',
234
+ placementKey,
235
+ format: 'banner',
236
+ });
237
+ move('failed');
238
+ emit({
239
+ type: 'load_failed',
240
+ timestamp: Date.now(),
241
+ placementKey,
242
+ format: 'banner',
243
+ correlationId: correlationRef.current ?? undefined,
244
+ error: normalized,
245
+ });
246
+ },
247
+ [emit, move, placementKey],
248
+ );
249
+
250
+ const handleImpression = useCallback(() => {
251
+ emit({
252
+ type: 'impression',
253
+ timestamp: Date.now(),
254
+ placementKey,
255
+ format: 'banner',
256
+ correlationId: correlationRef.current ?? undefined,
257
+ // A real provider impression, unlike the derived one full-screen
258
+ // formats get. The distinction is load-bearing for revenue
259
+ // reconciliation, so it is recorded rather than flattened (R5).
260
+ source: 'provider',
261
+ });
262
+ }, [emit, placementKey]);
263
+
264
+ const handleClicked = useCallback(() => {
265
+ emit({
266
+ type: 'click',
267
+ timestamp: Date.now(),
268
+ placementKey,
269
+ format: 'banner',
270
+ correlationId: correlationRef.current ?? undefined,
271
+ });
272
+ }, [emit, placementKey]);
273
+
274
+ // FR-041: nothing survives unmount. No retained native view, no dangling
275
+ // correlation, no state written into a disposed instance.
276
+ useEffect(() => {
277
+ return () => {
278
+ correlationRef.current = null;
279
+ };
280
+ }, []);
281
+
282
+ if (!ads || !provider) {
283
+ // FR-042, UX-003 — out of the tree entirely, blocking no touch.
284
+ return null;
285
+ }
286
+
287
+ // A terminal non-ready status means there will be no ad at all, so leave the
288
+ // tree rather than render an empty host. `not_started` and `initializing` are
289
+ // excluded on purpose: the host must stay mounted through them, because it is
290
+ // what measures the container and starts the load once the instance is ready.
291
+ //
292
+ // `undecided` is excluded for the same reason: the app may still resolve it to
293
+ // enabled, and a host that unmounted would have to re-measure the container
294
+ // from scratch when it did. It renders no ad meanwhile — it renders the same
295
+ // empty measuring host it renders while initializing — and it reports
296
+ // `undecided` so the app can size the space it decided to keep.
297
+ if (status === 'disabled' || status === 'unavailable' || status === 'failed') {
298
+ return null;
299
+ }
300
+
301
+ if (resolution && !resolution.ok) {
302
+ const refusal = resolution.result;
303
+ // `undecided` deliberately absent: unlike `disabled`, it is not a settled
304
+ // no, so the host stays mounted (FR-116).
305
+ if (refusal.status === 'disabled' || refusal.status === 'unavailable') {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ const { BannerAd, BannerAdSize } = provider;
311
+ const adUnitId = resolution?.ok ? resolution.adUnitId : null;
312
+ // The provider's polarity is the inverse of the consumer-facing option.
313
+ const requestNonPersonalizedAdsOnly = resolution?.ok ? !resolution.personalizedAds : false;
314
+
315
+ return (
316
+ <View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
317
+ {usable && adUnitId ? (
318
+ <BannerAd
319
+ key={`${placementKey}-${cycle}`}
320
+ unitId={adUnitId}
321
+ size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
322
+ requestOptions={{ requestNonPersonalizedAdsOnly }}
323
+ onAdLoaded={handleLoaded}
324
+ onAdFailedToLoad={handleFailed}
325
+ onAdImpression={handleImpression}
326
+ onAdClicked={handleClicked}
327
+ />
328
+ ) : null}
329
+ </View>
330
+ );
331
+ }
332
+
333
+ /** Exposed for tests that assert the failure taxonomy without a renderer. */
334
+ export function bannerUnavailableError(placementKey: string): AdsError {
335
+ return new AdsError('unsupported_platform', {
336
+ operation: 'load',
337
+ placementKey,
338
+ format: 'banner',
339
+ });
340
+ }