@craft-native/ios 0.0.338 → 0.0.341

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.338",
3
+ "version": "0.0.341",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -203,7 +203,13 @@ enum CraftNativeActions {
203
203
  case ("Clipboard", "read"):
204
204
  sync = perform(action: "clipboardRead", body: [:], config: config)
205
205
  case ("Storage", _):
206
- DispatchQueue.main.async { complete(requestToken, storage(method: method, args: args), completion) }
206
+ DispatchQueue.main.async {
207
+ cancellationLock.lock()
208
+ let cancelled = cancelledRequests.remove(requestToken) != nil
209
+ cancellationLock.unlock()
210
+ guard !cancelled else { return }
211
+ complete(requestToken, storage(method: method, args: args), completion)
212
+ }
207
213
  return
208
214
  case ("SecureStorage", _):
209
215
  guard config.enableSecureStorage else {
@@ -60,7 +60,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
60
60
  /// The row this cell showed is gone; its views stay in the cell, and
61
61
  /// the next row it shows reuses them when it has the same shape.
62
62
  func recycle() {
63
- if let identity = representedIdentity { onRecycle?(identity) }
63
+ if let identity = representedIdentity, showsHostedView { onRecycle?(identity) }
64
64
  representedIdentity = nil
65
65
  onRecycle = nil
66
66
  }
@@ -81,6 +81,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
81
81
  // since moved to another cell is that cell's now, and pulling it
82
82
  // from there blanked a row on screen.
83
83
  if let shown = hostedView, shown !== view, shown.superview === contentView { shown.removeFromSuperview() }
84
+ NSLayoutConstraint.deactivate(hostedConstraints)
84
85
  hostedView = view
85
86
  view.translatesAutoresizingMaskIntoConstraints = false
86
87
  contentView.addSubview(view)
@@ -110,6 +111,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
110
111
  func releaseHostedView() {
111
112
  recycle()
112
113
  if let shown = hostedView, shown.superview === contentView { shown.removeFromSuperview() }
114
+ NSLayoutConstraint.deactivate(hostedConstraints)
115
+ hostedConstraints = []
113
116
  hostedView = nil
114
117
  }
115
118
 
@@ -195,8 +198,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
195
198
  private var endReachedThreshold = 0.1
196
199
  private var endReachedSignature: String?
197
200
  private var dataContentSignature = ""
201
+ private var dataOrdinals: [Int] = []
202
+ private var dataItemCount = 0
198
203
  private var columns = 1
199
204
  private var isApplyingSnapshot = false
205
+ private var snapshotNeedsReset = false
200
206
  private var pendingApply: (() -> Void)?
201
207
  private var pendingThemeRefresh = false
202
208
  private let nativeRefreshControl = UIRefreshControl()
@@ -224,6 +230,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
224
230
  backgroundColor = .clear
225
231
  alwaysBounceVertical = true
226
232
  delegate = self
233
+ // The renderer keeps one live view per keyed row. UIKit's speculative
234
+ // cell prefetch can otherwise move that view out of its visible cell.
235
+ // UICollectionView still recycles and self-sizes the visible window.
236
+ isPrefetchingEnabled = false
227
237
  keyboardTapGesture.cancelsTouchesInView = false
228
238
  keyboardTapGesture.delegate = self
229
239
  keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
@@ -382,13 +392,15 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
382
392
  let padding = nonNegative(raw?["padding"]) ?? 0
383
393
  let horizontal = nonNegative(raw?["paddingHorizontal"]) ?? padding
384
394
  let vertical = nonNegative(raw?["paddingVertical"]) ?? padding
385
- contentInset = UIEdgeInsets(
395
+ let inset = UIEdgeInsets(
386
396
  top: nonNegative(raw?["paddingTop"]) ?? vertical,
387
397
  left: nonNegative(raw?["paddingLeft"]) ?? horizontal,
388
398
  bottom: nonNegative(raw?["paddingBottom"]) ?? vertical,
389
399
  right: nonNegative(raw?["paddingRight"]) ?? horizontal
390
400
  )
391
- scrollIndicatorInsets = contentInset
401
+ guard contentInset != inset else { return }
402
+ contentInset = inset
403
+ scrollIndicatorInsets = inset
392
404
  collectionViewLayout.invalidateLayout()
393
405
  }
394
406
 
@@ -479,7 +491,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
479
491
  self.removeItem = removeItem
480
492
  self.endReached = endReached
481
493
  self.endReachedThreshold = max(0, endReachedThreshold)
482
- self.columns = max(1, columns)
494
+ let nextColumns = max(1, columns)
495
+ let layoutChanged = self.columns != nextColumns || flowLayout.scrollDirection != (horizontal ? .horizontal : .vertical)
496
+ self.columns = nextColumns
483
497
 
484
498
  layoutIfNeeded()
485
499
  let oldItems = itemsByIdentity
@@ -496,6 +510,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
496
510
  return Item(identity: identity, node: node, signature: signature, isChrome: role != nil && role != "item")
497
511
  }
498
512
  if inverted { next.reverse() }
513
+ // Reapplying an identical keyed tree during a timer or badge update
514
+ // must not reconfigure cells, invalidate self-sizing, or reset a drag.
515
+ if !snapshotNeedsReset, !layoutChanged, next.count == items.count,
516
+ zip(next, items).allSatisfy({ $0.identity == $1.identity && $0.signature == $1.signature }) {
517
+ finishApplyingSnapshot()
518
+ return
519
+ }
520
+ snapshotNeedsReset = false
521
+ dataItemCount = 0
522
+ dataOrdinals = next.map { item in
523
+ if !item.isChrome { dataItemCount += 1 }
524
+ return dataItemCount - 1
525
+ }
499
526
  items = next
500
527
  itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
501
528
 
@@ -504,7 +531,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
504
531
  alwaysBounceVertical = !horizontal
505
532
  showsHorizontalScrollIndicator = horizontal
506
533
  showsVerticalScrollIndicator = !horizontal
507
- flowLayout.invalidateLayout()
534
+ if layoutChanged { flowLayout.invalidateLayout() }
508
535
 
509
536
  let nextIds = Set(itemsByIdentity.keys)
510
537
  for identity in oldItems.keys where !nextIds.contains(identity) { removeItem(identity) }
@@ -550,9 +577,12 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
550
577
  }
551
578
 
552
579
  func discardAll() {
580
+ snapshotNeedsReset = true
553
581
  visibleCells.compactMap { $0 as? Cell }.forEach { $0.releaseHostedView() }
554
582
  itemsByIdentity.keys.forEach { recycleItem?($0) }
555
583
  items = []
584
+ dataOrdinals = []
585
+ dataItemCount = 0
556
586
  itemsByIdentity = [:]
557
587
  renderItem = nil
558
588
  recycleItem = nil
@@ -638,11 +668,11 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
638
668
  guard !isApplyingSnapshot, pendingApply == nil,
639
669
  let endReached = endReached,
640
670
  let lastVisible = indexPathsForVisibleItems.map(\.item).max() else { return }
641
- let dataIndices = items.indices.filter { !items[$0].isChrome }
642
- guard !dataIndices.isEmpty,
643
- let lastDataOrdinal = dataIndices.lastIndex(where: { $0 <= lastVisible }) else { return }
644
- let remaining = dataIndices.count - lastDataOrdinal - 1
645
- let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
671
+ guard dataItemCount > 0, dataOrdinals.indices.contains(lastVisible) else { return }
672
+ let lastDataOrdinal = dataOrdinals[lastVisible]
673
+ guard lastDataOrdinal >= 0 else { return }
674
+ let remaining = dataItemCount - lastDataOrdinal - 1
675
+ let thresholdItems = max(1, Int(ceil(Double(dataItemCount) * endReachedThreshold)))
646
676
  guard remaining <= thresholdItems else { return }
647
677
  let signature = dataContentSignature
648
678
  guard endReachedSignature != signature else { return }
@@ -15,6 +15,45 @@ import UIKit
15
15
 
16
16
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
17
17
 
18
+ /// A retained JavaScript context must lose synchronous storage access when
19
+ /// its screen closes. Only the actual synchronous unmount callback may finish
20
+ /// its cleanup; queued script work runs after that allowance ends.
21
+ private final class CraftNativeStorageLifetime {
22
+ private let lock = NSLock()
23
+ private var closed = false
24
+ private var unmounting = false
25
+
26
+ func close() {
27
+ lock.lock()
28
+ closed = true
29
+ lock.unlock()
30
+ }
31
+
32
+ func unmount(_ work: () -> Void) {
33
+ lock.lock()
34
+ unmounting = true
35
+ lock.unlock()
36
+ defer {
37
+ lock.lock()
38
+ unmounting = false
39
+ lock.unlock()
40
+ }
41
+ work()
42
+ }
43
+
44
+ func access<T>(closedValue: T, _ work: () -> T) -> T {
45
+ lock.lock()
46
+ defer { lock.unlock() }
47
+ guard !closed || unmounting else {
48
+ if let context = JSContext.current() {
49
+ context.exception = JSValue(newErrorFromMessage: "SCREEN_CLOSED: The native screen is closed", in: context)
50
+ }
51
+ return closedValue
52
+ }
53
+ return work()
54
+ }
55
+ }
56
+
18
57
  /// Foundation's `NSNumber(0/1) is Bool` is true even for JSON numbers.
19
58
  /// Check the Core Foundation identity so real booleans stay invalid numeric
20
59
  /// props while zero lengths, flex factors and opacity 1 remain numbers.
@@ -5751,6 +5790,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5751
5790
  weak var hybridEvents: CraftHybridScreenEvents?
5752
5791
  private var hybridRendered = false
5753
5792
  private var runtimeDisposed = false
5793
+ private let storageLifetime = CraftNativeStorageLifetime()
5754
5794
  /// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
5755
5795
  /// no screen, loaded once and never shown, that runs for the app's life.
5756
5796
  /// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
@@ -5998,6 +6038,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5998
6038
  private func disposeRuntime() {
5999
6039
  guard !runtimeDisposed else { return }
6000
6040
  runtimeDisposed = true
6041
+ storageLifetime.close()
6042
+ pendingCapabilityDeadlines.values.forEach { $0.cancel() }
6043
+ pendingCapabilityDeadlines.removeAll()
6044
+ pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
6045
+ pendingCapabilityRequests.removeAll()
6001
6046
  if let owner = voiceRecordingOwner {
6002
6047
  voiceRecordingOwner = nil
6003
6048
  CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
@@ -6007,7 +6052,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6007
6052
  disposeRenderedTree()
6008
6053
  let context = jsContext
6009
6054
  let route = routeName ?? "screen"
6010
- CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
6055
+ let storageLifetime = self.storageLifetime
6056
+ CraftNativeScriptThread.run(label: "unmount \(route)") {
6057
+ storageLifetime.unmount { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
6058
+ }
6011
6059
  audio.close()
6012
6060
  sounds.close()
6013
6061
  streams.close()
@@ -6469,17 +6517,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6469
6517
  let log: @convention(block) (String, String, String) -> Void = { level, category, message in
6470
6518
  CraftNativeConsole.write(level, category: category, message: message)
6471
6519
  }
6520
+ let storageLifetime = self.storageLifetime
6472
6521
  let storageGet: @convention(block) (String) -> String? = { key in
6473
- do { return try CraftNativeActions.storageJSON(forKey: key) }
6474
- catch {
6475
- if let context = JSContext.current() {
6476
- context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
6522
+ storageLifetime.access(closedValue: nil) {
6523
+ do { return try CraftNativeActions.storageJSON(forKey: key) }
6524
+ catch {
6525
+ if let context = JSContext.current() {
6526
+ context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
6527
+ }
6528
+ return nil
6477
6529
  }
6478
- return nil
6479
6530
  }
6480
6531
  }
6481
6532
  let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
6482
- CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
6533
+ storageLifetime.access(closedValue: false) {
6534
+ CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
6535
+ }
6483
6536
  }
6484
6537
  let snapshotGet: @convention(block) (String) -> String? = { name in
6485
6538
  CraftSnapshots.read(name)
@@ -7613,6 +7666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7613
7666
  navigationController?.popToRootViewController(animated: true)
7614
7667
  }
7615
7668
  case "API_REQUEST":
7669
+ guard !runtimeDisposed else { return }
7616
7670
  guard let id = message["id"] as? String else { return }
7617
7671
  let args = payload["args"] as? [Any] ?? []
7618
7672
  let requestToken = "\(capabilityScope)/\(id)"
@@ -7682,7 +7736,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7682
7736
  case "API_CANCEL":
7683
7737
  guard let id = payload["requestId"] as? String else { return }
7684
7738
  let requestToken = "\(capabilityScope)/\(id)"
7685
- pendingCapabilityRequests.remove(requestToken)
7739
+ guard pendingCapabilityRequests.remove(requestToken) != nil else { return }
7686
7740
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
7687
7741
  screenRequestCancellers.removeValue(forKey: requestToken)?()
7688
7742
  CraftNativeActions.cancel(requestToken: requestToken)