craft-native 0.0.113 → 0.0.118

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.
@@ -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
+ }
@@ -0,0 +1,365 @@
1
+ import CoreFoundation
2
+ import Foundation
3
+
4
+ let craftNativeMutationProtocolVersion = 1
5
+
6
+ struct CraftNativeMutationFailure: Error, Equatable {
7
+ let code: String
8
+ let message: String
9
+ let operationIndex: Int?
10
+
11
+ init(_ code: String, _ message: String, operationIndex: Int? = nil) {
12
+ self.code = code
13
+ self.message = message
14
+ self.operationIndex = operationIndex
15
+ }
16
+ }
17
+
18
+ struct CraftNativeMutationResult {
19
+ let batchId: String
20
+ let revision: Int
21
+ let document: [String: Any]?
22
+ let updatedNodeIds: [String]
23
+ let affectedNodeIds: [String]
24
+ let requiresFullRender: Bool
25
+ }
26
+
27
+ /// Validates and applies versioned native-tree mutations without touching UIKit.
28
+ /// A batch is committed only after every operation succeeds against a copy.
29
+ final class CraftNativeMutationDocument {
30
+ private struct Node {
31
+ let id: String
32
+ var type: String
33
+ var props: [String: Any]
34
+ var style: [String: Any]
35
+ var events: [String: Any]
36
+ var text: [String]
37
+ var children: [String]
38
+ var parent: String?
39
+ }
40
+
41
+ private var nodes: [String: Node] = [:]
42
+ private var rootId: String?
43
+ private(set) var revision = 0
44
+
45
+ func replace(with document: [String: Any]) {
46
+ var replacement: [String: Node] = [:]
47
+ let root = importNode(document, preferredId: "root", parent: nil, nodes: &replacement)
48
+ nodes = replacement
49
+ rootId = root
50
+ revision = 0
51
+ }
52
+
53
+ func apply(_ payload: [String: Any]) throws -> CraftNativeMutationResult {
54
+ let batchId = payload["batchId"] as? String ?? ""
55
+ guard !batchId.isEmpty else {
56
+ throw CraftNativeMutationFailure("INVALID_BATCH", "batchId must be a non-empty string")
57
+ }
58
+ guard integer(payload["version"]) == craftNativeMutationProtocolVersion else {
59
+ throw CraftNativeMutationFailure(
60
+ "UNSUPPORTED_VERSION",
61
+ "mutation protocol version must be \(craftNativeMutationProtocolVersion)"
62
+ )
63
+ }
64
+ guard let baseRevision = integer(payload["baseRevision"]), baseRevision == revision else {
65
+ throw CraftNativeMutationFailure(
66
+ "REVISION_MISMATCH",
67
+ "baseRevision must equal the current revision \(revision)"
68
+ )
69
+ }
70
+ guard let nextRevision = integer(payload["revision"]), nextRevision == revision + 1 else {
71
+ throw CraftNativeMutationFailure(
72
+ "INVALID_REVISION",
73
+ "revision must advance exactly once from \(revision)"
74
+ )
75
+ }
76
+ guard let operations = payload["operations"] as? [Any], !operations.isEmpty else {
77
+ throw CraftNativeMutationFailure("INVALID_BATCH", "operations must be a non-empty array")
78
+ }
79
+
80
+ var candidateNodes = nodes
81
+ var candidateRoot = rootId
82
+ var updatedNodeIds: [String] = []
83
+ var affectedNodeIds: [String] = []
84
+ var requiresFullRender = false
85
+ for (index, value) in operations.enumerated() {
86
+ guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
87
+ throw CraftNativeMutationFailure(
88
+ "INVALID_OPERATION",
89
+ "operation must be an object with an op string",
90
+ operationIndex: index
91
+ )
92
+ }
93
+ do {
94
+ switch name {
95
+ case "createNode":
96
+ if operation["root"] as? Bool == true { requiresFullRender = true }
97
+ try createNode(operation, nodes: &candidateNodes, rootId: &candidateRoot)
98
+ case "updateNode":
99
+ try updateNode(operation, nodes: &candidateNodes)
100
+ if let id = operation["id"] as? String {
101
+ appendUnique(id, to: &updatedNodeIds)
102
+ appendUnique(id, to: &affectedNodeIds)
103
+ }
104
+ case "insertChild":
105
+ try insertChild(operation, nodes: &candidateNodes)
106
+ if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
107
+ case "moveChild":
108
+ try moveChild(operation, nodes: &candidateNodes)
109
+ if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
110
+ case "removeNode":
111
+ if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
112
+ appendUnique(parent, to: &affectedNodeIds)
113
+ } else {
114
+ requiresFullRender = true
115
+ }
116
+ try removeNode(operation, nodes: &candidateNodes, rootId: &candidateRoot)
117
+ default:
118
+ throw CraftNativeMutationFailure("UNKNOWN_OPERATION", "unsupported operation \(name)")
119
+ }
120
+ } catch let failure as CraftNativeMutationFailure {
121
+ throw CraftNativeMutationFailure(failure.code, failure.message, operationIndex: index)
122
+ }
123
+ }
124
+ if candidateRoot == nil, !candidateNodes.isEmpty {
125
+ throw CraftNativeMutationFailure("MISSING_ROOT", "a non-empty document must have one root node")
126
+ }
127
+ if let candidateRoot {
128
+ guard let root = candidateNodes[candidateRoot], root.parent == nil else {
129
+ throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
130
+ }
131
+ guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
132
+ throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
133
+ }
134
+ }
135
+
136
+ nodes = candidateNodes
137
+ rootId = candidateRoot
138
+ revision = nextRevision
139
+ return CraftNativeMutationResult(
140
+ batchId: batchId,
141
+ revision: nextRevision,
142
+ document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
143
+ updatedNodeIds: updatedNodeIds,
144
+ affectedNodeIds: affectedNodeIds,
145
+ requiresFullRender: requiresFullRender
146
+ )
147
+ }
148
+
149
+ func node(_ id: String) -> [String: Any]? {
150
+ materialize(id, nodes: nodes)
151
+ }
152
+
153
+ private func createNode(
154
+ _ operation: [String: Any],
155
+ nodes: inout [String: Node],
156
+ rootId: inout String?
157
+ ) throws {
158
+ guard let id = operation["id"] as? String, !id.isEmpty else {
159
+ throw CraftNativeMutationFailure("INVALID_NODE", "createNode.id must be a non-empty string")
160
+ }
161
+ guard nodes[id] == nil else {
162
+ throw CraftNativeMutationFailure("DUPLICATE_NODE", "node \(id) already exists")
163
+ }
164
+ guard let value = operation["node"] as? [String: Any],
165
+ let type = value["type"] as? String, !type.isEmpty else {
166
+ throw CraftNativeMutationFailure("INVALID_NODE", "createNode.node.type must be a non-empty string")
167
+ }
168
+ let text = try textChildren(value["children"] ?? value["text"])
169
+ let makeRoot = operation["root"] as? Bool == true
170
+ if makeRoot, rootId != nil {
171
+ throw CraftNativeMutationFailure("ROOT_EXISTS", "the document already has a root node")
172
+ }
173
+ nodes[id] = Node(
174
+ id: id,
175
+ type: type,
176
+ props: dictionary(value["props"]),
177
+ style: dictionary(value["style"]),
178
+ events: dictionary(value["events"]),
179
+ text: text,
180
+ children: [],
181
+ parent: nil
182
+ )
183
+ if makeRoot { rootId = id }
184
+ }
185
+
186
+ private func updateNode(_ operation: [String: Any], nodes: inout [String: Node]) throws {
187
+ let id = try requiredId(operation, name: "updateNode")
188
+ guard var node = nodes[id] else {
189
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(id) does not exist")
190
+ }
191
+ guard let patch = operation["patch"] as? [String: Any] else {
192
+ throw CraftNativeMutationFailure("INVALID_PATCH", "updateNode.patch must be an object")
193
+ }
194
+ let allowed = Set(["props", "style", "events", "children", "text"])
195
+ if let unknown = patch.keys.sorted().first(where: { !allowed.contains($0) }) {
196
+ throw CraftNativeMutationFailure("INVALID_PATCH", "unsupported patch field \(unknown)")
197
+ }
198
+ if patch.keys.contains("props") { node.props = dictionary(patch["props"]) }
199
+ if patch.keys.contains("style") { node.style = dictionary(patch["style"]) }
200
+ if patch.keys.contains("events") { node.events = dictionary(patch["events"]) }
201
+ if patch.keys.contains("children") || patch.keys.contains("text") {
202
+ node.text = try textChildren(patch["children"] ?? patch["text"])
203
+ }
204
+ nodes[id] = node
205
+ }
206
+
207
+ private func insertChild(_ operation: [String: Any], nodes: inout [String: Node]) throws {
208
+ let parentId = try requiredId(operation, field: "parentId", name: "insertChild")
209
+ let childId = try requiredId(operation, field: "childId", name: "insertChild")
210
+ guard var parent = nodes[parentId] else {
211
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "parent node \(parentId) does not exist")
212
+ }
213
+ guard var child = nodes[childId] else {
214
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
215
+ }
216
+ guard acceptsChildren(parent.type) else {
217
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
218
+ }
219
+ guard child.parent == nil else {
220
+ throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
221
+ }
222
+ guard parentId != childId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
223
+ throw CraftNativeMutationFailure("CYCLE", "inserting node \(childId) would create a cycle")
224
+ }
225
+ let index = try insertionIndex(operation, count: parent.children.count)
226
+ parent.children.insert(childId, at: index)
227
+ child.parent = parentId
228
+ nodes[parentId] = parent
229
+ nodes[childId] = child
230
+ }
231
+
232
+ private func moveChild(_ operation: [String: Any], nodes: inout [String: Node]) throws {
233
+ let parentId = try requiredId(operation, field: "parentId", name: "moveChild")
234
+ let childId = try requiredId(operation, field: "childId", name: "moveChild")
235
+ guard var parent = nodes[parentId] else {
236
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "parent node \(parentId) does not exist")
237
+ }
238
+ guard nodes[childId]?.parent == parentId, let oldIndex = parent.children.firstIndex(of: childId) else {
239
+ throw CraftNativeMutationFailure("NOT_A_CHILD", "node \(childId) is not a child of \(parentId)")
240
+ }
241
+ parent.children.remove(at: oldIndex)
242
+ let index = try insertionIndex(operation, count: parent.children.count)
243
+ parent.children.insert(childId, at: index)
244
+ nodes[parentId] = parent
245
+ }
246
+
247
+ private func removeNode(
248
+ _ operation: [String: Any],
249
+ nodes: inout [String: Node],
250
+ rootId: inout String?
251
+ ) throws {
252
+ let id = try requiredId(operation, name: "removeNode")
253
+ guard let node = nodes[id] else {
254
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(id) does not exist")
255
+ }
256
+ if let parentId = node.parent, var parent = nodes[parentId] {
257
+ parent.children.removeAll { $0 == id }
258
+ nodes[parentId] = parent
259
+ }
260
+ for descendant in descendants(of: id, nodes: nodes).union([id]) { nodes.removeValue(forKey: descendant) }
261
+ if rootId == id { rootId = nil }
262
+ }
263
+
264
+ private func requiredId(
265
+ _ operation: [String: Any],
266
+ field: String = "id",
267
+ name: String
268
+ ) throws -> String {
269
+ guard let id = operation[field] as? String, !id.isEmpty else {
270
+ throw CraftNativeMutationFailure("INVALID_NODE", "\(name).\(field) must be a non-empty string")
271
+ }
272
+ return id
273
+ }
274
+
275
+ private func insertionIndex(_ operation: [String: Any], count: Int) throws -> Int {
276
+ guard let index = integer(operation["index"]), index >= 0, index <= count else {
277
+ throw CraftNativeMutationFailure("INVALID_INDEX", "index must be between 0 and \(count)")
278
+ }
279
+ return index
280
+ }
281
+
282
+ private func textChildren(_ value: Any?) throws -> [String] {
283
+ if value == nil || value is NSNull { return [] }
284
+ if let text = value as? String { return [text] }
285
+ guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
286
+ throw CraftNativeMutationFailure(
287
+ "INVALID_NODE",
288
+ "node children may contain text only; use insertChild for node relationships"
289
+ )
290
+ }
291
+ return values.compactMap { $0 as? String }
292
+ }
293
+
294
+ private func acceptsChildren(_ type: String) -> Bool {
295
+ type == "View" || type == "SafeAreaView" || type == "ScrollView"
296
+ }
297
+
298
+ private func descendants(of id: String, nodes: [String: Node]) -> Set<String> {
299
+ guard let node = nodes[id] else { return [] }
300
+ return node.children.reduce(into: Set<String>()) { result, child in
301
+ result.insert(child)
302
+ result.formUnion(descendants(of: child, nodes: nodes))
303
+ }
304
+ }
305
+
306
+ private func materialize(_ id: String, nodes: [String: Node]) -> [String: Any]? {
307
+ guard let node = nodes[id] else { return nil }
308
+ var value: [String: Any] = [
309
+ "id": node.id,
310
+ "type": node.type,
311
+ "props": node.props,
312
+ "style": node.style,
313
+ "events": node.events,
314
+ ]
315
+ let text: [Any] = node.text
316
+ let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
317
+ value["children"] = text + children
318
+ return value
319
+ }
320
+
321
+ private func importNode(
322
+ _ value: [String: Any],
323
+ preferredId: String,
324
+ parent: String?,
325
+ nodes: inout [String: Node]
326
+ ) -> String {
327
+ let props = dictionary(value["props"])
328
+ let explicit = (value["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
329
+ let key = ([value["key"], props["key"], props["testID"]].compactMap { $0 as? String }.first)
330
+ var id = explicit ?? key.map { "\(parent ?? "root")/key:\($0)" } ?? preferredId
331
+ if nodes[id] != nil { id = "\(preferredId)#\(nodes.count)" }
332
+ let children = value["children"] as? [Any] ?? []
333
+ nodes[id] = Node(
334
+ id: id,
335
+ type: value["type"] as? String ?? "View",
336
+ props: props,
337
+ style: dictionary(value["style"]),
338
+ events: dictionary(value["events"]),
339
+ text: children.compactMap { $0 as? String },
340
+ children: [],
341
+ parent: parent
342
+ )
343
+ let childIds = children.enumerated().compactMap { index, child -> String? in
344
+ guard let child = child as? [String: Any] else { return nil }
345
+ return importNode(child, preferredId: "\(id)/index:\(index)", parent: id, nodes: &nodes)
346
+ }
347
+ nodes[id]?.children = childIds
348
+ return id
349
+ }
350
+
351
+ private func dictionary(_ value: Any?) -> [String: Any] {
352
+ value as? [String: Any] ?? [:]
353
+ }
354
+
355
+ private func integer(_ value: Any?) -> Int? {
356
+ guard let number = value as? NSNumber else { return nil }
357
+ guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
358
+ let candidate = number.intValue
359
+ return number.doubleValue == Double(candidate) ? candidate : nil
360
+ }
361
+
362
+ private func appendUnique(_ id: String, to values: inout [String]) {
363
+ if !values.contains(id) { values.append(id) }
364
+ }
365
+ }