@craft-native/ios 0.0.247 → 0.0.249

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.
@@ -264,8 +264,8 @@ protocol CraftHybridScreenEvents: AnyObject {
264
264
  func nativeScreenDidRender(_ screen: CraftNativeScreenController)
265
265
  /// The screen could not draw its first frame: no bundle, or its script threw.
266
266
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
267
- /// `craft.navigation.open(path)`.
268
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
267
+ /// `craft.navigation.open(path, props)`.
268
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any])
269
269
  /// `craft.navigation.replace(path)`: finish the current flow atomically.
270
270
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
271
271
  }
@@ -446,11 +446,11 @@ final class CraftHybrid: NSObject {
446
446
  /// A path or a web address opened by the app script or a single-stack
447
447
  /// screen: the shell opens it (holding it until the shell exists); an app
448
448
  /// with no shell opens a web address in the in-app browser.
449
- func open(_ path: String) {
449
+ func open(_ path: String, props: [String: Any] = [:]) {
450
450
  if let controller {
451
- controller.open(path)
451
+ controller.open(path, props: props)
452
452
  } else if isEnabled {
453
- untilShell.append { $0.open(path) }
453
+ untilShell.append { $0.open(path, props: props) }
454
454
  } else {
455
455
  openOutside(path)
456
456
  }
@@ -976,8 +976,8 @@ extension CraftHybrid: CraftHybridScreenEvents {
976
976
  DispatchQueue.main.async { self.startAppScript() }
977
977
  }
978
978
 
979
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
980
- open(path)
979
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
980
+ open(path, props: props)
981
981
  }
982
982
 
983
983
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
@@ -1249,8 +1249,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1249
1249
  // create a second web root here: the shared WebView cannot draw
1250
1250
  // both pages during the same gesture.
1251
1251
  guard hybrid.routes.match(id) != nil else { return false }
1252
- target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
1253
- (target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1252
+ target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id, hidden: true) }
1254
1253
  }
1255
1254
  guard current.view !== target.view,
1256
1255
  !(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
@@ -1546,6 +1545,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1546
1545
 
1547
1546
  private var visibleStack: UINavigationController? { stacks[currentTab] }
1548
1547
 
1548
+ /// A swipe preview can be visible without owning navigation or focus.
1549
+ /// UIKit appearance callbacks must follow the committed native stack.
1550
+ func isFocusedScreen(_ screen: CraftNativeScreenController) -> Bool {
1551
+ visibleStack?.topViewController === screen
1552
+ }
1553
+
1549
1554
  var webIsVisible: Bool {
1550
1555
  guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
1551
1556
  return webHost.view.superview === slot.view
@@ -1557,7 +1562,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1557
1562
  /// the view hierarchy, so its screen never takes the shell's chrome (the
1558
1563
  /// bar, the tab accessory) from the one on screen.
1559
1564
  private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
1560
- let nav = UINavigationController(rootViewController: entry(for: rootPath, isTabRoot: true, depth: nil))
1565
+ let root = entry(for: rootPath, isTabRoot: true, depth: nil)
1566
+ // Constructing the navigation controller can load the root's view and
1567
+ // mount its script immediately. Set its initial focus before that.
1568
+ if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
1569
+ let nav = UINavigationController(rootViewController: root)
1561
1570
  nav.delegate = self
1562
1571
  nav.view.backgroundColor = config.resolvedBackgroundColor
1563
1572
  nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
@@ -1585,11 +1594,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1585
1594
  }
1586
1595
 
1587
1596
  /// The entry for a path: its native screen, or a web entry.
1588
- private func entry(for path: String, isTabRoot: Bool, depth: Int?, params extra: [String: Any] = [:]) -> UIViewController {
1597
+ private func entry(for path: String, isTabRoot: Bool, depth: Int?, props: [String: Any] = [:]) -> UIViewController {
1589
1598
  if hybrid.isEnabled, let match = hybrid.routes.match(path) {
1590
- var params = self.params(for: path, match)
1591
- extra.forEach { params[$0.key] = $0.value }
1592
- let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
1599
+ let params = self.params(for: path, match)
1600
+ let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params, routeProps: props)
1593
1601
  screen.hybridEvents = self
1594
1602
  nativePaths[ObjectIdentifier(screen)] = match.path
1595
1603
  return screen
@@ -1734,7 +1742,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1734
1742
  /// an app with no web view, a path no screen draws opens in the in-app
1735
1743
  /// browser on the app's site. A web address on the app's site is its
1736
1744
  /// path; one anywhere else opens in the in-app browser.
1737
- func open(_ raw: String) {
1745
+ func open(_ raw: String, props: [String: Any] = [:]) {
1738
1746
  if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
1739
1747
  guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
1740
1748
  hybrid.openOutside(raw)
@@ -1749,7 +1757,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1749
1757
  return
1750
1758
  }
1751
1759
  if hybrid.routes.match(raw) != nil {
1752
- showNative(path: raw, replace: false)
1760
+ showNative(path: raw, replace: false, props: props)
1753
1761
  return
1754
1762
  }
1755
1763
  if hybrid.hasWeb {
@@ -1769,7 +1777,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1769
1777
 
1770
1778
  /// The native screen for `raw`: the one already in the stack (popping to
1771
1779
  /// it), a tab's root, or a new one pushed.
1772
- func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
1780
+ func showNative(path raw: String, replace: Bool, fromLink: Bool = false, props: [String: Any] = [:]) {
1773
1781
  guard let match = hybrid.routes.match(raw) else { return }
1774
1782
  interruptTabSwipe(restoringSelection: true)
1775
1783
  let tabs = hybrid.tabIds
@@ -1785,7 +1793,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1785
1793
  nav.popToRootViewController(animated: !fromLink)
1786
1794
  }
1787
1795
  // `/m/store?coach=5`: the tab's own screen, with the query.
1788
- (stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
1796
+ (stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
1789
1797
  return
1790
1798
  }
1791
1799
  if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
@@ -1796,10 +1804,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1796
1804
  if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
1797
1805
  // Opened again, perhaps with another query (`?person=8`): the
1798
1806
  // screen hears the params it was opened with this time.
1799
- (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
1807
+ (existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
1800
1808
  return
1801
1809
  }
1802
- let screen = entry(for: raw, isTabRoot: false, depth: nil)
1810
+ let screen = entry(for: raw, isTabRoot: false, depth: nil, props: props)
1803
1811
  let show = { [weak nav] in
1804
1812
  guard let nav else { return }
1805
1813
  if replace, !nav.viewControllers.isEmpty {
@@ -2175,8 +2183,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2175
2183
  }
2176
2184
  }
2177
2185
 
2178
- func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
2179
- open(path)
2186
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
2187
+ open(path, props: props)
2180
2188
  }
2181
2189
 
2182
2190
  func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
@@ -70,7 +70,7 @@ enum CraftLiveActivities {
70
70
 
71
71
  private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
72
72
  guard body.keys.contains(key) else { return fallback }
73
- guard let number = body[key] as? NSNumber, number.doubleValue.isFinite else { return nil }
73
+ guard let number = craftNativeNumber(body[key]), number.doubleValue.isFinite else { return nil }
74
74
  return max(0, number.doubleValue)
75
75
  }
76
76
 
@@ -1,6 +1,7 @@
1
1
  import Foundation
2
2
  import AuthenticationServices
3
3
  import LocalAuthentication
4
+ import PassKit
4
5
  import SQLite3
5
6
  import Security
6
7
  import UIKit
@@ -41,9 +42,10 @@ enum CraftNativeActions {
41
42
  static func capabilities(config: CraftConfig) -> [String] {
42
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
43
44
  // as it arrives) need nothing the app has to declare.
44
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
45
46
  values.append("storageCompareAndSet")
46
47
  values.append("storageSetIfAbsent")
48
+ values.append("viewScroll")
47
49
  values.append("crypto")
48
50
  if config.enableBiometric { values.append("biometric") }
49
51
  if config.enableSecureStorage { values.append("secureStorage") }
@@ -51,6 +53,7 @@ enum CraftNativeActions {
51
53
  if config.enableDeepLinks { values.append("deepLinks") }
52
54
  if config.enableLocalNotifications { values.append("notifications") }
53
55
  if config.enableHealthKit { values.append("health") }
56
+ if config.enableWallet { values.append("wallet") }
54
57
  if config.enableLiveActivities { values.append("liveActivities") }
55
58
  // The in-app browser and the system's web sign-in sheet (CraftBrowser).
56
59
  values.append("browser")
@@ -375,6 +378,44 @@ enum CraftNativeActions {
375
378
  complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
376
379
  }
377
380
  return
381
+ case ("Wallet", "isAvailable"):
382
+ sync = config.enableWallet ? .success(PKPassLibrary.isPassLibraryAvailable()) : failure("CAPABILITY_DISABLED", "Wallet is disabled")
383
+ case ("Wallet", "has"):
384
+ guard config.enableWallet else {
385
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Wallet is disabled"), completion)
386
+ return
387
+ }
388
+ guard let serialNumber = args.first as? String, !serialNumber.isEmpty else {
389
+ complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.has needs a serial number"), completion)
390
+ return
391
+ }
392
+ sync = .success(PKPassLibrary().passes().contains { $0.serialNumber == serialNumber })
393
+ case ("Wallet", "add"):
394
+ guard config.enableWallet else {
395
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Wallet is disabled"), completion)
396
+ return
397
+ }
398
+ let options = args.first as? [String: Any] ?? [:]
399
+ if let encoded = options["data"] as? String {
400
+ guard let data = Data(base64Encoded: encoded), let pass = try? PKPass(data: data) else {
401
+ complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.add needs a valid base64 pkpass"), completion)
402
+ return
403
+ }
404
+ presentWalletPass(pass, requestToken: requestToken, completion: completion)
405
+ return
406
+ }
407
+ guard let address = options["url"] as? String, let url = URL(string: address), url.scheme == "https" else {
408
+ complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.add needs base64 data or an https URL"), completion)
409
+ return
410
+ }
411
+ URLSession.shared.dataTask(with: url) { data, _, error in
412
+ guard let data, error == nil, let pass = try? PKPass(data: data) else {
413
+ complete(requestToken, failure("WALLET_PASS_INVALID", "The Wallet pass could not be downloaded or parsed"), completion)
414
+ return
415
+ }
416
+ DispatchQueue.main.async { self.presentWalletPass(pass, requestToken: requestToken, completion: completion) }
417
+ }.resume()
418
+ return
378
419
  case ("KeepAwake", "set"):
379
420
  guard config.enableKeepAwake else {
380
421
  complete(requestToken, failure("CAPABILITY_DISABLED", "Keep awake is disabled"), completion)
@@ -392,7 +433,7 @@ enum CraftNativeActions {
392
433
  case ("Brightness", "set"):
393
434
  // The device's backlight, system-wide: a screen that dims it reads
394
435
  // it first and puts it back, as @stacksjs/mobile's brightness does.
395
- guard let level = (args.first as? NSNumber)?.doubleValue, level.isFinite else {
436
+ guard let level = craftNativeNumber(args.first)?.doubleValue, level.isFinite else {
396
437
  complete(requestToken, failure("INVALID_ARGUMENT", "Brightness.set needs a level from 0 to 1"), completion)
397
438
  return
398
439
  }
@@ -517,9 +558,9 @@ enum CraftNativeActions {
517
558
  CraftNativeSpeech.shared.speak(
518
559
  text,
519
560
  language: options["language"] as? String,
520
- rate: (options["rate"] as? NSNumber)?.doubleValue,
521
- pitch: (options["pitch"] as? NSNumber)?.doubleValue,
522
- preDelay: (options["preDelay"] as? NSNumber)?.doubleValue,
561
+ rate: craftNativeNumber(options["rate"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
562
+ pitch: craftNativeNumber(options["pitch"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
563
+ preDelay: craftNativeNumber(options["preDelay"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
523
564
  interrupt: options["interrupt"] as? Bool ?? true
524
565
  ) { spoken in complete(requestToken, .success(spoken), completion) }
525
566
  }
@@ -570,7 +611,7 @@ enum CraftNativeActions {
570
611
  }
571
612
  return
572
613
  case ("Badge", "set"):
573
- guard let value = args.first as? NSNumber,
614
+ guard let value = craftNativeNumber(args.first),
574
615
  value.doubleValue.isFinite,
575
616
  value.doubleValue >= 0,
576
617
  value.doubleValue.rounded() == value.doubleValue else {
@@ -595,6 +636,27 @@ enum CraftNativeActions {
595
636
  complete(requestToken, sync ?? failure("UNKNOWN_ACTION", "Unsupported native API"), completion)
596
637
  }
597
638
 
639
+ private static var walletPresenters: [String: CraftWalletPresenter] = [:]
640
+
641
+ private static func presentWalletPass(
642
+ _ pass: PKPass,
643
+ requestToken: String,
644
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
645
+ ) {
646
+ DispatchQueue.main.async {
647
+ let presenter = CraftWalletPresenter { result in
648
+ walletPresenters.removeValue(forKey: requestToken)
649
+ completion(result)
650
+ }
651
+ guard let controller = PKAddPassesViewController(pass: pass), CraftPresenter.present(controller, from: nil) else {
652
+ completion(failure("PRESENTATION_FAILED", "Wallet could not present its add-pass sheet"))
653
+ return
654
+ }
655
+ controller.delegate = presenter
656
+ walletPresenters[requestToken] = presenter
657
+ }
658
+ }
659
+
598
660
  static func cancel(requestToken: String) {
599
661
  cancellationLock.lock()
600
662
  cancelledRequests.insert(requestToken)
@@ -1117,9 +1179,14 @@ enum CraftNativeActions {
1117
1179
  content.body = value["body"] as? String ?? ""
1118
1180
  content.sound = .default
1119
1181
  if let data = value["data"] as? [String: Any] { content.userInfo = data }
1120
- if let badge = value["badge"] as? NSNumber { content.badge = badge }
1182
+ if let badge = craftNativeNumber(value["badge"]),
1183
+ badge.doubleValue.isFinite,
1184
+ badge.doubleValue >= 0,
1185
+ badge.doubleValue.rounded() == badge.doubleValue {
1186
+ content.badge = badge
1187
+ }
1121
1188
  let id = (value["id"] as? String).flatMap { validKey($0) } ?? UUID().uuidString
1122
- let delay = (value["delay"] as? NSNumber)?.doubleValue
1189
+ let delay = craftNativeNumber(value["delay"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }
1123
1190
  let trigger = delay.map { UNTimeIntervalNotificationTrigger(timeInterval: max($0 / 1_000, 1), repeats: false) }
1124
1191
  center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) { addError in
1125
1192
  if let addError = addError {
@@ -1236,6 +1303,18 @@ enum CraftNativeActions {
1236
1303
  }
1237
1304
  }
1238
1305
 
1306
+ private final class CraftWalletPresenter: NSObject, PKAddPassesViewControllerDelegate {
1307
+ private let completion: (Result<Any, CraftNativeActionError>) -> Void
1308
+
1309
+ init(completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
1310
+ self.completion = completion
1311
+ }
1312
+
1313
+ func addPassesViewControllerDidFinish(_ controller: PKAddPassesViewController) {
1314
+ controller.dismiss(animated: true) { [completion] in completion(.success(true)) }
1315
+ }
1316
+ }
1317
+
1239
1318
  /// The native WebAuthn half for screens without a browser context. The
1240
1319
  /// server still owns challenges and verifies the returned client data; this
1241
1320
  /// class only translates WebAuthn options into Apple's passkey sheet and
@@ -108,8 +108,15 @@ enum CraftNativeFiles {
108
108
  /// (no location) left in it. Answers the file, or null when the person
109
109
  /// closed the picker. Main thread.
110
110
  static func pickImage(_ options: [String: Any], cameraEnabled: Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
111
- let maxPixels = min(largestMaxPixels, max(64, (options["maxPixels"] as? NSNumber)?.intValue ?? defaultMaxPixels))
112
- let quality = min(1, max(0.1, (options["quality"] as? NSNumber)?.doubleValue ?? defaultQuality))
111
+ let maxPixels = craftNativeNumber(options["maxPixels"])
112
+ .map { $0.doubleValue }
113
+ .flatMap { value in
114
+ guard value.isFinite else { return nil }
115
+ return Int(max(64, min(Double(largestMaxPixels), value)))
116
+ } ?? defaultMaxPixels
117
+ let quality = min(1, max(0.1, craftNativeNumber(options["quality"])
118
+ .map { $0.doubleValue }
119
+ .flatMap { $0.isFinite ? $0 : nil } ?? defaultQuality))
113
120
  let source = options["source"] as? String ?? "library"
114
121
  guard activePicker == nil else {
115
122
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
@@ -284,7 +291,12 @@ enum CraftNativeFiles {
284
291
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
285
292
  return
286
293
  }
287
- let limit = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
294
+ let limit = craftNativeNumber(options["maxBytes"])
295
+ .map { $0.doubleValue }
296
+ .flatMap { value in
297
+ guard value.isFinite else { return nil }
298
+ return Int(max(1, min(Double(512 * 1024 * 1024), value)))
299
+ } ?? 512 * 1024 * 1024
288
300
  let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
289
301
  DispatchQueue.main.async { activePicker = nil; done(result) }
290
302
  }
@@ -145,6 +145,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
145
145
  private var itemsByIdentity: [String: Item] = [:]
146
146
  private var renderItem: RenderItem?
147
147
  private var recycleItem: ((String) -> Void)?
148
+ private var removeItem: ((String) -> Void)?
148
149
  private var endReached: (() -> Void)?
149
150
  var onScrollEvent: ((UIScrollView) -> Void)?
150
151
  /// Called as the visible rows move, for a screen that lays out around them.
@@ -408,6 +409,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
408
409
  endReachedThreshold: Double,
409
410
  renderItem: @escaping RenderItem,
410
411
  recycleItem: @escaping (String) -> Void,
412
+ removeItem: @escaping (String) -> Void,
411
413
  endReached: (() -> Void)?
412
414
  ) {
413
415
  if isApplyingSnapshot {
@@ -420,6 +422,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
420
422
  endReachedThreshold: endReachedThreshold,
421
423
  renderItem: renderItem,
422
424
  recycleItem: recycleItem,
425
+ removeItem: removeItem,
423
426
  endReached: endReached
424
427
  )
425
428
  }
@@ -429,6 +432,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
429
432
 
430
433
  self.renderItem = renderItem
431
434
  self.recycleItem = recycleItem
435
+ self.removeItem = removeItem
432
436
  self.endReached = endReached
433
437
  self.endReachedThreshold = max(0, endReachedThreshold)
434
438
  self.columns = max(1, columns)
@@ -459,7 +463,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
459
463
  flowLayout.invalidateLayout()
460
464
 
461
465
  let nextIds = Set(itemsByIdentity.keys)
462
- for identity in oldItems.keys where !nextIds.contains(identity) { recycleItem(identity) }
466
+ for identity in oldItems.keys where !nextIds.contains(identity) { removeItem(identity) }
463
467
  let changed = next.compactMap { item -> String? in
464
468
  guard let old = oldItems[item.identity], old.signature != item.signature else { return nil }
465
469
  return item.identity
@@ -508,6 +512,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
508
512
  itemsByIdentity = [:]
509
513
  renderItem = nil
510
514
  recycleItem = nil
515
+ removeItem = nil
511
516
  endReached = nil
512
517
  dataContentSignature = ""
513
518
  onScrollEvent = nil
@@ -521,6 +526,18 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
521
526
  pendingScrollTarget = nil
522
527
  }
523
528
 
529
+ /// Releases callbacks and UIKit ownership that belong to a permanently
530
+ /// removed list node. Recycled rows intentionally use `discardAll()` only:
531
+ /// the same list can be rebound during the next reconciliation.
532
+ func resetForTeardown() {
533
+ setRefreshHandler(nil, refreshing: false)
534
+ discardAll()
535
+ onFittingHeightChanged = nil
536
+ delegate = nil
537
+ nativeRefreshControl.removeTarget(self, action: nil, for: .allEvents)
538
+ removeGestureRecognizer(keyboardTapGesture)
539
+ }
540
+
524
541
  func refreshThemeDefaults() {
525
542
  if isApplyingSnapshot {
526
543
  pendingThemeRefresh = true
@@ -108,6 +108,13 @@ final class CraftNativeMutationDocument {
108
108
  case "moveChild":
109
109
  try moveChild(operation, nodes: &candidateNodes)
110
110
  if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
111
+ case "reparentChild":
112
+ let previousParent = try reparentChild(operation, nodes: &candidateNodes)
113
+ if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
114
+ appendUnique(previousParent, to: &affectedNodeIds)
115
+ // A child crossing parents needs the committed tree to be
116
+ // reconciled so the host can move its existing native view.
117
+ requiresFullRender = true
111
118
  case "removeNode":
112
119
  if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
113
120
  appendUnique(parent, to: &affectedNodeIds)
@@ -251,6 +258,35 @@ final class CraftNativeMutationDocument {
251
258
  nodes[parentId] = parent
252
259
  }
253
260
 
261
+ private func reparentChild(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
262
+ let parentId = try requiredId(operation, field: "parentId", name: "reparentChild")
263
+ let childId = try requiredId(operation, field: "childId", name: "reparentChild")
264
+ guard var parent = nodes[parentId] else {
265
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
266
+ }
267
+ guard var child = nodes[childId], let previousParentId = child.parent,
268
+ var previousParent = nodes[previousParentId] else {
269
+ throw CraftNativeMutationFailure("NOT_ATTACHED", "node \(childId) is not attached to a parent")
270
+ }
271
+ guard acceptsChildren(parent.type) else {
272
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
273
+ }
274
+ guard parentId != childId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
275
+ throw CraftNativeMutationFailure("CYCLE", "reparenting node \(childId) would create a cycle")
276
+ }
277
+ guard parentId != previousParentId else {
278
+ throw CraftNativeMutationFailure("SAME_PARENT", "node \(childId) is already a child of \(parentId)")
279
+ }
280
+ let index = try insertionIndex(operation, count: parent.children.count)
281
+ previousParent.children.removeAll { $0 == childId }
282
+ parent.children.insert(childId, at: index)
283
+ child.parent = parentId
284
+ nodes[previousParentId] = previousParent
285
+ nodes[parentId] = parent
286
+ nodes[childId] = child
287
+ return previousParentId
288
+ }
289
+
254
290
  private func removeNode(
255
291
  _ operation: [String: Any],
256
292
  nodes: inout [String: Node],