@amplytools/react-native-amply-sdk 0.9.1 → 0.10.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.
Files changed (26) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +6 -0
  4. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +8 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +29 -5
  6. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +4 -0
  7. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +6 -0
  8. package/dist/src/__tests__/campaignResolved.test.js +16 -4
  9. package/dist/src/__tests__/gateRegistrationLifecycle.test.d.ts +2 -0
  10. package/dist/src/__tests__/gateRegistrationLifecycle.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/gateRegistrationLifecycle.test.js +215 -0
  12. package/dist/src/index.d.ts +11 -1
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/index.js +177 -38
  15. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +9 -0
  16. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  17. package/dist/tsconfig.tsbuildinfo +1 -1
  18. package/ios/Sources/AmplyReactNative/AmplyModule.mm +65 -12
  19. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +7 -0
  20. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +1 -0
  21. package/package.json +1 -1
  22. package/scripts/check-spec-parity.sh +82 -0
  23. package/src/__tests__/campaignResolved.test.ts +23 -5
  24. package/src/__tests__/gateRegistrationLifecycle.test.ts +281 -0
  25. package/src/index.ts +193 -41
  26. package/src/nativeSpecs/NativeAmplyModule.ts +9 -0
@@ -22,7 +22,7 @@ Pod::Spec.new do |s|
22
22
  s.dependency 'React-RCTFabric'
23
23
 
24
24
  # Amply KMP SDK from CocoaPods
25
- s.dependency 'AmplySDK', '~> 0.9.1'
25
+ s.dependency 'AmplySDK', '~> 0.10.0'
26
26
 
27
27
  s.libraries = 'c++'
28
28
  s.pod_target_xcconfig = {
@@ -15,7 +15,7 @@ buildscript {
15
15
  apply plugin: 'com.android.library'
16
16
  apply plugin: 'org.jetbrains.kotlin.android'
17
17
 
18
- def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.9.1'
18
+ def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.10.0'
19
19
  group = 'tools.amply'
20
20
  version = amplySdkVersion
21
21
 
@@ -136,6 +136,12 @@ class AmplyModule(reactContext: ReactApplicationContext) :
136
136
  client.registerGate(baseUrl, onAbort, timeoutMs.toLong())
137
137
  }
138
138
 
139
+ override fun unregisterGate(baseUrl: String) {
140
+ // The present-collection job stays: it is per-module, not per-gate, and a later
141
+ // registerGate on any URL would need it again.
142
+ client.unregisterGate(baseUrl)
143
+ }
144
+
139
145
  override fun getRecentEvents(limit: Double, promise: Promise) {
140
146
  scope.launch {
141
147
  try {
@@ -48,6 +48,14 @@ interface AmplyClient {
48
48
  */
49
49
  fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long)
50
50
 
51
+ /**
52
+ * Withdraw the gate registered for [baseUrl]. A no-op when none is registered.
53
+ *
54
+ * Without it a gate outlives its presenter: the registry is process-scoped, so the next
55
+ * gated call on that URL parks for the whole fail-open timeout, silently.
56
+ */
57
+ fun unregisterGate(baseUrl: String)
58
+
51
59
  /**
52
60
  * Report the terminal result (one of `Completed` / `Dismissed` / `Unavailable`) for
53
61
  * a previously dispatched campaign present. Idempotent — late/duplicate or unknown
@@ -70,7 +70,9 @@ class DefaultAmplyClient(
70
70
  private val logListenerToken = AtomicReference<ListenerToken?>(null)
71
71
  private val systemEventsToken = AtomicReference<ListenerToken?>(null)
72
72
  private val deepLinkToken = AtomicReference<ListenerToken?>(null)
73
- private val gateTokens = java.util.concurrent.CopyOnWriteArrayList<ListenerToken>()
73
+ // Keyed by baseUrl: withdrawal names a URL, not a token, and at most one gate per URL may
74
+ // be registered — the JS side evicts before it registers again.
75
+ private val gateTokensByBaseUrl = java.util.concurrent.ConcurrentHashMap<String, ListenerToken>()
74
76
 
75
77
  private val _deepLinkEvents = MutableSharedFlow<DeepLinkPayload>(
76
78
  replay = 1,
@@ -218,14 +220,36 @@ class DefaultAmplyClient(
218
220
  // gets its OWN presenter instance owning its OWN "current presentation" slot, so a
219
221
  // dismiss() delivered to this gate's presenter can only abandon THIS gate's live
220
222
  // presentation — never one belonging to a different url-pattern's gate.
221
- gateTokens.add(
223
+ // Defensive: JS evicts before registering again, but overwriting the map entry without
224
+ // withdrawing would leak the previous token for the life of the process.
225
+ unregisterGate(baseUrl)
226
+
227
+ gateTokensByBaseUrl[baseUrl] =
222
228
  instance.registerGate(
223
229
  baseUrl = baseUrl,
224
230
  presenter = GatePresenter(baseUrl),
225
231
  onAbort = policy,
226
232
  timeoutMs = if (timeoutMs > 0L) timeoutMs else DEFAULT_GATE_TIMEOUT_MS,
227
233
  )
228
- )
234
+ }
235
+
236
+ /**
237
+ * Withdraws the gate registered for [baseUrl]. A no-op when none is registered, so JS can
238
+ * call it without checking first.
239
+ *
240
+ * By URL rather than by token because the token never crosses the bridge. It has to exist
241
+ * at all because a gate outliving its presenter is invisible: the next gated call on that
242
+ * URL parks for the whole fail-open timeout with no crash and nothing in the log.
243
+ */
244
+ override fun unregisterGate(baseUrl: String) {
245
+ val token = gateTokensByBaseUrl.remove(baseUrl) ?: return
246
+ // The instance can already be gone if the bridge was torn down between register and
247
+ // withdraw; dropping our own bookkeeping above is still correct in that case.
248
+ amplyInstance?.unregisterGate(token)
249
+ android.util.Log.i("AmplyReactNative", "Unregistered gate baseUrl=$baseUrl")
250
+ if (gateTokensByBaseUrl.isEmpty()) {
251
+ gatePresenterRegistered.set(false)
252
+ }
229
253
  }
230
254
 
231
255
  override fun resolveCampaign(mediationId: String, result: String) {
@@ -438,9 +462,9 @@ class DefaultAmplyClient(
438
462
  if (instance != null) {
439
463
  systemEventsToken.getAndSet(null)?.let { instance.clearSystemEventsListener(it) }
440
464
  deepLinkToken.getAndSet(null)?.let { instance.removeDeepLinkListener(it) }
441
- gateTokens.forEach { instance.unregisterGate(it) }
465
+ gateTokensByBaseUrl.values.forEach { instance.unregisterGate(it) }
442
466
  }
443
- gateTokens.clear()
467
+ gateTokensByBaseUrl.clear()
444
468
 
445
469
  runBlocking {
446
470
  mutex.withLock {
@@ -70,6 +70,10 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
70
70
  @DoNotStrip
71
71
  public abstract void registerGate(String baseUrl, String onAbort, double timeoutMs);
72
72
 
73
+ @ReactMethod
74
+ @DoNotStrip
75
+ public abstract void unregisterGate(String baseUrl);
76
+
73
77
  @ReactMethod
74
78
  @DoNotStrip
75
79
  public abstract void getRecentEvents(double limit, Promise promise);
@@ -42,6 +42,11 @@ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_registerGate
42
42
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "registerGate", "(Ljava/lang/String;Ljava/lang/String;D)V", args, count, cachedMethodId);
43
43
  }
44
44
 
45
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_unregisterGate(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
46
+ static jmethodID cachedMethodId = nullptr;
47
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "unregisterGate", "(Ljava/lang/String;)V", args, count, cachedMethodId);
48
+ }
49
+
45
50
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
46
51
  static jmethodID cachedMethodId = nullptr;
47
52
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "getRecentEvents", "(DLcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
@@ -110,6 +115,7 @@ NativeAmplyModuleSpecJSI::NativeAmplyModuleSpecJSI(const JavaTurboModule::InitPa
110
115
  methodMap_["trackGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackGated};
111
116
  methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
112
117
  methodMap_["registerGate"] = MethodMetadata {3, __hostFunction_NativeAmplyModuleSpecJSI_registerGate};
118
+ methodMap_["unregisterGate"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_unregisterGate};
113
119
  methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
114
120
  methodMap_["getDataSetSnapshot"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot};
115
121
  methodMap_["registerDeepLinkListener"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerDeepLinkListener};
@@ -17,6 +17,7 @@ const mockNativeModule = {
17
17
  getDataSetSnapshot: jest.fn(),
18
18
  registerDeepLinkListener: jest.fn(),
19
19
  registerGate: jest.fn(),
20
+ unregisterGate: jest.fn(),
20
21
  addListener: jest.fn(),
21
22
  removeListeners: jest.fn(),
22
23
  onSystemEvent: jest.fn(),
@@ -99,7 +100,7 @@ describe('Amply gate API (trackGated + registerGate)', () => {
99
100
  });
100
101
  });
101
102
  describe('registerGate', () => {
102
- it('registers once on the native module per baseUrl and subscribes to onCampaignPresent', async () => {
103
+ it('registers on the native module and subscribes to onCampaignPresent', async () => {
103
104
  const remove = jest.fn();
104
105
  mockNativeModule.onCampaignPresent.mockReturnValue({ remove });
105
106
  const presenter = jest.fn();
@@ -107,11 +108,22 @@ describe('Amply gate API (trackGated + registerGate)', () => {
107
108
  expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
108
109
  expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
109
110
  expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
110
- // Second registration of the same baseUrl re-subscribes but does NOT re-register.
111
- await Amply.registerGate('stillframe://ad', jest.fn());
112
- expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
113
111
  unsubscribe();
114
112
  expect(remove).toHaveBeenCalledTimes(1);
113
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledWith('stillframe://ad');
114
+ });
115
+ // This used to assert the opposite — that a second registration re-subscribed but did
116
+ // NOT re-register natively. That encoded the defect: the second subscriber joined the
117
+ // shared onCampaignPresent channel while the first stayed on it, so one presentation was
118
+ // handed to both presenters, and the second call's onAbort/timeout were silently dropped.
119
+ // See https://trello.com/c/Gq589zwz; the full behaviour is covered in
120
+ // gateRegistrationLifecycle.test.ts.
121
+ it('replaces the previous registration when the same baseUrl is registered again', async () => {
122
+ mockNativeModule.onCampaignPresent.mockReturnValue({ remove: jest.fn() });
123
+ await Amply.registerGate('stillframe://ad', jest.fn());
124
+ await Amply.registerGate('stillframe://ad', jest.fn(), { timeoutMs: 5000 });
125
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledWith('stillframe://ad');
126
+ expect(mockNativeModule.registerGate).toHaveBeenLastCalledWith('stillframe://ad', 'cancel', 5000);
115
127
  });
116
128
  it('passes through onAbort and timeoutMs options', async () => {
117
129
  mockNativeModule.onCampaignPresent.mockReturnValue({ remove: jest.fn() });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gateRegistrationLifecycle.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateRegistrationLifecycle.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/gateRegistrationLifecycle.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,215 @@
1
+ import * as Amply from '../index';
2
+ import { __setNativeModule } from '../nativeModule';
3
+ async function flushMicrotasks() {
4
+ for (let i = 0; i < 5; i++) {
5
+ await Promise.resolve();
6
+ }
7
+ }
8
+ /**
9
+ * The registration lifecycle of a gate: who receives a presentation, what withdrawal
10
+ * actually withdraws, and what happens to a registration made before the SDK is up.
11
+ *
12
+ * All three were found on 2026-07-31 — the first two by reading the bridge after a field
13
+ * report on Happens, the third by the field report itself. They share one cause: the JS side
14
+ * keeps its own record of what is registered natively, and that record was written
15
+ * optimistically and never reconciled.
16
+ *
17
+ * Trello: gate eviction https://trello.com/c/Gq589zwz, withdrawal
18
+ * https://trello.com/c/saf8I9c6, pre-init registration https://trello.com/c/TZCixnYi
19
+ */
20
+ describe('gate registration lifecycle', () => {
21
+ /** Every listener currently attached to the shared onCampaignPresent channel. */
22
+ let emitters;
23
+ const mockNativeModule = {
24
+ initialize: jest.fn(),
25
+ isInitialized: jest.fn(() => true),
26
+ track: jest.fn(),
27
+ trackGated: jest.fn(),
28
+ resolveCampaign: jest.fn(),
29
+ getRecentEvents: jest.fn(),
30
+ getDataSetSnapshot: jest.fn(),
31
+ registerDeepLinkListener: jest.fn(),
32
+ registerGate: jest.fn(),
33
+ unregisterGate: jest.fn(),
34
+ addListener: jest.fn(),
35
+ removeListeners: jest.fn(),
36
+ onSystemEvent: jest.fn(),
37
+ onDeepLink: jest.fn(),
38
+ onCampaignPresent: jest.fn(),
39
+ setCustomProperties: jest.fn(),
40
+ getCustomProperty: jest.fn(),
41
+ removeCustomProperty: jest.fn(),
42
+ clearCustomProperties: jest.fn(),
43
+ };
44
+ beforeEach(() => {
45
+ emitters = [];
46
+ mockNativeModule.isInitialized.mockReturnValue(true);
47
+ mockNativeModule.onCampaignPresent.mockImplementation((listener) => {
48
+ emitters.push(listener);
49
+ // `remove` must actually detach, as a real RN subscription does — otherwise this
50
+ // harness reports a listener as live after it was removed, and every eviction and
51
+ // withdrawal assertion below would be measuring the mock rather than the code.
52
+ const remove = jest.fn(() => {
53
+ const at = emitters.indexOf(listener);
54
+ if (at >= 0) {
55
+ emitters.splice(at, 1);
56
+ }
57
+ });
58
+ return { remove };
59
+ });
60
+ mockNativeModule.onDeepLink.mockReturnValue({ remove: jest.fn() });
61
+ __setNativeModule(mockNativeModule);
62
+ });
63
+ afterEach(() => {
64
+ Amply.removeAllListeners();
65
+ jest.clearAllMocks();
66
+ __setNativeModule(null);
67
+ });
68
+ /** Delivers one presentation to every listener currently subscribed to the channel. */
69
+ function emitPresentation(mediationId = 'm1') {
70
+ const event = {
71
+ url: 'stillframe://ad?adUnit=main',
72
+ params: { adUnit: 'main' },
73
+ info: { campaignId: 'c1' },
74
+ mediationId,
75
+ };
76
+ emitters.forEach(emit => emit(event));
77
+ }
78
+ describe('a second registration on the same baseUrl (Gq589zwz)', () => {
79
+ it('leaves exactly one live presenter, not two', async () => {
80
+ const first = jest.fn((_p, _i, r) => r.completed());
81
+ const second = jest.fn((_p, _i, r) => r.completed());
82
+ await Amply.registerGate('stillframe://ad', first);
83
+ await Amply.registerGate('stillframe://ad', second);
84
+ emitPresentation();
85
+ await flushMicrotasks();
86
+ // Both presenters showing UI for one presentation is the defect: the outcome is then
87
+ // decided by whichever reported first, and the loser's UI is orphaned on screen.
88
+ expect(second).toHaveBeenCalledTimes(1);
89
+ expect(first).not.toHaveBeenCalled();
90
+ });
91
+ it('applies the options of the newest registration', async () => {
92
+ await Amply.registerGate('stillframe://ad', jest.fn(), {
93
+ onAbort: 'cancel',
94
+ timeoutMs: 1000,
95
+ });
96
+ await Amply.registerGate('stillframe://ad', jest.fn(), {
97
+ onAbort: 'proceed',
98
+ timeoutMs: 8000,
99
+ });
100
+ // Previously the second call was a no-op natively, so its onAbort and timeout were
101
+ // silently discarded and the gate kept running under the first call's policy.
102
+ expect(mockNativeModule.registerGate).toHaveBeenLastCalledWith('stillframe://ad', 'proceed', 8000);
103
+ });
104
+ it('keeps gates on different baseUrls independent', async () => {
105
+ const ad = jest.fn((_p, _i, r) => r.completed());
106
+ const paywall = jest.fn((_p, _i, r) => r.completed());
107
+ await Amply.registerGate('stillframe://ad', ad);
108
+ await Amply.registerGate('stillframe://paywall', paywall);
109
+ emitPresentation();
110
+ await flushMicrotasks();
111
+ // Only the URL-matching presenter runs; evicting by baseUrl must not evict a sibling.
112
+ expect(ad).toHaveBeenCalledTimes(1);
113
+ expect(paywall).not.toHaveBeenCalled();
114
+ });
115
+ });
116
+ describe('withdrawal (saf8I9c6)', () => {
117
+ it('withdraws the native gate, not just the JS subscription', async () => {
118
+ const unsubscribe = await Amply.registerGate('stillframe://ad', jest.fn());
119
+ unsubscribe();
120
+ // Without this the gate outlives its presenter and parks the next gated call on that
121
+ // URL for the whole fail-open timeout — silently, with no crash and no console trace.
122
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledWith('stillframe://ad');
123
+ });
124
+ it('lets the same baseUrl be registered again after withdrawal', async () => {
125
+ const unsubscribe = await Amply.registerGate('stillframe://ad', jest.fn());
126
+ unsubscribe();
127
+ const presenter = jest.fn((_p, _i, r) => r.completed());
128
+ await Amply.registerGate('stillframe://ad', presenter);
129
+ emitPresentation();
130
+ await flushMicrotasks();
131
+ expect(presenter).toHaveBeenCalledTimes(1);
132
+ });
133
+ /**
134
+ * Found by codex, not by me. Withdrawal was keyed by URL alone, so an unsubscribe handed
135
+ * out for a registration that has since been evicted still withdrew whatever holds that
136
+ * URL now — killing its replacement. A stale handle must be inert, not destructive.
137
+ */
138
+ it('an evicted registration\'s unsubscribe does not withdraw its replacement', async () => {
139
+ const withdrawFirst = await Amply.registerGate('stillframe://ad', jest.fn());
140
+ const second = jest.fn((_p, _i, r) => r.completed());
141
+ await Amply.registerGate('stillframe://ad', second);
142
+ mockNativeModule.unregisterGate.mockClear();
143
+ withdrawFirst();
144
+ expect(mockNativeModule.unregisterGate).not.toHaveBeenCalled();
145
+ emitPresentation();
146
+ await flushMicrotasks();
147
+ expect(second).toHaveBeenCalledTimes(1);
148
+ });
149
+ it('is idempotent — a second call withdraws nothing further', async () => {
150
+ const unsubscribe = await Amply.registerGate('stillframe://ad', jest.fn());
151
+ unsubscribe();
152
+ unsubscribe();
153
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledTimes(1);
154
+ });
155
+ it('removeAllListeners withdraws every native gate too', async () => {
156
+ await Amply.registerGate('stillframe://ad', jest.fn());
157
+ await Amply.registerGate('stillframe://paywall', jest.fn());
158
+ Amply.removeAllListeners();
159
+ // It used to clear the JS bookkeeping only, leaving JS believing nothing was
160
+ // registered while both gates were still live natively.
161
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledWith('stillframe://ad');
162
+ expect(mockNativeModule.unregisterGate).toHaveBeenCalledWith('stillframe://paywall');
163
+ });
164
+ });
165
+ describe('registration before initialize (TZCixnYi)', () => {
166
+ it('replays a gate registration once the SDK is up', async () => {
167
+ mockNativeModule.isInitialized.mockReturnValue(false);
168
+ const presenter = jest.fn((_p, _i, r) => r.completed());
169
+ await Amply.registerGate('stillframe://ad', presenter);
170
+ // Native refuses while uninitialised; JS must not record that as done.
171
+ expect(mockNativeModule.registerGate).not.toHaveBeenCalled();
172
+ mockNativeModule.isInitialized.mockReturnValue(true);
173
+ await Amply.initialize({ apiKey: 'k', apiKeySecret: 's' });
174
+ expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
175
+ emitPresentation();
176
+ await flushMicrotasks();
177
+ expect(presenter).toHaveBeenCalledTimes(1);
178
+ });
179
+ it('replays a deeplink listener once the SDK is up', async () => {
180
+ mockNativeModule.isInitialized.mockReturnValue(false);
181
+ await Amply.addDeepLinkListener(jest.fn());
182
+ expect(mockNativeModule.registerDeepLinkListener).not.toHaveBeenCalled();
183
+ mockNativeModule.isInitialized.mockReturnValue(true);
184
+ await Amply.initialize({ apiKey: 'k', apiKeySecret: 's' });
185
+ expect(mockNativeModule.registerDeepLinkListener).toHaveBeenCalledTimes(1);
186
+ });
187
+ /**
188
+ * Found by self-review, not by the original pass. Eviction ran at `registerGate()` time,
189
+ * but a queued registration is not in the registry yet, so there was nothing to evict —
190
+ * both attached at replay and the second overwrote the map entry without removing the
191
+ * first subscription. That is the duplicate-presenter defect again, on the pre-init path.
192
+ */
193
+ it('two registrations on one baseUrl before init still leave one live presenter', async () => {
194
+ mockNativeModule.isInitialized.mockReturnValue(false);
195
+ const first = jest.fn((_p, _i, r) => r.completed());
196
+ const second = jest.fn((_p, _i, r) => r.completed());
197
+ await Amply.registerGate('stillframe://ad', first);
198
+ await Amply.registerGate('stillframe://ad', second);
199
+ mockNativeModule.isInitialized.mockReturnValue(true);
200
+ await Amply.initialize({ apiKey: 'k', apiKeySecret: 's' });
201
+ emitPresentation();
202
+ await flushMicrotasks();
203
+ expect(second).toHaveBeenCalledTimes(1);
204
+ expect(first).not.toHaveBeenCalled();
205
+ });
206
+ it('withdrawing a queued gate cancels it instead of replaying it', async () => {
207
+ mockNativeModule.isInitialized.mockReturnValue(false);
208
+ const unsubscribe = await Amply.registerGate('stillframe://ad', jest.fn());
209
+ unsubscribe();
210
+ mockNativeModule.isInitialized.mockReturnValue(true);
211
+ await Amply.initialize({ apiKey: 'k', apiKeySecret: 's' });
212
+ expect(mockNativeModule.registerGate).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+ });
@@ -103,7 +103,17 @@ export declare function trackGated(event: string, properties?: Record<string, un
103
103
  * ad lifecycles into the single result you report. A synchronous throw → `unavailable`
104
104
  * (fail-open).
105
105
  *
106
- * @returns an unsubscribe function.
106
+ * One presenter per `baseUrl`: registering the same URL again REPLACES the previous
107
+ * presenter and re-applies the options, rather than adding a second one. Two presenters on
108
+ * one URL would both be handed the same presentation, so both would show UI and the outcome
109
+ * would be decided by whichever reported first.
110
+ *
111
+ * Safe to call before {@link initialize} — the registration is held and applied once the SDK
112
+ * is up. Withdrawing before then cancels it instead.
113
+ *
114
+ * @returns an unsubscribe function. It withdraws the NATIVE gate as well as this
115
+ * subscription; a gate left registered behind a departed presenter parks the next gated call
116
+ * on that URL for its whole fail-open timeout, silently.
107
117
  */
108
118
  export declare function registerGate(baseUrl: string, presenter: GatePresenter, opts?: RegisterGateOptions): Promise<() => void>;
109
119
  export declare function getRecentEvents(limit: number): Promise<EventRecord[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}