react-native-swipeable-actions 0.1.10 → 0.1.12

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.
@@ -66,8 +66,25 @@ public class SwipeableView: ExpoView {
66
66
  registryQueue.async(flags: .barrier) { _viewRegistry[key] = WeakRef(view) }
67
67
  }
68
68
 
69
- private static func unregisterView(for key: String) {
70
- registryQueue.async(flags: .barrier) { _viewRegistry.removeValue(forKey: key) }
69
+ private static func unregisterView(_ view: SwipeableView, for key: String) {
70
+ // Identity-check: a newer SwipeableView may have already taken over this
71
+ // key (FlashList remounts a new instance before the old one's deinit
72
+ // fires). Without this guard the old view's deinit blindly removes the
73
+ // key, leaving the new view orphaned in the registry — closeByKey for
74
+ // that key then returns nil and the visible row can't be closed.
75
+ //
76
+ // Capture identity as a *value* (ObjectIdentifier), never reference
77
+ // `view` inside the async closure. Callers include `deinit`, where
78
+ // `self` is mid-deallocation; strongly capturing it in an escaping
79
+ // closure produces a zombie reference and crashes (EXC_BAD_ACCESS) when
80
+ // the barrier finally runs after the object has been freed.
81
+ let identity = ObjectIdentifier(view)
82
+ registryQueue.async(flags: .barrier) {
83
+ if let registered = _viewRegistry[key]?.value,
84
+ ObjectIdentifier(registered) == identity {
85
+ _viewRegistry.removeValue(forKey: key)
86
+ }
87
+ }
71
88
  }
72
89
 
73
90
  private static func clearOpenState(for key: String) {
@@ -88,7 +105,15 @@ public class SwipeableView: ExpoView {
88
105
 
89
106
  static func closeByKey(_ key: String, animated: Bool = true) {
90
107
  DispatchQueue.main.async {
91
- getView(for: key)?.close(animated: animated)
108
+ if let view = getView(for: key) {
109
+ view.close(animated: animated)
110
+ } else {
111
+ // View was recycled away (or unregistered) before this close
112
+ // could run. Update the cache directly so a future cell binding
113
+ // to this key starts closed instead of inheriting a stale open
114
+ // state — see recyclingKey setter for the matching invariant.
115
+ setOpenState(for: key, isOpen: false)
116
+ }
92
117
  }
93
118
  }
94
119
 
@@ -229,10 +254,15 @@ public class SwipeableView: ExpoView {
229
254
  let shouldOpen = recyclingKey.map { Self.getOpenState(for: $0) } ?? false
230
255
  pendingShouldOpen = shouldOpen
231
256
 
232
- // Thread-safe write of old key's state and unregister
257
+ // Unregister from the old key. We intentionally do NOT write back
258
+ // `isOpen` here: open() and close() already maintain the cache when
259
+ // the user actually triggers the state change. Writing the view's
260
+ // current `isOpen` at reassignment time captures stale state when a
261
+ // pending async close hasn't run yet, leaving the old key cached as
262
+ // open forever — the next cell binding to it would snap open
263
+ // without any user intent.
233
264
  if let old = oldValue {
234
- Self.setOpenState(for: old, isOpen: isOpen)
235
- Self.unregisterView(for: old)
265
+ Self.unregisterView(self, for: old)
236
266
  }
237
267
 
238
268
  // Thread-safe register with new key
@@ -261,9 +291,6 @@ public class SwipeableView: ExpoView {
261
291
  // Auto-close work item for tracking and cancellation
262
292
  private var autoCloseWorkItem: DispatchWorkItem?
263
293
 
264
- // Override onSwipeEnd state when autoClose triggers close() (emit "open" instead of "closed")
265
- private var autoCloseEndEventOverride = false
266
-
267
294
  // UIViewPropertyAnimator for interruptible animations (2.1, 2.2)
268
295
  private var currentAnimator: UIViewPropertyAnimator?
269
296
 
@@ -307,7 +334,7 @@ public class SwipeableView: ExpoView {
307
334
  removeGestureRecognizer(panGesture)
308
335
  // Thread-safe deregister from view registry
309
336
  if let key = recyclingKey {
310
- Self.unregisterView(for: key)
337
+ Self.unregisterView(self, for: key)
311
338
  }
312
339
  }
313
340
 
@@ -340,7 +367,7 @@ public class SwipeableView: ExpoView {
340
367
 
341
368
  // Unregister from static registry and clear cached state
342
369
  if let key = recyclingKey {
343
- Self.unregisterView(for: key)
370
+ Self.unregisterView(self, for: key)
344
371
  Self.clearOpenState(for: key)
345
372
  }
346
373
  }
@@ -451,8 +478,7 @@ public class SwipeableView: ExpoView {
451
478
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: retryWorkItem)
452
479
  } else if isOpen {
453
480
  // Emit "open" state when autoClose completes - user's swipe intent was achieved
454
- autoCloseEndEventOverride = true
455
- close()
481
+ close(autoCloseOverride: true)
456
482
  }
457
483
  }
458
484
 
@@ -516,12 +542,27 @@ public class SwipeableView: ExpoView {
516
542
  // views that UIView.subviews would otherwise include.
517
543
  public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
518
544
  super.mountChildComponentView(childComponentView, index: index)
545
+ // Incoming child may carry a stale transform/zPosition from Fabric recycling;
546
+ // reset it (refreshChildAssignments re-applies the open transform when open).
547
+ childComponentView.transform = .identity
548
+ childComponentView.layer.zPosition = 0
519
549
  let clampedIndex = min(max(index, 0), reactChildren.count)
520
550
  reactChildren.insert(childComponentView, at: clampedIndex)
521
551
  refreshChildAssignments()
522
552
  }
523
553
 
524
554
  public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
555
+ // Content recycled mid-animation: stop the animator before contentView is
556
+ // nil'd below, else it keeps writing a transform onto the pooled view.
557
+ // (Android skips this — RN zeroes translationX on recycle; port if needed.)
558
+ if childComponentView === contentView {
559
+ currentAnimator?.stopAnimation(true)
560
+ currentAnimator = nil
561
+ isAnimating = false
562
+ stopProgressUpdates()
563
+ cancelAutoClose() // a pending timer would fire on the reused view
564
+ }
565
+
525
566
  // Fabric recycles UIView instances. Before handing this child back, undo any
526
567
  // transform / zPosition we may have applied — otherwise the next React
527
568
  // component that receives this recycled UIView inherits our stale state and
@@ -877,7 +918,9 @@ public class SwipeableView: ExpoView {
877
918
  onSwipeProgress(["progress": 1.0, "translationX": targetX])
878
919
  }
879
920
 
880
- func close(animated: Bool = true, velocity: CGFloat = 0) {
921
+ // autoCloseOverride is a value, not a sticky flag: an interrupted close (which
922
+ // never runs the completion) can't strand it and mislabel a later close as "open".
923
+ func close(animated: Bool = true, velocity: CGFloat = 0, autoCloseOverride: Bool = false) {
881
924
  cancelAutoClose()
882
925
  stopProgressUpdates()
883
926
 
@@ -921,8 +964,7 @@ public class SwipeableView: ExpoView {
921
964
  self.actionsView?.transform = CGAffineTransform(translationX: actionsOffset, y: 0)
922
965
  self.onSwipeProgress(["progress": 0.0, "translationX": 0.0])
923
966
  // Emit "open" when autoClose completes - user's swipe intent was achieved
924
- let endState = self.autoCloseEndEventOverride ? "open" : "closed"
925
- self.autoCloseEndEventOverride = false
967
+ let endState = autoCloseOverride ? "open" : "closed"
926
968
  self.onSwipeEnd(["state": endState])
927
969
 
928
970
  // Restore first responder if it was lost during swipe (keyboard preservation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-swipeable-actions",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "High-performance native swipeable row component for React Native using Expo Modules",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",