craft-native 0.0.174 → 0.0.176

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 (32) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +998 -303
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/templates/CraftApp.swift +51 -1
  22. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  23. package/dist/ios/templates/CraftHybrid.swift +10 -0
  24. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  25. package/dist/ios/templates/CraftNativeFlatList.swift +13 -21
  26. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +410 -55
  28. package/dist/ios/templates/project.yml.template +1 -0
  29. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  30. package/dist/macos/templates/native_layout.zig +389 -17
  31. package/dist/mobile.js +66 -0
  32. package/package.json +1 -1
@@ -12,6 +12,15 @@ import UIKit
12
12
 
13
13
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
14
14
 
15
+ /// A finite number from a prop, for the views outside the screen controller,
16
+ /// which has its own: the keyboard-avoiding view and SVG text read theirs
17
+ /// with it, and without it the template did not compile.
18
+ private func number(_ value: Any?) -> CGFloat? {
19
+ guard let number = value as? NSNumber else { return nil }
20
+ let value = CGFloat(truncating: number)
21
+ return value.isFinite ? value : nil
22
+ }
23
+
15
24
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
16
25
  if let number = value as? NSNumber { return max(1, number.intValue) }
17
26
  guard let text = value as? String else { return 1 }
@@ -81,6 +90,178 @@ private func craftNativeJustifyCode(_ value: String) -> UInt32 {
81
90
  }
82
91
  }
83
92
 
93
+ /// Optional bridge to the host-neutral tree layout ABI. The host still owns
94
+ /// intrinsic measurement and keeps its local implementation for unsupported
95
+ /// trees or runtimes built before this symbol was exported.
96
+ private enum CraftNativeTreeLayoutBridge {
97
+ private struct StyleInput {
98
+ var direction: UInt32 = 0
99
+ var width: Float = .nan
100
+ var height: Float = .nan
101
+ var intrinsicWidth: Float = .nan
102
+ var intrinsicHeight: Float = .nan
103
+ var baseline: Float = .nan
104
+ var aspectRatio: Float = .nan
105
+ var flexBasis: Float = .nan
106
+ var flexGrow: Float = 0
107
+ var flexShrink: Float = 0
108
+ var minWidth: Float = .nan
109
+ var maxWidth: Float = .nan
110
+ var minHeight: Float = .nan
111
+ var maxHeight: Float = .nan
112
+ var gap: Float = 0
113
+ var justifyContent: UInt32 = 0
114
+ var alignItems: UInt32 = 3
115
+ var alignSelf: UInt32 = 0
116
+ var flexWrap: UInt32 = 0
117
+ var alignContent: UInt32 = 6
118
+ var marginTop: Float = 0
119
+ var marginRight: Float = 0
120
+ var marginBottom: Float = 0
121
+ var marginLeft: Float = 0
122
+ var paddingTop: Float = 0
123
+ var paddingRight: Float = 0
124
+ var paddingBottom: Float = 0
125
+ var paddingLeft: Float = 0
126
+ var grid: UInt32 = 0
127
+ var gridColumns: UInt32 = 1
128
+ var rowGap: Float = 0
129
+ var columnGap: Float = 0
130
+ }
131
+
132
+ private struct NodeInput {
133
+ var style: StyleInput
134
+ var firstChild: UInt32
135
+ var childCount: UInt32
136
+ }
137
+
138
+ private struct Frame {
139
+ var x: Float
140
+ var y: Float
141
+ var width: Float
142
+ var height: Float
143
+ }
144
+
145
+ private typealias Function = @convention(c) (
146
+ Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
147
+ ) -> Int32
148
+
149
+ private static let function: Function? = {
150
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
151
+ return unsafeBitCast(symbol, to: Function.self)
152
+ }()
153
+
154
+ private static func finite(_ value: CGFloat?) -> Float {
155
+ guard let value, value.isFinite else { return .nan }
156
+ return Float(value)
157
+ }
158
+
159
+ private static func length(_ value: CraftNativeLength?, base: CGFloat?) -> Float {
160
+ guard let value else { return .nan }
161
+ return finite(value.resolve(base))
162
+ }
163
+
164
+ private static func alignment(_ value: String?) -> UInt32 {
165
+ switch value {
166
+ case "center": return 1
167
+ case "flex-end": return 2
168
+ case "stretch": return 3
169
+ case "baseline": return 4
170
+ default: return 0
171
+ }
172
+ }
173
+
174
+ private static func alignSelf(_ value: String?) -> UInt32 {
175
+ switch value {
176
+ case "flex-start": return 1
177
+ case "center": return 2
178
+ case "flex-end": return 3
179
+ case "stretch": return 4
180
+ case "baseline": return 5
181
+ default: return 0
182
+ }
183
+ }
184
+
185
+ private static func justify(_ value: String) -> UInt32 {
186
+ craftNativeJustifyCode(value)
187
+ }
188
+
189
+ static func frames(
190
+ width: CGFloat,
191
+ height: CGFloat,
192
+ direction: NSLayoutConstraint.Axis,
193
+ gap: CGFloat,
194
+ rowGap: CGFloat?,
195
+ columnGap: CGFloat?,
196
+ wrap: Bool,
197
+ justifyContent: String,
198
+ alignItems: String,
199
+ padding: UIEdgeInsets,
200
+ children: [(UIView, CraftNativeLayoutStyle, CGSize, CGFloat)]
201
+ ) -> [(UIView, CGRect)]? {
202
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !children.isEmpty else { return nil }
203
+ let mainGap = direction == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
204
+ let crossGap = direction == .horizontal ? (rowGap ?? gap) : (columnGap ?? gap)
205
+ var root = StyleInput(
206
+ direction: direction == .horizontal ? 0 : 1,
207
+ width: Float(width), height: Float(height), gap: Float(max(0, mainGap)),
208
+ justifyContent: justify(justifyContent), alignItems: alignment(alignItems), flexWrap: wrap ? 1 : 0,
209
+ paddingTop: Float(max(0, padding.top)), paddingRight: Float(max(0, padding.right)),
210
+ paddingBottom: Float(max(0, padding.bottom)), paddingLeft: Float(max(0, padding.left))
211
+ )
212
+ root.rowGap = Float(max(0, rowGap ?? 0))
213
+ root.columnGap = Float(max(0, columnGap ?? 0))
214
+ let contentWidth = max(0, width - padding.left - padding.right)
215
+ let contentHeight = max(0, height - padding.top - padding.bottom)
216
+ let mainSize = direction == .horizontal ? contentWidth : contentHeight
217
+ var nodes = [NodeInput(style: root, firstChild: 1, childCount: UInt32(children.count))]
218
+ for (view, style, intrinsic, baseline) in children {
219
+ let shrink: CGFloat
220
+ if let authored = style.flexShrink {
221
+ shrink = authored
222
+ } else if view is UIScrollView {
223
+ shrink = 1
224
+ } else if view is UILabel, direction == .horizontal {
225
+ shrink = 1
226
+ } else {
227
+ shrink = 0
228
+ }
229
+ var input = StyleInput(
230
+ width: length(style.width, base: contentWidth), height: length(style.height, base: contentHeight),
231
+ intrinsicWidth: Float(max(0, intrinsic.width)), intrinsicHeight: Float(max(0, intrinsic.height)),
232
+ baseline: Float(max(0, baseline)), aspectRatio: finite(style.aspectRatio),
233
+ flexBasis: length(style.flexBasis, base: mainSize), flexGrow: Float(max(0, style.flexGrow)), flexShrink: Float(max(0, shrink)),
234
+ minWidth: length(style.minWidth, base: contentWidth), maxWidth: length(style.maxWidth, base: contentWidth),
235
+ minHeight: length(style.minHeight, base: contentHeight), maxHeight: length(style.maxHeight, base: contentHeight),
236
+ alignSelf: alignSelf(style.alignSelf),
237
+ marginTop: Float(max(0, style.marginTop)), marginRight: Float(max(0, style.marginRight)),
238
+ marginBottom: Float(max(0, style.marginBottom)), marginLeft: Float(max(0, style.marginLeft))
239
+ )
240
+ input.direction = 0
241
+ nodes.append(NodeInput(style: input, firstChild: 0, childCount: 0))
242
+ }
243
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
244
+ let frameCount = output.count
245
+ let status = nodes.withUnsafeBufferPointer { nodeBuffer in
246
+ output.withUnsafeMutableBufferPointer { frameBuffer in
247
+ function(
248
+ Float(width), Float(height), UnsafeRawPointer(nodeBuffer.baseAddress!), nodes.count,
249
+ UnsafeMutableRawPointer(frameBuffer.baseAddress!), frameCount
250
+ )
251
+ }
252
+ }
253
+ guard status == 0 else { return nil }
254
+ let childFrames = output.dropFirst()
255
+ guard childFrames.allSatisfy({ frame in
256
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
257
+ frame.width >= 0 && frame.height >= 0
258
+ }) else { return nil }
259
+ return zip(children, childFrames).map { child, frame in
260
+ (child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
261
+ }
262
+ }
263
+ }
264
+
84
265
  private enum CraftNativeFlexLineBridge {
85
266
  private struct Item {
86
267
  var basis: Float
@@ -430,7 +611,9 @@ private final class CraftNativeVideoView: UIView {
430
611
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
431
612
  private var annotationsByID: [String: MKPointAnnotation] = [:]
432
613
  private var overlaysByID: [String: MKPolyline] = [:]
614
+ private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
433
615
  private var overlayColors: [ObjectIdentifier: UIColor] = [:]
616
+ private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
434
617
  private(set) var markerHandlers: [String: String] = [:]
435
618
  var onMarkerTap: ((String) -> Void)?
436
619
 
@@ -453,6 +636,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
453
636
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
454
637
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
455
638
  mapType = Self.mapType(props["mapType"] as? String)
639
+ // Showing the user dot must not trigger a permission prompt from a
640
+ // renderer pass. The app's existing location capability owns that
641
+ // prompt; a map only opts in after authorization is already granted.
642
+ showsUserLocation = props["showsUserLocation"] as? Bool == true && Self.locationPermissionGranted()
456
643
 
457
644
  var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
458
645
  var nextHandlers: [String: String] = [:]
@@ -474,7 +661,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
474
661
  }
475
662
  } else if type == "mappolyline" || type == "polyline" {
476
663
  guard points.count > 1 else { continue }
477
- nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
664
+ let authoredWidth = (childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3
665
+ let width = CGFloat(authoredWidth)
666
+ guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
667
+ nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
478
668
  }
479
669
  }
480
670
 
@@ -494,13 +684,33 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
494
684
  }
495
685
  markerHandlers = nextHandlers
496
686
 
497
- removeOverlays(Array(overlaysByID.values))
498
- overlaysByID.removeAll(keepingCapacity: true)
499
- overlayColors.removeAll(keepingCapacity: true)
687
+ let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
688
+ for id in staleOverlays {
689
+ if let overlay = overlaysByID.removeValue(forKey: id) {
690
+ removeOverlay(overlay)
691
+ overlayColors.removeValue(forKey: ObjectIdentifier(overlay))
692
+ overlayWidths.removeValue(forKey: ObjectIdentifier(overlay))
693
+ }
694
+ overlayCoordinatesByID.removeValue(forKey: id)
695
+ }
500
696
  for (id, value) in nextPolylines {
697
+ if let overlay = overlaysByID[id],
698
+ let coordinates = overlayCoordinatesByID[id],
699
+ Self.coordinatesEqual(coordinates, value.coordinates),
700
+ overlayColors[ObjectIdentifier(overlay)]?.isEqual(value.color) == true,
701
+ overlayWidths[ObjectIdentifier(overlay)] == value.width {
702
+ continue
703
+ }
704
+ if let previous = overlaysByID.removeValue(forKey: id) {
705
+ removeOverlay(previous)
706
+ overlayColors.removeValue(forKey: ObjectIdentifier(previous))
707
+ overlayWidths.removeValue(forKey: ObjectIdentifier(previous))
708
+ }
501
709
  let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
502
710
  overlaysByID[id] = overlay
711
+ overlayCoordinatesByID[id] = value.coordinates
503
712
  overlayColors[ObjectIdentifier(overlay)] = value.color
713
+ overlayWidths[ObjectIdentifier(overlay)] = value.width
504
714
  addOverlay(overlay)
505
715
  }
506
716
 
@@ -542,7 +752,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
542
752
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
543
753
  let renderer = MKPolylineRenderer(overlay: overlay)
544
754
  renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
545
- renderer.lineWidth = 3
755
+ renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
546
756
  return renderer
547
757
  }
548
758
 
@@ -550,8 +760,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
550
760
  let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
551
761
  ?? (value as? [NSNumber])?.map(\.doubleValue)
552
762
  ?? []
553
- return stride(from: 0, to: numbers.count - 1, by: 2).map {
554
- CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
763
+ return stride(from: 0, to: numbers.count - 1, by: 2).compactMap {
764
+ let latitude = numbers[$0]
765
+ let longitude = numbers[$0 + 1]
766
+ guard latitude.isFinite, longitude.isFinite,
767
+ (-90...90).contains(latitude), (-180...180).contains(longitude) else { return nil }
768
+ return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
769
+ }
770
+ }
771
+
772
+ private static func coordinatesEqual(_ lhs: [CLLocationCoordinate2D], _ rhs: [CLLocationCoordinate2D]) -> Bool {
773
+ guard lhs.count == rhs.count else { return false }
774
+ return zip(lhs, rhs).allSatisfy { left, right in
775
+ left.latitude == right.latitude && left.longitude == right.longitude
555
776
  }
556
777
  }
557
778
 
@@ -562,7 +783,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
562
783
  let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
563
784
  let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
564
785
  let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
565
- let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
786
+ let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue,
787
+ latitude.isFinite, longitude.isFinite, latitudeDelta.isFinite, longitudeDelta.isFinite,
788
+ (-90...90).contains(latitude), (-180...180).contains(longitude),
789
+ latitudeDelta > 0, latitudeDelta <= 180, longitudeDelta > 0, longitudeDelta <= 360,
790
+ latitude - latitudeDelta / 2 >= -90, latitude + latitudeDelta / 2 <= 90 else { return nil }
566
791
  return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
567
792
  }
568
793
 
@@ -585,6 +810,16 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
585
810
  default: return .standard
586
811
  }
587
812
  }
813
+
814
+ private static func locationPermissionGranted() -> Bool {
815
+ let status: CLAuthorizationStatus
816
+ if #available(iOS 14.0, *) {
817
+ status = CLLocationManager().authorizationStatus
818
+ } else {
819
+ status = CLLocationManager.authorizationStatus()
820
+ }
821
+ return status == .authorizedAlways || status == .authorizedWhenInUse
822
+ }
588
823
  }
589
824
 
590
825
  /// A WebView-free host for the first stx native vertical slice. The bundled
@@ -614,15 +849,17 @@ private enum CraftNativeLength: Equatable {
614
849
 
615
850
  init?(_ value: Any?) {
616
851
  if let number = value as? NSNumber {
617
- self = .points(CGFloat(truncating: number))
852
+ let points = CGFloat(truncating: number)
853
+ guard points.isFinite else { return nil }
854
+ self = .points(points)
618
855
  return
619
856
  }
620
857
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
621
- if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
858
+ if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
622
859
  self = .percent(CGFloat(fraction) / 100)
623
- } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
860
+ } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
624
861
  self = .points(CGFloat(points))
625
- } else if let points = Double(text) {
862
+ } else if let points = Double(text), points.isFinite {
626
863
  self = .points(CGFloat(points))
627
864
  } else {
628
865
  return nil
@@ -631,7 +868,7 @@ private enum CraftNativeLength: Equatable {
631
868
 
632
869
  func resolve(_ base: CGFloat?) -> CGFloat? {
633
870
  switch self {
634
- case .points(let value): return value
871
+ case .points(let value): return max(0, value)
635
872
  case .percent(let fraction): return base.map { max(0, $0 * fraction) }
636
873
  }
637
874
  }
@@ -668,7 +905,11 @@ private struct CraftNativeLayoutStyle {
668
905
  static let empty = CraftNativeLayoutStyle([:])
669
906
 
670
907
  init(_ raw: [String: Any]) {
671
- func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
908
+ func number(_ value: Any?) -> CGFloat? {
909
+ guard let number = value as? NSNumber else { return nil }
910
+ let value = CGFloat(truncating: number)
911
+ return value.isFinite ? value : nil
912
+ }
672
913
  width = CraftNativeLength(raw["width"])
673
914
  height = CraftNativeLength(raw["height"])
674
915
  let ratio = number(raw["aspectRatio"])
@@ -970,6 +1211,13 @@ private class CraftNativeFlowView: UIStackView {
970
1211
 
971
1212
  private var flowChildren: [UIView] = []
972
1213
  private var measurements: [MeasureKey: CGSize] = [:]
1214
+ /// The screen's rendered root gets a definite viewport from the host. A
1215
+ /// root with `flex: 1` must keep that height even when its first child is
1216
+ /// a ScrollView whose content is taller than the screen; otherwise an
1217
+ /// absolute sibling is positioned against the scroll content and moves
1218
+ /// with it.
1219
+ private weak var viewportChild: UIView?
1220
+ private var viewportChildFillsHeight = false
973
1221
 
974
1222
  private struct MeasureKey: Hashable {
975
1223
  let width: CGFloat?
@@ -1071,6 +1319,12 @@ private class CraftNativeFlowView: UIStackView {
1071
1319
  invalidateMeasurements()
1072
1320
  }
1073
1321
 
1322
+ func setViewportChild(_ view: UIView?, fillsHeight: Bool) {
1323
+ viewportChild = view
1324
+ viewportChildFillsHeight = fillsHeight
1325
+ invalidateMeasurements()
1326
+ }
1327
+
1074
1328
  func removeLayoutStyle(for view: UIView) {
1075
1329
  view.craftLayoutStyle = .empty
1076
1330
  }
@@ -1105,7 +1359,12 @@ private class CraftNativeFlowView: UIStackView {
1105
1359
  let scale = window?.screen.scale ?? traitCollection.displayScale
1106
1360
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
1107
1361
  for (child, frame) in result.frames {
1108
- var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
1362
+ var viewportFrame = frame
1363
+ if child === viewportChild, viewportChildFillsHeight {
1364
+ viewportFrame.origin.y = 0
1365
+ viewportFrame.size.height = bounds.height
1366
+ }
1367
+ var snapped = CraftNativeFlexLayout.snapped(viewportFrame, scale: scale)
1109
1368
  // Snapping each edge can take a pixel off a label (15.83…33.83
1110
1369
  // becomes 16…33.67), and a label a pixel narrower than its text
1111
1370
  // truncates it ("13" drew as "…"). Text keeps its whole size.
@@ -1188,6 +1447,29 @@ private class CraftNativeFlowView: UIStackView {
1188
1447
  /// outer size when its parent fixes it, nil when it sizes to its content
1189
1448
  /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
1190
1449
  /// placing, every child's frame in its own coordinates.
1450
+ private func sharedDirectFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> [(UIView, CGRect)]? {
1451
+ guard let width, let height, !grid, !reverse, !wrapReverse,
1452
+ width.isFinite, height.isFinite, width >= 0, height >= 0,
1453
+ children.allSatisfy({ child in
1454
+ let style = child.craftLayoutStyle
1455
+ return style.position != "absolute" && style.display != "none"
1456
+ }) else { return nil }
1457
+
1458
+ let intrinsic = children.map { child in
1459
+ CraftNativeFlexLayout.measure(child, width: nil, height: nil, maxWidth: width, maxHeight: height)
1460
+ }
1461
+ let baselines = children.enumerated().map { index, child in
1462
+ CraftNativeFlowView.firstBaseline(child, cross: intrinsic[index].height)
1463
+ }
1464
+ let payload = zip(children, zip(intrinsic, baselines)).map { child, measured in
1465
+ (child, child.craftLayoutStyle, measured.0, measured.1)
1466
+ }
1467
+ return CraftNativeTreeLayoutBridge.frames(
1468
+ width: width, height: height, direction: axis, gap: gap, rowGap: rowGap, columnGap: columnGap, wrap: wrap,
1469
+ justifyContent: justifyContent, alignItems: alignItems, padding: effectivePadding, children: payload
1470
+ )
1471
+ }
1472
+
1191
1473
  private func layout(
1192
1474
  width: CGFloat?,
1193
1475
  height: CGFloat?,
@@ -1222,6 +1504,9 @@ private class CraftNativeFlowView: UIStackView {
1222
1504
  frames: &frames
1223
1505
  )
1224
1506
  }
1507
+ if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
1508
+ frames = shared
1509
+ }
1225
1510
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
1226
1511
 
1227
1512
  if place {
@@ -1269,6 +1554,7 @@ private class CraftNativeFlowView: UIStackView {
1269
1554
  place: Bool,
1270
1555
  frames: inout [(UIView, CGRect)]
1271
1556
  ) -> CGSize {
1557
+ let contentPadding = effectivePadding
1272
1558
  let definiteMain = horizontal ? innerWidth : innerHeight
1273
1559
  let definiteCross = horizontal ? innerHeight : innerWidth
1274
1560
  let roomMain = horizontal ? roomWidth : roomHeight
@@ -1630,6 +1916,7 @@ private class CraftNativeFlowView: UIStackView {
1630
1916
  place: Bool,
1631
1917
  frames: inout [(UIView, CGRect)]
1632
1918
  ) -> CGSize {
1919
+ let contentPadding = effectivePadding
1633
1920
  let columns = max(1, gridColumns)
1634
1921
  let horizontalGap = columnGap ?? gap
1635
1922
  let verticalGap = rowGap ?? gap
@@ -1653,7 +1940,7 @@ private class CraftNativeFlowView: UIStackView {
1653
1940
  let available = max(0, cellWidth - style.marginLeft - style.marginRight)
1654
1941
  let align = style.alignSelf ?? alignItems
1655
1942
  var fixedWidth = style.width?.resolve(cellWidth)
1656
- var fixedHeight = style.height?.resolve(nil)
1943
+ var fixedHeight = style.height?.resolve(innerHeight)
1657
1944
  // A cell's item is as wide as its column unless it sets a width
1658
1945
  // (or an aspect ratio derives one from its height), whatever its
1659
1946
  // height: a fixed-height swatch with no content was drawn 0 wide.
@@ -1687,7 +1974,7 @@ private class CraftNativeFlowView: UIStackView {
1687
1974
  let size = sizes[index]
1688
1975
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1689
1976
  var explicitWidth = style.width?.resolve(cellWidth)
1690
- var explicitHeight = style.height?.resolve(nil)
1977
+ var explicitHeight = style.height?.resolve(innerHeight)
1691
1978
  if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
1692
1979
  explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1693
1980
  }
@@ -1696,7 +1983,7 @@ private class CraftNativeFlowView: UIStackView {
1696
1983
  size.width, size.height,
1697
1984
  explicitWidth, explicitHeight,
1698
1985
  style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
1699
- style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
1986
+ style.minHeight?.resolve(innerHeight), style.maxHeight?.resolve(innerHeight),
1700
1987
  style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1701
1988
  align == "stretch", alignment
1702
1989
  )
@@ -1805,7 +2092,7 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
1805
2092
  func configureKeyboard(props: [String: Any]) {
1806
2093
  let requestedBehavior = (props["behavior"] as? String) ?? "padding"
1807
2094
  keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
1808
- keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
2095
+ keyboardVerticalOffset = number(props["keyboardVerticalOffset"]) ?? 0
1809
2096
  if keyboardBehavior != "height" { appliedHeightInset = 0 }
1810
2097
  authoredPadding = padding
1811
2098
  applyKeyboardAvoidance()
@@ -2306,7 +2593,7 @@ private final class CraftNativeSvgView: UIView {
2306
2593
  let props = drawing["props"] as? [String: Any] ?? [:]
2307
2594
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
2308
2595
  let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2309
- guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
2596
+ guard n.count == 4, n.allSatisfy(\.isFinite), n[2] > 0, n[3] > 0 else { return nil }
2310
2597
  return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
2311
2598
  }
2312
2599
 
@@ -2327,21 +2614,29 @@ private final class CraftNativeSvgView: UIView {
2327
2614
  var linecap: String?
2328
2615
  var linejoin: String?
2329
2616
  var dashes: [CGFloat]?
2617
+ var vectorEffect: String?
2330
2618
 
2331
2619
  func merged(_ node: [String: Any]) -> Paint {
2332
2620
  var next = self
2333
2621
  let props = node["props"] as? [String: Any] ?? [:]
2334
2622
  let style = node["style"] as? [String: Any] ?? [:]
2623
+ func value(_ key: String) -> Any? { style[key] ?? props[key] }
2624
+ func finite(_ raw: Any?) -> CGFloat? {
2625
+ guard let number = raw as? NSNumber else { return nil }
2626
+ let value = CGFloat(truncating: number)
2627
+ return value.isFinite ? value : nil
2628
+ }
2335
2629
  if let color = style["color"] as? String { next.color = color }
2336
2630
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
2337
2631
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
2338
- if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
2632
+ if let width = finite(style["strokeWidth"] ?? props["strokeWidth"]) { next.strokeWidth = width }
2339
2633
  // Computed style wins over the component prop, matching Android
2340
2634
  // and the browser's cascade when both provide opacity.
2341
- if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
2342
- if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
2343
- if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
2344
- if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
2635
+ if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
2636
+ if let cap = value("strokeLinecap") as? String { next.linecap = cap }
2637
+ if let join = value("strokeLinejoin") as? String { next.linejoin = join }
2638
+ if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
2639
+ if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
2345
2640
  return next
2346
2641
  }
2347
2642
 
@@ -2408,7 +2703,7 @@ private final class CraftNativeSvgView: UIView {
2408
2703
  // `vector-effect="non-scaling-stroke"`: the stroke is in the
2409
2704
  // view's points, so a stretched chart's line keeps its width
2410
2705
  // and a round cap stays a round dot.
2411
- let nonScaling = (shape["props"] as? [String: Any])?["vectorEffect"] as? String == "non-scaling-stroke"
2706
+ let nonScaling = own.vectorEffect == "non-scaling-stroke"
2412
2707
  let line: UIBezierPath
2413
2708
  if nonScaling, let copy = path.copy() as? UIBezierPath {
2414
2709
  copy.apply(viewTransform)
@@ -2435,14 +2730,14 @@ private final class CraftNativeSvgView: UIView {
2435
2730
  guard !text.isEmpty else { return }
2436
2731
  let props = node["props"] as? [String: Any] ?? [:]
2437
2732
  let style = node["style"] as? [String: Any] ?? [:]
2438
- let size = CGFloat(truncating: props["fontSize"] as? NSNumber ?? style["fontSize"] as? NSNumber ?? 12)
2733
+ let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
2439
2734
  let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
2440
2735
  let string = NSAttributedString(string: text, attributes: [
2441
2736
  .font: UIFont.systemFont(ofSize: size, weight: weight),
2442
2737
  .foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
2443
2738
  ])
2444
- var x = CGFloat(truncating: props["x"] as? NSNumber ?? 0)
2445
- let y = CGFloat(truncating: props["y"] as? NSNumber ?? 0)
2739
+ var x = number(props["x"]) ?? 0
2740
+ let y = number(props["y"]) ?? 0
2446
2741
  switch props["textAnchor"] as? String {
2447
2742
  case "middle": x -= string.size().width / 2
2448
2743
  case "end": x -= string.size().width
@@ -2455,7 +2750,7 @@ private final class CraftNativeSvgView: UIView {
2455
2750
  static func path(_ value: Any?) -> UIBezierPath? {
2456
2751
  guard let list = value as? [Any] else { return nil }
2457
2752
  let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2458
- guard !n.isEmpty, n.count == list.count else { return nil }
2753
+ guard !n.isEmpty, n.count == list.count, n.allSatisfy(\.isFinite) else { return nil }
2459
2754
  let path = UIBezierPath()
2460
2755
  var i = 0
2461
2756
  func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
@@ -2892,6 +3187,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2892
3187
  override func viewDidAppear(_ animated: Bool) {
2893
3188
  super.viewDidAppear(animated)
2894
3189
  screenVisibilityChanged(true)
3190
+ // A navigation breadcrumb for the crash reporter: the trail a crash
3191
+ // report carries is the screens that led to it.
3192
+ if !isAppScript, let routeName, !routeName.isEmpty {
3193
+ CraftCrashReporter.shared.screenAppeared(routeName)
3194
+ }
2895
3195
  }
2896
3196
 
2897
3197
  override func viewDidDisappear(_ animated: Bool) {
@@ -2987,7 +3287,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2987
3287
  ?? Self.safeJavaScriptString(exception)
2988
3288
  ?? "unknown"
2989
3289
  let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
2990
- CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3290
+ let screen = self?.screenName(in: context) ?? "screen"
3291
+ CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3292
+ // To the app's crash reporter too (bughq, when configured): an
3293
+ // uncaught error in a shipped screen is otherwise only in a log
3294
+ // nobody reads on someone else's phone.
3295
+ CraftCrashReporter.shared.captureJavaScript(
3296
+ name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
3297
+ message: message,
3298
+ stack: stack,
3299
+ route: screen,
3300
+ isAppScript: self?.isAppScript ?? false
3301
+ )
2991
3302
  self?.hybridFailed(message)
2992
3303
  }
2993
3304
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
@@ -4215,6 +4526,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4215
4526
  var rootStyle = next.style
4216
4527
  if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
4217
4528
  rootStack.setLayoutStyle(rootStyle, for: next.view)
4529
+ rootStack.setViewportChild(next.view, fillsHeight: rootStyle["height"] == nil)
4218
4530
  rootStack.invalidateMeasurements()
4219
4531
  renderedRoot = next
4220
4532
  rootIsSafeAreaView = next.type == "SafeAreaView"
@@ -4229,6 +4541,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4229
4541
  if let root = renderedRoot { detach(root, from: rootStack) }
4230
4542
  renderedRoot = nil
4231
4543
  rootIsSafeAreaView = false
4544
+ rootStack.setViewportChild(nil, fillsHeight: false)
4232
4545
  rootTopToSafeArea?.isActive = true
4233
4546
  rootTopToView?.isActive = false
4234
4547
  rootBottomToSafeArea?.isActive = true
@@ -4520,10 +4833,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4520
4833
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
4521
4834
  case "Slider":
4522
4835
  let slider = result as! UISlider
4523
- slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4524
- slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
4525
- let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4526
- if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4836
+ let minimum = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4837
+ slider.minimumValue = minimum.isFinite ? minimum : 0
4838
+ let maximum = (props["maximumValue"] as? NSNumber)?.floatValue ?? 1
4839
+ slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, maximum.isFinite ? maximum : 1)
4840
+ let authoredValue = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4841
+ let value = authoredValue.isFinite ? authoredValue : slider.minimumValue
4842
+ if let step = (props["step"] as? NSNumber)?.floatValue, step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4527
4843
  else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
4528
4844
  slider.value = snappedSliderValue(value, for: slider)
4529
4845
  slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
@@ -4538,7 +4854,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4538
4854
  indicator.style = (requestedSize as? String) == "large" ? .large : .medium
4539
4855
  let scale: CGFloat
4540
4856
  if let numericSize = requestedSize as? NSNumber {
4541
- scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
4857
+ let value = CGFloat(numericSize.doubleValue / 20.0)
4858
+ scale = value.isFinite ? max(0.5, value) : 1
4542
4859
  } else if (requestedSize as? String) == "small" {
4543
4860
  scale = 0.75
4544
4861
  } else {
@@ -4794,6 +5111,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4794
5111
  case "checkbox": return "Checkbox"
4795
5112
  case "textinput", "input": return "TextInput"
4796
5113
  case "picker", "select": return "Picker"
5114
+ case "datepicker", "date-picker", "date": return "DatePicker"
4797
5115
  case "modal": return "Modal"
4798
5116
  case "switch": return "Switch"
4799
5117
  case "slider": return "Slider"
@@ -4933,14 +5251,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4933
5251
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
4934
5252
  }
4935
5253
 
5254
+ let threshold = max(
5255
+ 0,
5256
+ Double(number(props["onEndReachedThreshold"]) ?? number(props["threshold"]) ?? 0.1)
5257
+ )
4936
5258
  list.apply(
4937
5259
  nodes: children,
4938
5260
  horizontal: props["horizontal"] as? Bool == true,
4939
5261
  columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
4940
5262
  inverted: props["inverted"] as? Bool == true,
4941
- endReachedThreshold: (props["onEndReachedThreshold"] as? NSNumber)?.doubleValue
4942
- ?? (props["threshold"] as? NSNumber)?.doubleValue
4943
- ?? 0.1,
5263
+ endReachedThreshold: threshold,
4944
5264
  renderItem: { [weak self] node, identity, _ in
4945
5265
  guard let self = self else { return UIView() }
4946
5266
  let previous = self.flatListRows[listKey]?[identity]
@@ -5083,7 +5403,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5083
5403
 
5084
5404
  private func emitLayoutEvents(for node: RenderedNode) {
5085
5405
  let id = ObjectIdentifier(node.view)
5086
- if let handler = layoutHandlers[id], node.view.window != nil {
5406
+ if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
5087
5407
  let frame = node.view.frame
5088
5408
  if lastLayoutFrames[id] != frame {
5089
5409
  lastLayoutFrames[id] = frame
@@ -5104,6 +5424,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5104
5424
  }
5105
5425
  }
5106
5426
 
5427
+ private func isEffectivelyVisible(_ view: UIView) -> Bool {
5428
+ guard view.window != nil, !view.isHidden else { return false }
5429
+ var ancestor = view.superview
5430
+ while let current = ancestor {
5431
+ if current.isHidden { return false }
5432
+ ancestor = current.superview
5433
+ }
5434
+ return true
5435
+ }
5436
+
5107
5437
  private func updateAuxiliaryHandler(
5108
5438
  _ handler: String?,
5109
5439
  in handlers: inout [ObjectIdentifier: String],
@@ -5310,10 +5640,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5310
5640
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
5311
5641
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
5312
5642
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
5313
- let gap = number(style["gap"]) ?? 0
5643
+ let gap = max(0, number(style["gap"]) ?? 0)
5314
5644
  stack.gap = gap
5315
- stack.rowGap = number(style["rowGap"]) ?? gap
5316
- stack.columnGap = number(style["columnGap"]) ?? gap
5645
+ stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
5646
+ stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
5317
5647
  stack.grid = style["display"] as? String == "grid"
5318
5648
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
5319
5649
  stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
@@ -5388,14 +5718,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5388
5718
  }
5389
5719
 
5390
5720
  private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
5391
- let padding = number(style["padding"]) ?? 0
5392
- let horizontal = number(style["paddingHorizontal"]) ?? padding
5393
- let vertical = number(style["paddingVertical"]) ?? padding
5721
+ func nonNegative(_ value: Any?) -> CGFloat? {
5722
+ guard let value = number(value), value >= 0 else { return nil }
5723
+ return value
5724
+ }
5725
+ let padding = nonNegative(style["padding"]) ?? 0
5726
+ let horizontal = nonNegative(style["paddingHorizontal"]) ?? padding
5727
+ let vertical = nonNegative(style["paddingVertical"]) ?? padding
5394
5728
  return UIEdgeInsets(
5395
- top: number(style["paddingTop"]) ?? vertical,
5396
- left: number(style["paddingLeft"]) ?? horizontal,
5397
- bottom: number(style["paddingBottom"]) ?? vertical,
5398
- right: number(style["paddingRight"]) ?? horizontal
5729
+ top: nonNegative(style["paddingTop"]) ?? vertical,
5730
+ left: nonNegative(style["paddingLeft"]) ?? horizontal,
5731
+ bottom: nonNegative(style["paddingBottom"]) ?? vertical,
5732
+ right: nonNegative(style["paddingRight"]) ?? horizontal
5399
5733
  )
5400
5734
  }
5401
5735
 
@@ -5655,6 +5989,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5655
5989
  imageSources[id] = uri
5656
5990
  imageErrors.removeValue(forKey: id)
5657
5991
  view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
5992
+ imageDidChange(view)
5658
5993
  view.accessibilityValue = nil
5659
5994
  emitImageEvent(view, handler: imageLoadStartHandlers[id])
5660
5995
 
@@ -5666,6 +6001,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5666
6001
  return
5667
6002
  }
5668
6003
  view.image = imageForDisplay(image, view: view)
6004
+ imageDidChange(view)
5669
6005
  emitImageEvent(view, handler: imageLoadHandlers[id])
5670
6006
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5671
6007
  return
@@ -5681,6 +6017,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5681
6017
  return
5682
6018
  }
5683
6019
  view.image = self.imageForDisplay(image, view: view)
6020
+ self.imageDidChange(view)
5684
6021
  self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
5685
6022
  self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
5686
6023
  }
@@ -5695,6 +6032,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5695
6032
  return
5696
6033
  }
5697
6034
  view.image = imageForDisplay(image, view: view)
6035
+ imageDidChange(view)
5698
6036
  emitImageEvent(view, handler: imageLoadHandlers[id])
5699
6037
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5700
6038
  return
@@ -5719,6 +6057,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5719
6057
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
5720
6058
  imageErrors[ObjectIdentifier(view)] = message
5721
6059
  view.image = nil
6060
+ imageDidChange(view)
5722
6061
  view.accessibilityValue = message
5723
6062
  let id = ObjectIdentifier(view)
5724
6063
  emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
@@ -5726,6 +6065,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5726
6065
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
5727
6066
  }
5728
6067
 
6068
+ private func imageDidChange(_ view: UIImageView) {
6069
+ view.invalidateIntrinsicContentSize()
6070
+ CraftNativeFlexLayout.invalidateAncestors(of: view)
6071
+ }
6072
+
5729
6073
  private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
5730
6074
  image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
5731
6075
  }
@@ -5749,7 +6093,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5749
6093
  view.accessibilityValue = nil
5750
6094
  }
5751
6095
  let role = props["accessibilityRole"] as? String
5752
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
6096
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
5753
6097
  var traits: UIAccessibilityTraits = []
5754
6098
  switch role ?? type.lowercased() {
5755
6099
  case "button", "checkbox": traits.insert(.button)
@@ -5983,7 +6327,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5983
6327
  /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
5984
6328
  static func countdownDuration(_ value: Any?) -> TimeInterval? {
5985
6329
  let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
5986
- if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
6330
+ if let number = value as? NSNumber, !(value is Bool) {
6331
+ let seconds = number.doubleValue
6332
+ guard seconds.isFinite else { return nil }
6333
+ return min(maximum, max(0, seconds))
6334
+ }
5987
6335
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5988
6336
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5989
6337
  let parts = text.split(separator: ":")
@@ -5991,7 +6339,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5991
6339
  let hours = Double(parts[0]),
5992
6340
  let minutes = Double(parts[1]) else { return nil }
5993
6341
  let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
5994
- guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
6342
+ guard hours.isFinite, minutes.isFinite, seconds.isFinite,
6343
+ hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5995
6344
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
5996
6345
  }
5997
6346
 
@@ -5999,7 +6348,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5999
6348
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
6000
6349
  /// milliseconds since 1970. Nil for anything else.
6001
6350
  static func pickerDate(_ value: Any?) -> Date? {
6002
- if let number = value as? NSNumber, !(value is Bool) { return Date(timeIntervalSince1970: number.doubleValue / 1000) }
6351
+ if let number = value as? NSNumber, !(value is Bool) {
6352
+ let milliseconds = number.doubleValue
6353
+ guard milliseconds.isFinite else { return nil }
6354
+ return Date(timeIntervalSince1970: milliseconds / 1000)
6355
+ }
6003
6356
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
6004
6357
  let local = DateFormatter()
6005
6358
  local.locale = Locale(identifier: "en_US_POSIX")
@@ -6157,7 +6510,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6157
6510
  }
6158
6511
 
6159
6512
  private func number(_ value: Any?) -> CGFloat? {
6160
- (value as? NSNumber).map { CGFloat(truncating: $0) }
6513
+ guard let number = value as? NSNumber else { return nil }
6514
+ let value = CGFloat(truncating: number)
6515
+ return value.isFinite ? value : nil
6161
6516
  }
6162
6517
 
6163
6518
  private func keyboardType(_ value: Any?) -> UIKeyboardType {