@trainstar/synchro-react-native 0.4.0-rc.3 → 0.4.0-rc.4

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.
@@ -0,0 +1,173 @@
1
+ import Foundation
2
+ import os
3
+ import React
4
+
5
+ /// Values that can cross wrapper execution boundaries without carrying Foundation containers.
6
+ enum BridgeValue: Sendable {
7
+ case null
8
+ case bool(Bool)
9
+ case integer(Int64)
10
+ case double(Double)
11
+ case string(String)
12
+ case array([BridgeValue])
13
+ case object([String: BridgeValue])
14
+
15
+ func foundationValue() -> Any {
16
+ switch self {
17
+ case .null:
18
+ return NSNull()
19
+ case .bool(let value):
20
+ return NSNumber(value: value)
21
+ case .integer(let value):
22
+ return NSNumber(value: value)
23
+ case .double(let value):
24
+ return NSNumber(value: value)
25
+ case .string(let value):
26
+ return value
27
+ case .array(let values):
28
+ return values.map { $0.foundationValue() }
29
+ case .object(let values):
30
+ return values.mapValues { $0.foundationValue() }
31
+ }
32
+ }
33
+ }
34
+
35
+ enum BridgePromiseValue: Sendable {
36
+ case void
37
+ case value(BridgeValue)
38
+ }
39
+
40
+ /// Owns the imported React promise blocks and settles them exactly once.
41
+ ///
42
+ /// React Native copies these blocks for an escaping module invocation. Its TurboModule
43
+ /// implementation protects its shared resolve/reject state with a mutex and schedules
44
+ /// JavaScript through `AsyncCallback`. React Native also permits native callbacks from
45
+ /// any queue. Swift cannot express those Objective-C runtime guarantees as `Sendable`.
46
+ final class ReactPromiseSettlement: @unchecked Sendable {
47
+ private let lock = NSLock()
48
+ private var resolveBlock: RCTPromiseResolveBlock?
49
+ private var rejectBlock: RCTPromiseRejectBlock?
50
+
51
+ init(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
52
+ resolveBlock = resolve
53
+ rejectBlock = reject
54
+ }
55
+
56
+ @discardableResult
57
+ func resolve(_ value: BridgePromiseValue) -> Bool {
58
+ lock.lock()
59
+ guard let resolveBlock else {
60
+ lock.unlock()
61
+ return false
62
+ }
63
+ self.resolveBlock = nil
64
+ rejectBlock = nil
65
+ lock.unlock()
66
+
67
+ switch value {
68
+ case .void:
69
+ resolveBlock(nil)
70
+ case .value(let value):
71
+ resolveBlock(value.foundationValue())
72
+ }
73
+ return true
74
+ }
75
+
76
+ @discardableResult
77
+ func reject(_ code: String?, _ message: String?, _ error: Error?) -> Bool {
78
+ lock.lock()
79
+ guard let rejectBlock else {
80
+ lock.unlock()
81
+ return false
82
+ }
83
+ resolveBlock = nil
84
+ self.rejectBlock = nil
85
+ lock.unlock()
86
+
87
+ rejectBlock(code, message, error)
88
+ return true
89
+ }
90
+ }
91
+
92
+ /// Owns the Objective-C event target without creating an implementation cycle.
93
+ ///
94
+ /// The delegate is installed by Objective-C++, can be released outside Swift, and is
95
+ /// invoked only on main. The lock protects the weak reference, not callback execution.
96
+ final class ReactEventTarget: @unchecked Sendable {
97
+ private let lock = NSLock()
98
+ private weak var delegate: SynchroEventEmitting?
99
+
100
+ var currentDelegate: SynchroEventEmitting? {
101
+ lock.lock()
102
+ defer { lock.unlock() }
103
+ return delegate
104
+ }
105
+
106
+ func setDelegate(_ delegate: SynchroEventEmitting?) {
107
+ lock.lock()
108
+ self.delegate = delegate
109
+ lock.unlock()
110
+ }
111
+
112
+ func emit(_ name: String, body: BridgeValue) {
113
+ DispatchQueue.main.async { [self, name, body] in
114
+ lock.lock()
115
+ let delegate = self.delegate
116
+ lock.unlock()
117
+ guard let payload = body.foundationValue() as? NSDictionary else {
118
+ preconditionFailure("Event payload must be an object")
119
+ }
120
+ delegate?.emitEvent(name, body: payload)
121
+ }
122
+ }
123
+ }
124
+
125
+ final class AuthRequestStore: Sendable {
126
+ private struct State: Sendable {
127
+ var acceptsRequests = false
128
+ var continuations: [String: CheckedContinuation<String, Error>] = [:]
129
+ }
130
+
131
+ private let state = OSAllocatedUnfairLock(initialState: State())
132
+
133
+ func activate() {
134
+ state.withLock { $0.acceptsRequests = true }
135
+ }
136
+
137
+ func insert(_ continuation: CheckedContinuation<String, Error>, for requestID: String) -> Bool {
138
+ state.withLock { state in
139
+ guard state.acceptsRequests else { return false }
140
+ state.continuations[requestID] = continuation
141
+ return true
142
+ }
143
+ }
144
+
145
+ func resolve(_ requestID: String, token: String) {
146
+ let continuation = state.withLock { $0.continuations.removeValue(forKey: requestID) }
147
+ continuation?.resume(returning: token)
148
+ }
149
+
150
+ func reject(_ requestID: String, error: Error) {
151
+ let continuation = state.withLock { $0.continuations.removeValue(forKey: requestID) }
152
+ continuation?.resume(throwing: error)
153
+ }
154
+
155
+ func cancelAll() {
156
+ let continuations = state.withLock { state in
157
+ state.acceptsRequests = false
158
+ let continuations = Array(state.continuations.values)
159
+ state.continuations.removeAll()
160
+ return continuations
161
+ }
162
+ let error = NSError(
163
+ domain: "SynchroModule",
164
+ code: 0,
165
+ userInfo: [NSLocalizedDescriptionKey: "client closed"]
166
+ )
167
+ continuations.forEach { $0.resume(throwing: error) }
168
+ }
169
+
170
+ var count: Int {
171
+ state.withLock { $0.continuations.count }
172
+ }
173
+ }
@@ -39,10 +39,3 @@
39
39
  }
40
40
 
41
41
  @end
42
-
43
- int main(int argc, char *argv[])
44
- {
45
- @autoreleasepool {
46
- return UIApplicationMain(argc, argv, nil, NSStringFromClass([SynchroTestHostAppDelegate class]));
47
- }
48
- }
@@ -0,0 +1,12 @@
1
+ import Darwin
2
+ import UIKit
3
+
4
+ let status = autoreleasepool {
5
+ UIApplicationMain(
6
+ CommandLine.argc,
7
+ CommandLine.unsafeArgv,
8
+ nil,
9
+ "SynchroTestHostAppDelegate"
10
+ )
11
+ }
12
+ exit(status)
@@ -244,6 +244,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
244
244
  private var events: EventRecorder!
245
245
  private var cleanup: CleanupState!
246
246
  private var databasePath: String!
247
+ private var externalConnections: [ExternalConnection] = []
247
248
 
248
249
  override func setUpWithError() throws {
249
250
  try super.setUpWithError()
@@ -257,32 +258,51 @@ final class SynchroModuleTransactionTests: XCTestCase {
257
258
  self.events = events
258
259
  self.cleanup = cleanup
259
260
  self.databasePath = databasePath
260
- // Teardown blocks run in reverse order. This block is first, so every gate a test
261
- // registers later is released before close joins the registered transactions.
262
- addTeardownBlock {
263
- withExtendedLifetime(events) {
264
- let close = PromiseRecorder("teardown close")
265
- module.close(close.resolve, reject: close.reject)
266
- let settled = XCTWaiter().wait(for: [close.firstSettlement], timeout: settlementTimeout) == .completed
267
- guard settled, close.resolutions.count == 1, close.rejections.isEmpty else {
268
- XCTFail("teardown close did not resolve once (settled: \(settled), resolutions: \(close.resolutions.count), rejections: \(close.rejections)). Kept \(databasePath).")
269
- return
270
- }
271
- guard !cleanup.hasFailure else {
272
- XCTFail("a test-owned resource did not close. Kept \(databasePath).")
273
- return
274
- }
275
- for suffix in ["", "-wal", "-shm"] {
276
- let path = databasePath + suffix
277
- guard FileManager.default.fileExists(atPath: path) else { continue }
278
- do {
279
- try FileManager.default.removeItem(atPath: path)
280
- } catch {
281
- XCTFail("remove \(path): \(error)")
282
- }
261
+ }
262
+
263
+ override func tearDownWithError() throws {
264
+ let teardownDatabasePath = databasePath ?? "unknown database path"
265
+ var moduleClosed = false
266
+ for connection in externalConnections.reversed() {
267
+ do {
268
+ try connection.close()
269
+ } catch {
270
+ cleanup.recordFailure()
271
+ XCTFail("close external connection: \(error)")
272
+ }
273
+ }
274
+ externalConnections.removeAll()
275
+
276
+ if let module {
277
+ let close = PromiseRecorder("teardown close")
278
+ module.close(close.resolve, reject: close.reject)
279
+ let settled = XCTWaiter().wait(for: [close.firstSettlement], timeout: settlementTimeout) == .completed
280
+ moduleClosed = settled && close.resolutions.count == 1 && close.rejections.isEmpty
281
+ if !moduleClosed {
282
+ XCTFail("teardown close did not resolve once (settled: \(settled), resolutions: \(close.resolutions.count), rejections: \(close.rejections)). Kept \(teardownDatabasePath).")
283
+ }
284
+ module.eventDelegate = nil
285
+ }
286
+ withExtendedLifetime(events) {}
287
+
288
+ if cleanup.hasFailure {
289
+ XCTFail("a test-owned resource did not close. Kept \(teardownDatabasePath).")
290
+ } else if moduleClosed {
291
+ for suffix in ["", "-wal", "-shm"] {
292
+ let path = teardownDatabasePath + suffix
293
+ guard FileManager.default.fileExists(atPath: path) else { continue }
294
+ do {
295
+ try FileManager.default.removeItem(atPath: path)
296
+ } catch {
297
+ XCTFail("remove \(path): \(error)")
283
298
  }
284
299
  }
285
300
  }
301
+ module = nil
302
+ events = nil
303
+ cleanup = nil
304
+ databasePath = nil
305
+ try super.tearDownWithError()
286
306
  }
287
307
 
288
308
  func testAcquisitionFailureRejectsBeginOnce() throws {
@@ -392,21 +412,15 @@ final class SynchroModuleTransactionTests: XCTestCase {
392
412
  let held = try holdReadTransactions(count: 5)
393
413
  let ordinary = PromiseRecorder("ordinary query with five readers")
394
414
  let commits = held.ids.indices.map { PromiseRecorder("commit reader \($0)") }
395
- let invocationReturned = XCTestExpectation(description: "ordinary query invocation returns")
396
- let methodQueue = DispatchQueue(label: "test.react-native.query-dispatch")
397
415
  let module = try XCTUnwrap(self.module)
398
416
 
399
- methodQueue.async {
400
- module.query("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
401
- invocationReturned.fulfill()
402
- }
403
- methodQueue.async {
404
- for (index, txID) in held.ids.enumerated() {
405
- module.commitTransaction(txID, resolve: commits[index].resolve, reject: commits[index].reject)
406
- }
417
+ let invocationStarted = Date()
418
+ module.query("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
419
+ XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout)
420
+ for (index, txID) in held.ids.enumerated() {
421
+ module.commitTransaction(txID, resolve: commits[index].resolve, reject: commits[index].reject)
407
422
  }
408
423
 
409
- XCTAssertEqual(XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout), .completed)
410
424
  waitForSettlements([ordinary] + commits, timeout: promptDispatchTimeout)
411
425
  XCTAssertEqual((try resolvedValue(ordinary) as? [[String: Any]])?.count, 1)
412
426
  let close = try closeModule()
@@ -420,21 +434,15 @@ final class SynchroModuleTransactionTests: XCTestCase {
420
434
  let held = try holdReadTransactions(count: 5)
421
435
  let ordinary = PromiseRecorder("ordinary query one with five readers")
422
436
  let rollbacks = held.ids.indices.map { PromiseRecorder("rollback reader \($0)") }
423
- let invocationReturned = XCTestExpectation(description: "ordinary query one invocation returns")
424
- let methodQueue = DispatchQueue(label: "test.react-native.query-one-dispatch")
425
437
  let module = try XCTUnwrap(self.module)
426
438
 
427
- methodQueue.async {
428
- module.queryOne("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
429
- invocationReturned.fulfill()
430
- }
431
- methodQueue.async {
432
- for (index, txID) in held.ids.enumerated() {
433
- module.rollbackTransaction(txID, resolve: rollbacks[index].resolve, reject: rollbacks[index].reject)
434
- }
439
+ let invocationStarted = Date()
440
+ module.queryOne("SELECT 1", params: [], resolve: ordinary.resolve, reject: ordinary.reject)
441
+ XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout)
442
+ for (index, txID) in held.ids.enumerated() {
443
+ module.rollbackTransaction(txID, resolve: rollbacks[index].resolve, reject: rollbacks[index].reject)
435
444
  }
436
445
 
437
- XCTAssertEqual(XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout), .completed)
438
446
  waitForSettlements([ordinary] + rollbacks, timeout: promptDispatchTimeout)
439
447
  XCTAssertEqual((try resolvedValue(ordinary) as? [String: Any])?.count, 1)
440
448
  let close = try closeModule()
@@ -446,7 +454,6 @@ final class SynchroModuleTransactionTests: XCTestCase {
446
454
  func testOrdinaryWriterSurfacesDoNotBlockWriteTransactionCommands() throws {
447
455
  try initializeModule()
448
456
  let module = try XCTUnwrap(self.module)
449
- let methodQueue = DispatchQueue(label: "test.react-native.writer-dispatch")
450
457
  let ordinaryCalls: [(String, (PromiseRecorder) -> Void)] = [
451
458
  ("execute", { recorder in
452
459
  module.execute("SELECT 1", params: [], resolve: recorder.resolve, reject: recorder.reject)
@@ -482,21 +489,11 @@ final class SynchroModuleTransactionTests: XCTestCase {
482
489
  }
483
490
  let ordinary = PromiseRecorder(name)
484
491
  let commit = PromiseRecorder("commit after \(name)")
485
- let invocationReturned = XCTestExpectation(description: "\(name) invocation returns")
486
-
487
- methodQueue.async {
488
- call(ordinary)
489
- invocationReturned.fulfill()
490
- }
491
- methodQueue.async {
492
- module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
493
- }
492
+ let invocationStarted = Date()
493
+ call(ordinary)
494
+ XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout, name)
495
+ module.commitTransaction(txID, resolve: commit.resolve, reject: commit.reject)
494
496
 
495
- XCTAssertEqual(
496
- XCTWaiter().wait(for: [invocationReturned], timeout: promptDispatchTimeout),
497
- .completed,
498
- name
499
- )
500
497
  waitForSettlements([ordinary, commit])
501
498
  recorders += [begin, probe, ordinary, commit]
502
499
  }
@@ -673,6 +670,40 @@ final class SynchroModuleTransactionTests: XCTestCase {
673
670
  XCTAssertEqual(events.names, [])
674
671
  }
675
672
 
673
+ func testUnsupportedCyclicSQLParametersRejectPromptlyAndSettleOnce() throws {
674
+ try initializeModule()
675
+ let cyclicArray = NSMutableArray()
676
+ cyclicArray.add(cyclicArray)
677
+ let cyclicDictionary = NSMutableDictionary()
678
+ cyclicDictionary["type"] = "bytes"
679
+ cyclicDictionary["base64"] = cyclicDictionary
680
+ let inputs: [(String, Any)] = [
681
+ ("cyclic array", cyclicArray),
682
+ ("cyclic dictionary", cyclicDictionary),
683
+ ]
684
+ var rejections: [PromiseRecorder] = []
685
+
686
+ for (name, value) in inputs {
687
+ let recorder = PromiseRecorder(name)
688
+ let invocationStarted = Date()
689
+ module.query("SELECT ?", params: [value], resolve: recorder.resolve, reject: recorder.reject)
690
+ XCTAssertLessThan(Date().timeIntervalSince(invocationStarted), promptDispatchTimeout, name)
691
+ waitForSettlements([recorder], timeout: promptDispatchTimeout)
692
+ rejections.append(recorder)
693
+ }
694
+
695
+ cyclicArray.removeAllObjects()
696
+ cyclicDictionary.removeAllObjects()
697
+ let close = try closeModule()
698
+ for recorder in rejections {
699
+ XCTAssertEqual(recorder.resolutions.count, 0, recorder.name)
700
+ XCTAssertEqual(recorder.rejections, ["UNKNOWN"], recorder.name)
701
+ XCTAssertEqual(recorder.settlementCount, 1, recorder.name)
702
+ }
703
+ requireSettledOnce([close])
704
+ XCTAssertEqual(events.names, [])
705
+ }
706
+
676
707
  // MARK: - Inspection snapshot
677
708
 
678
709
  func testClientStateSnapshotReadsRequestedRowsInsideReadOnlySnapshot() throws {
@@ -874,15 +905,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
874
905
  private func openExternalConnection() throws -> ExternalConnection {
875
906
  let getPath = settle("get path") { module.getPath($0.resolve, reject: $0.reject) }
876
907
  let connection = try ExternalConnection(path: try XCTUnwrap(try resolvedValue(getPath) as? String))
877
- let cleanup = self.cleanup!
878
- addTeardownBlock {
879
- do {
880
- try connection.close()
881
- } catch {
882
- cleanup.recordFailure()
883
- XCTFail("close external connection: \(error)")
884
- }
885
- }
908
+ externalConnections.append(connection)
886
909
  return connection
887
910
  }
888
911
 
@@ -909,9 +932,7 @@ final class SynchroModuleTransactionTests: XCTestCase {
909
932
 
910
933
  private func registrySnapshot() throws -> [String] {
911
934
  let module = try XCTUnwrap(self.module)
912
- module.sessionsLock.lock()
913
- defer { module.sessionsLock.unlock() }
914
- return Array(module.sessions.keys)
935
+ return module.activeTransactionIDs
915
936
  }
916
937
 
917
938
  /// Close removes every registered session under the registry lock before it waits for them.
@@ -1,4 +1,5 @@
1
1
  @testable import SynchroReactNative
2
+ import os
2
3
  import XCTest
3
4
 
4
5
  private struct SessionError: Error, Equatable {
@@ -6,21 +7,95 @@ private struct SessionError: Error, Equatable {
6
7
  }
7
8
 
8
9
  /// Records the begin completions that the actual session delivers.
9
- private final class BeginRecorder {
10
- private(set) var results: [Result<String, Error>] = []
11
-
12
- func record(_ result: Result<String, Error>) {
13
- results.append(result)
10
+ private final class BeginRecorder: Sendable {
11
+ private struct State: Sendable {
12
+ var successes: [String] = []
13
+ var failures: [SessionError?] = []
14
14
  }
15
15
 
16
+ private let state = OSAllocatedUnfairLock(initialState: State())
17
+
16
18
  var successes: [String] {
17
- results.compactMap { try? $0.get() }
19
+ state.withLock { $0.successes }
18
20
  }
19
21
 
20
22
  var failures: [SessionError?] {
21
- results.compactMap { result in
22
- guard case .failure(let error) = result else { return nil }
23
- return .some(error as? SessionError)
23
+ state.withLock { $0.failures }
24
+ }
25
+
26
+ func settlement() -> ReactPromiseSettlement {
27
+ ReactPromiseSettlement(
28
+ resolve: { [self] value in
29
+ let stringValue = value as? String
30
+ state.withLock { state in
31
+ if let stringValue {
32
+ state.successes.append(stringValue)
33
+ }
34
+ }
35
+ },
36
+ reject: { [self] _, _, error in
37
+ state.withLock { $0.failures.append(error as? SessionError) }
38
+ }
39
+ )
40
+ }
41
+ }
42
+
43
+ private final class SettlementCounter: Sendable {
44
+ private let countState = OSAllocatedUnfairLock(initialState: 0)
45
+
46
+ var count: Int {
47
+ countState.withLock { $0 }
48
+ }
49
+
50
+ func increment() {
51
+ countState.withLock { $0 += 1 }
52
+ }
53
+ }
54
+
55
+ private final class EventRecorder: NSObject, SynchroEventEmitting {
56
+ private struct State {
57
+ var name: String?
58
+ var body: NSDictionary?
59
+ var deliveredOnMain = false
60
+ }
61
+
62
+ private let state = NSLock()
63
+ private var value = State()
64
+ let expectation: XCTestExpectation
65
+
66
+ init(expectation: XCTestExpectation) {
67
+ self.expectation = expectation
68
+ }
69
+
70
+ func emitEvent(_ name: String, body: NSDictionary) {
71
+ state.lock()
72
+ value.name = name
73
+ value.body = body
74
+ value.deliveredOnMain = Thread.isMainThread
75
+ state.unlock()
76
+ expectation.fulfill()
77
+ }
78
+
79
+ func snapshot() -> (String?, NSDictionary?, Bool) {
80
+ state.lock()
81
+ defer { state.unlock() }
82
+ return (value.name, value.body, value.deliveredOnMain)
83
+ }
84
+ }
85
+
86
+ private struct AuthStoreTimeout: Error {}
87
+
88
+ private func pendingAuthRequest(
89
+ _ store: AuthRequestStore,
90
+ id: String,
91
+ insertion: CheckedContinuation<Bool, Never>
92
+ ) async throws -> String {
93
+ try await withCheckedThrowingContinuation { continuation in
94
+ let inserted = store.insert(continuation, for: id)
95
+ insertion.resume(returning: inserted)
96
+ guard inserted else {
97
+ continuation.resume(throwing: AuthStoreTimeout())
98
+ return
24
99
  }
25
100
  }
26
101
  }
@@ -29,7 +104,7 @@ private final class BeginRecorder {
29
104
  final class TransactionSessionTests: XCTestCase {
30
105
  func testFailureBeforeCallbackRejectsStoredBeginOnce() {
31
106
  let begin = BeginRecorder()
32
- let session = TransactionSession(isWrite: true, beginCompletion: begin.record)
107
+ let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
33
108
 
34
109
  session.rejectBegin(SessionError(name: "acquisition"))
35
110
  session.rejectBegin(SessionError(name: "later failure"))
@@ -40,7 +115,7 @@ final class TransactionSessionTests: XCTestCase {
40
115
 
41
116
  func testAbortBeforeBeginAcceptanceRejectsBeginAndUnwindsCallback() {
42
117
  let begin = BeginRecorder()
43
- let session = TransactionSession(isWrite: true, beginCompletion: begin.record)
118
+ let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
44
119
 
45
120
  session.abort(SessionError(name: "close"))
46
121
  XCTAssertThrowsError(try session.acceptBegin("abort-before-begin")) { error in
@@ -54,7 +129,7 @@ final class TransactionSessionTests: XCTestCase {
54
129
 
55
130
  func testAcceptedBeginIsNotSettledAgainByLaterFailure() throws {
56
131
  let begin = BeginRecorder()
57
- let session = TransactionSession(isWrite: true, beginCompletion: begin.record)
132
+ let session = TransactionSession(isWrite: true, beginSettlement: begin.settlement())
58
133
 
59
134
  try session.acceptBegin("accepted")
60
135
  session.abort(SessionError(name: "close"))
@@ -63,4 +138,84 @@ final class TransactionSessionTests: XCTestCase {
63
138
  XCTAssertEqual(begin.successes, ["accepted"])
64
139
  XCTAssertEqual(begin.failures, [])
65
140
  }
141
+
142
+ func testPromiseSettlementRaceInvokesOneBlock() {
143
+ let counter = SettlementCounter()
144
+ let settlement = ReactPromiseSettlement(
145
+ resolve: { _ in counter.increment() },
146
+ reject: { _, _, _ in counter.increment() }
147
+ )
148
+
149
+ DispatchQueue.concurrentPerform(iterations: 100) { index in
150
+ if index.isMultiple(of: 2) {
151
+ settlement.resolve(.void)
152
+ } else {
153
+ settlement.reject("TEST", "test", nil)
154
+ }
155
+ }
156
+
157
+ XCTAssertEqual(counter.count, 1)
158
+ }
159
+
160
+ func testPromiseSettlementUnlocksBeforeReentrantInvocation() {
161
+ let counter = SettlementCounter()
162
+ let holder = OSAllocatedUnfairLock<ReactPromiseSettlement?>(initialState: nil)
163
+ let settlement = ReactPromiseSettlement(
164
+ resolve: { _ in
165
+ counter.increment()
166
+ holder.withLock { $0 }?.reject("REENTRANT", "reentrant", nil)
167
+ },
168
+ reject: { _, _, _ in counter.increment() }
169
+ )
170
+ holder.withLock { $0 = settlement }
171
+
172
+ settlement.resolve(.void)
173
+
174
+ XCTAssertEqual(counter.count, 1)
175
+ }
176
+
177
+ func testEventTargetIsWeakAndDeliversOnMain() {
178
+ let event = expectation(description: "event")
179
+ let target = ReactEventTarget()
180
+ var recorder: EventRecorder? = EventRecorder(expectation: event)
181
+ target.setDelegate(recorder)
182
+
183
+ target.emit("onTest", body: .object(["value": .string("ok")]))
184
+ wait(for: [event], timeout: 1)
185
+
186
+ let snapshot = recorder?.snapshot()
187
+ XCTAssertEqual(snapshot?.0, "onTest")
188
+ XCTAssertEqual(snapshot?.1?["value"] as? String, "ok")
189
+ XCTAssertEqual(snapshot?.2, true)
190
+
191
+ weak let weakRecorder = recorder
192
+ recorder = nil
193
+ XCTAssertNil(weakRecorder)
194
+ XCTAssertNil(target.currentDelegate)
195
+ }
196
+
197
+ func testAuthStoreCloseRejectsPendingAndIgnoresLateCompletion() async throws {
198
+ let store = AuthRequestStore()
199
+ store.activate()
200
+ var pendingTask: Task<String, Error>?
201
+ let inserted = await withCheckedContinuation { insertion in
202
+ pendingTask = Task {
203
+ try await pendingAuthRequest(store, id: "request", insertion: insertion)
204
+ }
205
+ }
206
+ XCTAssertTrue(inserted)
207
+ XCTAssertEqual(store.count, 1)
208
+ let task = try XCTUnwrap(pendingTask)
209
+
210
+ store.cancelAll()
211
+ store.resolve("request", token: "late")
212
+
213
+ do {
214
+ _ = try await task.value
215
+ XCTFail("Expected close to reject the request")
216
+ } catch {
217
+ XCTAssertEqual((error as NSError).domain, "SynchroModule")
218
+ }
219
+ XCTAssertEqual(store.count, 0)
220
+ }
66
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trainstar/synchro-react-native",
3
- "version": "0.4.0-rc.3",
3
+ "version": "0.4.0-rc.4",
4
4
  "description": "React Native bridge for Synchro sync SDK",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -20,6 +20,8 @@
20
20
  "./tooling-patches/cli-config-android-20.2.0.patch": "./tooling-patches/cli-config-android-20.2.0.patch",
21
21
  "./tooling-patches/cli-clean-20.2.0.patch": "./tooling-patches/cli-clean-20.2.0.patch",
22
22
  "./tooling-patches/react-native-builder-bob-0.40.18.patch": "./tooling-patches/react-native-builder-bob-0.40.18.patch",
23
+ "./tooling-patches/react-native-gradle-plugin-0.82.1-kotlin-2.4.21.patch": "./tooling-patches/react-native-gradle-plugin-0.82.1-kotlin-2.4.21.patch",
24
+ "./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch": "./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch",
23
25
  "./tooling-patches/stream-json-1.9.1.patch": "./tooling-patches/stream-json-1.9.1.patch",
24
26
  "./package.json": "./package.json"
25
27
  },
@@ -103,6 +105,7 @@
103
105
  "resolutions": {
104
106
  "@react-native-community/cli-config-android@npm:20.2.0": "patch:@react-native-community/cli-config-android@npm%3A20.2.0#./tooling-patches/cli-config-android-20.2.0.patch",
105
107
  "@react-native-community/cli-clean@npm:20.2.0": "patch:@react-native-community/cli-clean@npm%3A20.2.0#./tooling-patches/cli-clean-20.2.0.patch",
108
+ "@react-native/gradle-plugin@npm:0.88.0": "patch:@react-native/gradle-plugin@npm%3A0.88.0#./tooling-patches/react-native-gradle-plugin-0.88.0-kotlin-2.4.21.patch",
106
109
  "react-native-builder-bob@npm:^0.40.18": "patch:react-native-builder-bob@npm%3A0.40.18#./tooling-patches/react-native-builder-bob-0.40.18.patch",
107
110
  "stream-json@npm:^1.7.4": "patch:stream-json@npm%3A1.9.1#./tooling-patches/stream-json-1.9.1.patch",
108
111
  "stream-json@npm:^1.7.5": "patch:stream-json@npm%3A1.9.1#./tooling-patches/stream-json-1.9.1.patch"