@chipmobilesdk/rn-ads 0.2.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -6,11 +6,104 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  The event catalog, error codes, state names, and result unions are **public API**. Renaming or removing any of them is a major change.
8
8
 
9
+ ## [0.4.0] — 2026-10-12
10
+
11
+ Additive minor release closing one gap that surfaced when `0.3.x` met an app that must let users change their consent choice. It is a **capability** gap, the same category as consent orchestration in `0.2.0`: the app could not close it without importing the provider, or without misusing `consent.reset()`, a testing-only call that wipes the stored decision while the form is open.
12
+
13
+ **Upgrading requires no code change.** No existing exported union gained a member. Three behaviour changes are worth reading — see [Changed](#changed) — and one defect is fixed — see [Fixed](#fixed).
14
+
15
+ ### Added
16
+
17
+ - **Privacy options: the "change my choice" form** (`ads.consent.showPrivacyOptionsForm()`). Presents the provider's privacy options form and returns a typed `PrivacyOptionsFormResult`, never throwing:
18
+ - `shown`, carrying the `ConsentSnapshot` the form left behind, already applied to the gate. A user who backs out unchanged lands here with the state they had.
19
+ - `not_required`, `info_update_required`, `busy`: refusals that present nothing.
20
+ - `failed`, carrying an `AdsError` (`consent_flow_failed`).
21
+ - `unavailable`: `unsupported_platform` on iOS, `provider_missing` after `dispose()`.
22
+
23
+ The outcome feeds the privacy gate by itself, exactly like `showFormIfRequired`, and combines with the app's own gate as most-restrictive-wins. The package still draws no UI. Where the entry lives, its copy and when to offer it stay the app's.
24
+
25
+ - **`ConsentSnapshot.privacyOptionsRequirement`** (`'required' | 'not_required' | 'unknown'`): whether to show that entry at all. Refreshed by `requestInfoUpdate()` and published through `consent.subscribe`. It is the provider's verdict forwarded, never a region judgement of the package's. It is `unknown` before the first successful update, after `reset()`, after a failed update, and on iOS.
26
+
27
+ - **A failed form is never consent.** When the form fails, the package asks the provider what it still holds rather than assuming: the form never completed, so the stored decision has not changed, and revoking it would punish a user for a form that failed to open. If the provider cannot answer either, the state becomes `error` and the gate closes.
28
+
29
+ - **`./testing`**: `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm` (keeps the next form open until the test closes or fails it, so `busy`, a consent update or `dispose()` can happen while it is on screen), `failConsentInfoRead`, and a `privacyOptions` field on the scripted consent info. `FakeConsentCall` gained `{ kind: 'showPrivacyOptionsForm' }`.
30
+
31
+ - Exported types `PrivacyOptionsFormResult` and `PrivacyOptionsRequirement`.
32
+
33
+ No new event type. Consent changes caused by the form arrive through `consent.subscribe`, as `initialization` events when the status moves, and as `unavailable` for a load the withdrawal cancels. No payload carries the TCF string or any identifier.
34
+
35
+ No bridge change. The pinned `react-native-google-mobile-ads` `16.0.3` already exposes `AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus`, verified against the installed type declarations and its Android module. The range stays `>=16.0.3 <16.1.0`, and the merged manifest gains no permission.
36
+
37
+ ### Changed
38
+
39
+ - **`initialize()` waits for the package's consent flow, once the app uses it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests — declined, not yet answered, or failed — returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` checked only the app's own gate here, so it returned `ready` and initialized the provider for a user who had just declined, while `load` refused; that contradicts the provider's guidance not to initialize before consent permits requests. When the flow later permits requests, through either consent form, the package initializes the provider itself, once. An app that runs update → form → `initialize()` sees no difference for a user who consents. An app that never calls `ads.consent.*` is unaffected.
40
+
41
+ - **A consent gate that closes while the instance is `ready` now moves it to `blocked`, and stops what is in flight.** `0.3.0` kept `ready` while `load` already refused. That was harmless while the only consent outcome that could close the gate ran before anything loaded. The privacy options form changes that: a user can withdraw with a banner mounted, and a banner host keyed on `ready` would have gone on requesting. Now in-flight loads settle with `{ status: 'blocked', reason }` instead of being left waiting, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. An ad already on screen is still not force-closed. This also applies when a later `requestInfoUpdate()` fails.
42
+
43
+ - **Reopening a gate on an already-initialized instance resumes without a second native initialization.** `0.3.0` initialized the provider again on `ready → blocked → reopened`, through the consent flow or through `updatePrivacy`, against the one-initialization-per-instance rule. Now the status returns to `ready` with no native call, including when the app re-runs `initialize()` after a consent change. Resuming goes through the whole gate, so re-running `initialize()` cannot reopen a consent the user just withdrew. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can take effect.
44
+
45
+ ### Fixed
46
+
47
+ - **A `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now settles.** It resolves with `{ status: 'disabled' }`, `{ status: 'undecided' }`, or `{ status: 'unavailable', reason: 'provider_missing' }` — the answer a `load` started at that moment gets. Before `0.4.0` the resources were released but the promise was never resolved, so an app awaiting a load across an ad-removal purchase waited forever. Disabling and undeciding also emit the matching refusal event for the abandoned load; dispose emits nothing, as always.
48
+ - **A handle the provider returned after such a disable is disposed instead of adopted.** Previously it was subscribed and went on loading behind a disabled instance.
49
+
50
+ ### Not added, deliberately
51
+
52
+ - **No Settings entry, copy or offer timing.** Those are policy, and they stay the app's.
53
+ - **No region detection of the package's own.** The requirement is the provider's answer.
54
+ - **iOS** stays `unavailable` / `unsupported_platform`.
55
+
56
+ ## [0.3.0] — 2026-10-10
57
+
58
+ Additive minor release closing one gap that surfaced when `0.2.x` met an app placing a banner inside a view the user swipes between. It is a **capability** gap: the app could not close it itself without reaching past the package, which is the thing this package exists to prevent.
59
+
60
+ **Upgrading requires one change, compile-time only**, plus one behaviour change worth reading: see [Changed](#changed-1).
61
+
62
+ ### Added
63
+
64
+ - **Per-placement request throttling** (`minRequestIntervalMs`). A minimum interval between provider *requests* for one placement.
65
+
66
+ The pattern it exists for: a banner mounted inside a view the user navigates between — a day in a calendar, a tab, a row scrolling in and out of a list — is unmounted and remounted by React on every navigation, and **each remount is a billable request**. Ten quick swipes were ten requests in a few seconds. Ad networks read that as invalid traffic, and the penalty is account-level and not recoverable by shipping a fix.
67
+
68
+ That is why this belongs in the package rather than in a README note. It sits in the same class as the test-ad substitution already enforced in non-production builds: a safety property the library guarantees rather than documents and hopes each app remembers.
69
+
70
+ - **Format-aware defaults.** `60 000 ms` for a banner — AdMob's own documented floor for banner auto-refresh, so the number is the provider's rather than invented. `0` (off) for `interstitial`, `rewarded` and `app_open`: those are loaded by an explicit call, not mounted by a renderer, so they never had the problem, and a default floor would break an app that legitimately loads three times in two minutes.
71
+ - **Per-placement override**, bounds `0`–`600 000 ms`, validated eagerly at `createAds` and naming the offending placement — so a misconfiguration fails at construction rather than by refusing every request in production, which is the one moment nobody is watching for a configuration error.
72
+ - **Rotation is exempt.** A width re-adaptation inside one mount is the same banner the placement already requested; refusing it would leave a rotated screen with no banner for up to a minute. The slot is charged once per *mount*, not per request.
73
+ - **A concurrent duplicate load is exempt.** Those callers already join the in-flight request, and charging the second would turn an existing guarantee into a refusal.
74
+ - **Clock-robust.** A backwards clock — a manual time change, an NTP correction — fails open rather than locking a placement out until the clock catches up. The throttle guards against bursts, and a backwards clock is not a burst.
75
+
76
+ - `PlacementState` gains `'throttled'`, and `LoadResult` gains `{ status: 'throttled', retryAfterMs }`. Deliberately distinct from `'failed'` and from a no-fill: no-fill means the network had nothing to give, this means we did not ask. An app that conflates them will misdiagnose the first time a placement goes quiet.
77
+
78
+ - `request_throttled` event, carrying `retryAfterMs` and the `minRequestIntervalMs` that refused it, so the cause is readable from the event stream alone. No `load_started` accompanies it — a refused request is not a cycle, and the stream must not claim a billable request that never happened.
79
+
80
+ - Exported `DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS`, `DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS`, `MIN_REQUEST_INTERVAL_BOUNDS`, `defaultMinRequestIntervalMs`, so an app reads the floor it is bound by instead of discovering it from a `throttled` state in production.
81
+
82
+ ### Changed
83
+
84
+ - **A banner placement now has a 60s floor between requests.** If your banner lives inside a list row, a tab, or a view the user swipes between, it will report `'throttled'` after a quick navigation where `0.2.x` silently issued another request. That silent request was the defect; this is the fix.
85
+
86
+ Before reaching for `minRequestIntervalMs: 0`, consider the architectural answer: a banner kept **outside** the subtree that remounts stays mounted across navigation, keeps its ad, and costs one request. The throttle is a safety net for when that is not possible.
87
+
88
+ - Three unions gained a member, so an exhaustive `switch` over any of them needs the new case or a `default`:
89
+
90
+ | Union | New member |
91
+ |---|---|
92
+ | `PlacementState` | `'throttled'` |
93
+ | `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
94
+ | `AdEventType` | `'request_throttled'` |
95
+
96
+ - Full-screen formats are unchanged: their default floor is `0`, so request counts for `interstitial`, `rewarded` and `app_open` are byte-identical to `0.2.x` unless you configure a floor.
97
+
98
+ ### Not added, deliberately
99
+
100
+ - **No frequency cap, and none planned.** This release limits how often a placement may *ask*; how often an app *shows* remains entirely the app's. Pacing has no correct default — one app counts items created, another counts screens opened, another counts minutes — so shipping one would make the package own policy that belongs to the app. For "an interstitial every third save", count the saves in the app and call `show` when the rule says so.
101
+
9
102
  ## [0.2.0] — 2026-09-13
10
103
 
11
104
  Additive minor release closing five gaps that surfaced when `0.1.0` met an app selling an ad-removal tier. Four of the five were gaps in **capability** — an app could not patch them itself without importing the provider directly, which is the one thing this package exists to prevent.
12
105
 
13
- **Upgrading requires one change, and it is compile-time only**: see [Changed](#changed) and the migration guide in the README.
106
+ **Upgrading requires one change, and it is compile-time only**: see [Changed](#changed-2) and the migration guide in the README.
14
107
 
15
108
  ### Added
16
109