craft-native 0.0.113 → 0.0.115

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.
@@ -58,6 +58,7 @@ dependencies {
58
58
  implementation("androidx.core:core-ktx:1.12.0")
59
59
  implementation("androidx.appcompat:appcompat:1.6.1")
60
60
  implementation("androidx.fragment:fragment-ktx:1.6.2")
61
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
61
62
  implementation("androidx.webkit:webkit:1.12.1")
62
63
  {{JAVASCRIPT_ENGINE_DEPENDENCY}}
63
64
  implementation("com.google.android.material:material:1.11.0")
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.113";
3306
+ var version = "0.0.115";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.113";
313
+ var version = "0.0.115";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -3649,6 +3649,13 @@ var _watchRecursiveWarned = false;
3649
3649
  async function watch(path, callback, options = {}) {
3650
3650
  validatePath(path);
3651
3651
  const recursive = options.recursive ?? true;
3652
+ const native2 = getCraftFs();
3653
+ if (native2?.watch) {
3654
+ const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
3655
+ return () => {
3656
+ handle.unwatch();
3657
+ };
3658
+ }
3652
3659
  if (typeof window !== "undefined" && window.craft) {
3653
3660
  const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
3654
3661
  await callBridge("fs.watch", { path, watchId, recursive });
package/dist/index.d.cts CHANGED
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
81
81
  * Quick helper to load a URL
82
82
  */
83
83
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
84
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
84
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.d.ts CHANGED
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
81
81
  * Quick helper to load a URL
82
82
  */
83
83
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
84
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
84
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.113";
61
+ var version = "0.0.115";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -3409,6 +3409,13 @@ var _watchRecursiveWarned = false;
3409
3409
  async function watch(path, callback, options = {}) {
3410
3410
  validatePath(path);
3411
3411
  const recursive = options.recursive ?? true;
3412
+ const native2 = getCraftFs();
3413
+ if (native2?.watch) {
3414
+ const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
3415
+ return () => {
3416
+ handle.unwatch();
3417
+ };
3418
+ }
3412
3419
  if (typeof window !== "undefined" && window.craft) {
3413
3420
  const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
3414
3421
  await callBridge("fs.watch", { path, watchId, recursive });
@@ -406,7 +406,9 @@ async function init(options) {
406
406
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
407
407
  writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
408
408
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
409
+ cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
409
410
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
411
+ cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
410
412
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
411
413
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
412
414
  <true/>
@@ -0,0 +1,323 @@
1
+ import UIKit
2
+
3
+ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
4
+ override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
5
+ guard let current = collectionView?.bounds else { return true }
6
+ return scrollDirection == .vertical
7
+ ? abs(current.width - newBounds.width) > .ulpOfOne
8
+ : abs(current.height - newBounds.height) > .ulpOfOne
9
+ }
10
+ }
11
+
12
+ /// A keyed, recycling native list used by the stx-native FlatList primitive.
13
+ /// The screen controller owns rendered row state; this view owns collection
14
+ /// diffs, viewport state, and UICollectionView cell reuse.
15
+ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
16
+ typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
17
+
18
+ private final class Cell: UICollectionViewCell {
19
+ static let reuseIdentifier = "CraftNativeFlatListCell"
20
+
21
+ private(set) var representedIdentity: String?
22
+ private(set) var hostedView: UIView?
23
+ var onRecycle: ((String) -> Void)?
24
+
25
+ override func prepareForReuse() {
26
+ super.prepareForReuse()
27
+ releaseHostedView()
28
+ }
29
+
30
+ func host(_ view: UIView, identity: String) {
31
+ if representedIdentity != identity { releaseHostedView() }
32
+ representedIdentity = identity
33
+ guard hostedView !== view else { return }
34
+ hostedView?.removeFromSuperview()
35
+ hostedView = view
36
+ view.translatesAutoresizingMaskIntoConstraints = false
37
+ contentView.addSubview(view)
38
+ NSLayoutConstraint.activate([
39
+ view.topAnchor.constraint(equalTo: contentView.topAnchor),
40
+ view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
41
+ view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
42
+ view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
43
+ ])
44
+ }
45
+
46
+ func releaseHostedView() {
47
+ if let identity = representedIdentity { onRecycle?(identity) }
48
+ hostedView?.removeFromSuperview()
49
+ hostedView = nil
50
+ representedIdentity = nil
51
+ onRecycle = nil
52
+ }
53
+
54
+ override func preferredLayoutAttributesFitting(
55
+ _ layoutAttributes: UICollectionViewLayoutAttributes
56
+ ) -> UICollectionViewLayoutAttributes {
57
+ guard let hostedView = hostedView else { return layoutAttributes }
58
+ let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
59
+ let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
60
+ let measured = hostedView.systemLayoutSizeFitting(
61
+ target,
62
+ withHorizontalFittingPriority: .required,
63
+ verticalFittingPriority: .fittingSizeLevel
64
+ )
65
+ attributes.size.height = max(1, measured.height)
66
+ return attributes
67
+ }
68
+ }
69
+
70
+ private struct Item {
71
+ let identity: String
72
+ let node: [String: Any]
73
+ let signature: Data
74
+ let isChrome: Bool
75
+ }
76
+
77
+ private struct ViewportAnchor {
78
+ let identity: String
79
+ let distanceFromOrigin: CGFloat
80
+ }
81
+
82
+ private let flowLayout: UICollectionViewFlowLayout
83
+ private var diffableDataSource: UICollectionViewDiffableDataSource<Int, String>!
84
+ private var items: [Item] = []
85
+ private var itemsByIdentity: [String: Item] = [:]
86
+ private var renderItem: RenderItem?
87
+ private var recycleItem: ((String) -> Void)?
88
+ private var endReached: (() -> Void)?
89
+ private var endReachedThreshold = 0.1
90
+ private var endReachedSignature: String?
91
+ private var columns = 1
92
+ private var isApplyingSnapshot = false
93
+ private var pendingApply: (() -> Void)?
94
+
95
+ init() {
96
+ let layout = CraftNativeFlatListLayout()
97
+ layout.estimatedItemSize = CGSize(width: 320, height: 44)
98
+ layout.minimumLineSpacing = 0
99
+ layout.minimumInteritemSpacing = 0
100
+ flowLayout = layout
101
+ super.init(frame: .zero, collectionViewLayout: layout)
102
+ backgroundColor = .clear
103
+ alwaysBounceVertical = true
104
+ delegate = self
105
+ register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
106
+ diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
107
+ [weak self] collectionView, indexPath, identity in
108
+ guard let self = self,
109
+ let item = self.itemsByIdentity[identity],
110
+ let cell = collectionView.dequeueReusableCell(
111
+ withReuseIdentifier: Cell.reuseIdentifier,
112
+ for: indexPath
113
+ ) as? Cell,
114
+ let renderItem = self.renderItem else { return UICollectionViewCell() }
115
+ let view = renderItem(item.node, identity, cell.hostedView)
116
+ cell.onRecycle = self.recycleItem
117
+ cell.host(view, identity: identity)
118
+ return cell
119
+ }
120
+ }
121
+
122
+ required init?(coder: NSCoder) { nil }
123
+
124
+ var visibleItemIdentities: [String] {
125
+ indexPathsForVisibleItems
126
+ .sorted { $0.item < $1.item }
127
+ .compactMap { diffableDataSource.itemIdentifier(for: $0) }
128
+ }
129
+
130
+ func apply(
131
+ nodes: [[String: Any]],
132
+ horizontal: Bool,
133
+ columns: Int,
134
+ inverted: Bool,
135
+ endReachedThreshold: Double,
136
+ renderItem: @escaping RenderItem,
137
+ recycleItem: @escaping (String) -> Void,
138
+ endReached: (() -> Void)?
139
+ ) {
140
+ if isApplyingSnapshot {
141
+ pendingApply = { [weak self] in
142
+ self?.apply(
143
+ nodes: nodes,
144
+ horizontal: horizontal,
145
+ columns: columns,
146
+ inverted: inverted,
147
+ endReachedThreshold: endReachedThreshold,
148
+ renderItem: renderItem,
149
+ recycleItem: recycleItem,
150
+ endReached: endReached
151
+ )
152
+ }
153
+ return
154
+ }
155
+ isApplyingSnapshot = true
156
+
157
+ self.renderItem = renderItem
158
+ self.recycleItem = recycleItem
159
+ self.endReached = endReached
160
+ self.endReachedThreshold = max(0, endReachedThreshold)
161
+ self.columns = max(1, columns)
162
+
163
+ layoutIfNeeded()
164
+ let oldItems = itemsByIdentity
165
+ let viewportAnchor = currentViewportAnchor()
166
+ var seen: [String: Int] = [:]
167
+ var next = nodes.enumerated().map { index, node -> Item in
168
+ let base = Self.identity(for: node) ?? "index:\(index)"
169
+ let occurrence = seen[base, default: 0]
170
+ seen[base] = occurrence + 1
171
+ let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
172
+ let props = node["props"] as? [String: Any] ?? [:]
173
+ let role = props["listRole"] as? String
174
+ let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
175
+ return Item(identity: identity, node: node, signature: signature, isChrome: role != nil && role != "item")
176
+ }
177
+ if inverted { next.reverse() }
178
+ items = next
179
+ itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
180
+
181
+ flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
182
+ alwaysBounceHorizontal = horizontal
183
+ alwaysBounceVertical = !horizontal
184
+ showsHorizontalScrollIndicator = horizontal
185
+ showsVerticalScrollIndicator = !horizontal
186
+ flowLayout.invalidateLayout()
187
+
188
+ let nextIds = Set(itemsByIdentity.keys)
189
+ for identity in oldItems.keys where !nextIds.contains(identity) { recycleItem(identity) }
190
+ let changed = next.compactMap { item -> String? in
191
+ guard let old = oldItems[item.identity], old.signature != item.signature else { return nil }
192
+ return item.identity
193
+ }
194
+
195
+ var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
196
+ snapshot.appendSections([0])
197
+ snapshot.appendItems(next.map(\.identity), toSection: 0)
198
+ let retainedOffset = contentOffset
199
+ diffableDataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
200
+ guard let self = self else { return }
201
+ let live = Set(self.diffableDataSource.snapshot().itemIdentifiers)
202
+ let changedLive = changed.filter { live.contains($0) }
203
+ let restoreViewport = { [weak self] in
204
+ guard let self = self else { return }
205
+ self.layoutIfNeeded()
206
+ if let viewportAnchor = viewportAnchor {
207
+ self.restore(viewportAnchor)
208
+ } else {
209
+ self.setContentOffset(self.clamped(retainedOffset), animated: false)
210
+ }
211
+ self.finishApplyingSnapshot()
212
+ }
213
+ if !changedLive.isEmpty {
214
+ var update = self.diffableDataSource.snapshot()
215
+ update.reconfigureItems(changedLive)
216
+ self.diffableDataSource.apply(update, animatingDifferences: false, completion: restoreViewport)
217
+ } else {
218
+ restoreViewport()
219
+ }
220
+ }
221
+
222
+ let dataItems = next.filter { !$0.isChrome }
223
+ let contentSignature = "\(dataItems.count):\(dataItems.last?.identity ?? "empty")"
224
+ if contentSignature != endReachedSignature { endReachedSignature = nil }
225
+ }
226
+
227
+ func discardAll() {
228
+ visibleCells.compactMap { $0 as? Cell }.forEach { $0.releaseHostedView() }
229
+ itemsByIdentity.keys.forEach { recycleItem?($0) }
230
+ items = []
231
+ itemsByIdentity = [:]
232
+ renderItem = nil
233
+ recycleItem = nil
234
+ endReached = nil
235
+ }
236
+
237
+ func scrollViewDidScroll(_ scrollView: UIScrollView) { evaluateEndReached() }
238
+
239
+ func collectionView(
240
+ _ collectionView: UICollectionView,
241
+ layout collectionViewLayout: UICollectionViewLayout,
242
+ sizeForItemAt indexPath: IndexPath
243
+ ) -> CGSize {
244
+ guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
245
+ let item = items[indexPath.item]
246
+ if flowLayout.scrollDirection == .horizontal {
247
+ return CGSize(width: max(1, bounds.width * 0.8), height: max(1, bounds.height))
248
+ }
249
+ let count = item.isChrome ? 1 : columns
250
+ let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
251
+ return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
252
+ }
253
+
254
+ private func evaluateEndReached() {
255
+ guard !isApplyingSnapshot, pendingApply == nil,
256
+ let endReached = endReached,
257
+ let lastVisible = indexPathsForVisibleItems.map(\.item).max() else { return }
258
+ let dataIndices = items.indices.filter { !items[$0].isChrome }
259
+ guard !dataIndices.isEmpty,
260
+ let lastDataOrdinal = dataIndices.lastIndex(where: { $0 <= lastVisible }) else { return }
261
+ let remaining = dataIndices.count - lastDataOrdinal - 1
262
+ let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
263
+ guard remaining <= thresholdItems else { return }
264
+ let lastIdentity = items[dataIndices[dataIndices.count - 1]].identity
265
+ let signature = "\(dataIndices.count):\(lastIdentity)"
266
+ guard endReachedSignature != signature else { return }
267
+ endReachedSignature = signature
268
+ endReached()
269
+ }
270
+
271
+ private func finishApplyingSnapshot() {
272
+ DispatchQueue.main.async { [weak self] in
273
+ guard let self = self else { return }
274
+ self.isApplyingSnapshot = false
275
+ if let pendingApply = self.pendingApply {
276
+ self.pendingApply = nil
277
+ pendingApply()
278
+ return
279
+ }
280
+ self.evaluateEndReached()
281
+ }
282
+ }
283
+
284
+ private func clamped(_ offset: CGPoint) -> CGPoint {
285
+ CGPoint(
286
+ x: min(max(-adjustedContentInset.left, offset.x), max(-adjustedContentInset.left, contentSize.width - bounds.width + adjustedContentInset.right)),
287
+ y: min(max(-adjustedContentInset.top, offset.y), max(-adjustedContentInset.top, contentSize.height - bounds.height + adjustedContentInset.bottom))
288
+ )
289
+ }
290
+
291
+ private func currentViewportAnchor() -> ViewportAnchor? {
292
+ let horizontal = flowLayout.scrollDirection == .horizontal
293
+ let ordered = indexPathsForVisibleItems.sorted { lhs, rhs in
294
+ let left = layoutAttributesForItem(at: lhs)?.frame
295
+ let right = layoutAttributesForItem(at: rhs)?.frame
296
+ return horizontal ? (left?.minX ?? 0) < (right?.minX ?? 0) : (left?.minY ?? 0) < (right?.minY ?? 0)
297
+ }
298
+ guard let indexPath = ordered.first,
299
+ let identity = diffableDataSource.itemIdentifier(for: indexPath),
300
+ let frame = layoutAttributesForItem(at: indexPath)?.frame else { return nil }
301
+ let distance = horizontal ? frame.minX - contentOffset.x : frame.minY - contentOffset.y
302
+ return ViewportAnchor(identity: identity, distanceFromOrigin: distance)
303
+ }
304
+
305
+ private func restore(_ anchor: ViewportAnchor) {
306
+ guard let indexPath = diffableDataSource.indexPath(for: anchor.identity),
307
+ let frame = layoutAttributesForItem(at: indexPath)?.frame else { return }
308
+ var offset = contentOffset
309
+ if flowLayout.scrollDirection == .horizontal {
310
+ offset.x = frame.minX - anchor.distanceFromOrigin
311
+ } else {
312
+ offset.y = frame.minY - anchor.distanceFromOrigin
313
+ }
314
+ setContentOffset(clamped(offset), animated: false)
315
+ }
316
+
317
+ private static func identity(for node: [String: Any]) -> String? {
318
+ let props = node["props"] as? [String: Any] ?? [:]
319
+ return [node["id"], node["key"], props["key"], props["testID"]]
320
+ .compactMap { $0 as? String }
321
+ .first { !$0.isEmpty }
322
+ }
323
+ }