From 12e3e911cfe603156eb02c526e92c83a0c7d1582 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 13:31:07 -0400 Subject: [PATCH 01/11] Give callbacks stable path-keyed ids instead of generation-tagged ones --- .../SwiftUICore/CallbackRegistry.swift | 69 ++++++++++++------- .../Sources/SwiftUICore/Evaluator.swift | 7 ++ .../Sources/SwiftUICore/FocusState.swift | 2 +- SwiftUICore/Sources/SwiftUICore/Gesture.swift | 4 +- SwiftUICore/Sources/SwiftUICore/List.swift | 4 +- .../Modifiers/InteractionModifiers.swift | 12 ++-- .../Modifiers/KeyboardModifiers.swift | 2 +- .../Sources/SwiftUICore/Navigation.swift | 6 +- .../Sources/SwiftUICore/Presentation.swift | 10 +-- .../SwiftUICore/Primitives/Button.swift | 2 +- .../Primitives/ComposableView.swift | 14 ++-- .../SwiftUICore/Primitives/Controls.swift | 2 +- .../SwiftUICore/Primitives/DatePicker.swift | 2 +- .../Primitives/DisclosureGroup.swift | 2 +- .../Primitives/GeometryReader.swift | 2 +- .../SwiftUICore/Primitives/LazyStacks.swift | 3 +- .../SwiftUICore/Primitives/Picker.swift | 2 +- .../SwiftUICore/Primitives/Popover.swift | 2 +- .../SwiftUICore/Primitives/Stepper.swift | 4 +- .../SwiftUICore/Primitives/Views.swift | 6 +- SwiftUICore/Sources/SwiftUICore/TabView.swift | 2 +- .../Sources/SwiftUICore/ViewHost.swift | 2 +- .../SwiftUICoreTests/EvaluatorTests.swift | 43 ++++++++++++ 23 files changed, 136 insertions(+), 68 deletions(-) diff --git a/SwiftUICore/Sources/SwiftUICore/CallbackRegistry.swift b/SwiftUICore/Sources/SwiftUICore/CallbackRegistry.swift index 9d9cde6..b9d6c5b 100644 --- a/SwiftUICore/Sources/SwiftUICore/CallbackRegistry.swift +++ b/SwiftUICore/Sources/SwiftUICore/CallbackRegistry.swift @@ -4,8 +4,13 @@ // // Event closures can't cross the JNI boundary; nodes carry integer ids into // this table instead, and the fixed Kotlin→Swift dispatcher looks them up. -// Ids are generation-tagged so a stale event (dispatched just before an -// update) still resolves for one cycle before its entry is reclaimed. +// +// Ids are STABLE: derived from the registering view's identity path plus the +// ordinal of the registration within that path, so the same handler keeps the +// same id across every evaluation. That's what lets a node be reused without +// re-materialization (subtree patching) keep a valid callback — a +// generation-counter id would be evicted out from under it. Re-registering at +// a path overwrites the closure in place, so the freshest capture always wins. // public final class CallbackRegistry { @@ -21,60 +26,72 @@ public final class CallbackRegistry { case item((Int) -> RenderNode) } - private var current: [Int64: Callback] = [:] - private var previous: [Int64: Callback] = [:] - private var generation: Int32 = 0 - private var counter: Int32 = 0 + private var callbacks: [Int64: Callback] = [:] + /// Per-path registration counter for the pass in progress; gives each + /// callback at a path a stable ordinal (0, 1, 2, …) in registration order. + private var ordinals: [String: Int] = [:] public init() {} - /// Begins a fresh evaluation generation. The prior generation stays - /// resolvable for one cycle so in-flight events don't dangle. - public func beginGeneration() { - previous = current - current = [:] - generation &+= 1 - counter = 0 + /// Begins a fresh evaluation pass. Only the ordinal counters reset — the + /// callback table persists, since ids are stable and a re-registration just + /// overwrites its slot. (Lazy rows register outside the pass, during Compose + /// composition, so entries are never evicted here.) + public func beginPass() { + ordinals.removeAll(keepingCapacity: true) } - /// Registers a callback, returning its id (high 32 bits = generation). - public func register(_ callback: Callback) -> Int64 { - let id = (Int64(generation) << 32) | Int64(counter) - counter &+= 1 - current[id] = callback + /// Registers a callback for the view at `path`, returning a stable id. + public func register(_ callback: Callback, path: String) -> Int64 { + let ordinal = ordinals[path, default: 0] + ordinals[path] = ordinal + 1 + let id = Self.stableID(path: path, ordinal: ordinal) + callbacks[id] = callback return id } - /// Looks up a callback in the current or immediately-previous generation. + /// Looks up a callback by id. public func callback(for id: Int64) -> Callback? { - current[id] ?? previous[id] + callbacks[id] + } + + /// A stable 63-bit id for `path#ordinal` (FNV-1a). Collisions across a view + /// tree are astronomically unlikely. + private static func stableID(path: String, ordinal: Int) -> Int64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + func mix(_ byte: UInt8) { hash = (hash ^ UInt64(byte)) &* 0x0000_0100_0000_01b3 } + for byte in path.utf8 { mix(byte) } + mix(0x23) // '#' + var bits = UInt(bitPattern: ordinal) + repeat { mix(UInt8(bits & 0xff)); bits >>= 8 } while bits != 0 + return Int64(hash & 0x7fff_ffff_ffff_ffff) } // Typed dispatch entry points, matching the fixed Kotlin surface. public func invokeVoid(_ id: Int64) { - if case .void(let action)? = callback(for: id) { action() } + if case .void(let action)? = callbacks[id] { action() } } public func invokeBool(_ id: Int64, _ value: Bool) { - if case .bool(let action)? = callback(for: id) { action(value) } + if case .bool(let action)? = callbacks[id] { action(value) } } public func invokeDouble(_ id: Int64, _ value: Double) { - if case .double(let action)? = callback(for: id) { action(value) } + if case .double(let action)? = callbacks[id] { action(value) } } public func invokeInt(_ id: Int64, _ value: Int) { - if case .int(let action)? = callback(for: id) { action(value) } + if case .int(let action)? = callbacks[id] { action(value) } } public func invokeString(_ id: Int64, _ value: String) { - if case .string(let action)? = callback(for: id) { action(value) } + if case .string(let action)? = callbacks[id] { action(value) } } /// Resolves a lazy row on demand. public func item(_ id: Int64, _ index: Int) -> RenderNode? { - if case .item(let provider)? = callback(for: id) { return provider(index) } + if case .item(let provider)? = callbacks[id] { return provider(index) } return nil } } diff --git a/SwiftUICore/Sources/SwiftUICore/Evaluator.swift b/SwiftUICore/Sources/SwiftUICore/Evaluator.swift index 81c6a50..200cf15 100644 --- a/SwiftUICore/Sources/SwiftUICore/Evaluator.swift +++ b/SwiftUICore/Sources/SwiftUICore/Evaluator.swift @@ -44,6 +44,13 @@ public struct ResolveContext { self.depth = depth } + /// Registers a callback for the view at this context's identity path. + /// Ids are stable per (path, registration order), so a re-evaluated view's + /// handlers keep the same ids — see `CallbackRegistry`. + public func registerCallback(_ callback: CallbackRegistry.Callback) -> Int64 { + callbacks.register(callback, path: path) + } + /// Context for a child at a structurally stable position. public func descending(_ component: String) -> ResolveContext { var context = self diff --git a/SwiftUICore/Sources/SwiftUICore/FocusState.swift b/SwiftUICore/Sources/SwiftUICore/FocusState.swift index be7d04f..191f815 100644 --- a/SwiftUICore/Sources/SwiftUICore/FocusState.swift +++ b/SwiftUICore/Sources/SwiftUICore/FocusState.swift @@ -70,7 +70,7 @@ public struct _FocusedModifier: RenderModifier, _CallbackModifier { public var _modifierNode: ModifierNode { ModifierNode(kind: "focused") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.bool(setFocused)) + let id = context.registerCallback(.bool(setFocused)) return ModifierNode(kind: "focused", args: [ "isFocused": .bool(isFocused), "onChange": .int(Int(id)), diff --git a/SwiftUICore/Sources/SwiftUICore/Gesture.swift b/SwiftUICore/Sources/SwiftUICore/Gesture.swift index 02a8a23..46b605c 100644 --- a/SwiftUICore/Sources/SwiftUICore/Gesture.swift +++ b/SwiftUICore/Sources/SwiftUICore/Gesture.swift @@ -70,7 +70,7 @@ public struct _GestureModifier: RenderModifier, _CallbackModifier { public func _callbackNode(in context: ResolveContext) -> ModifierNode { let changed = gesture.changedAction let ended = gesture.endedAction - let id = context.callbacks.register(.string { payload in + let id = context.registerCallback(.string { payload in guard let value = DragGesture.Value(payload: payload) else { return } if payload.hasPrefix("ended") { ended?(value) @@ -92,7 +92,7 @@ public struct _LongPressModifier: RenderModifier, _CallbackModifier { public var _modifierNode: ModifierNode { ModifierNode(kind: "longPress") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) return ModifierNode(kind: "longPress", args: ["action": .int(Int(id))]) } } diff --git a/SwiftUICore/Sources/SwiftUICore/List.swift b/SwiftUICore/Sources/SwiftUICore/List.swift index f3ccc71..dcc829b 100644 --- a/SwiftUICore/Sources/SwiftUICore/List.swift +++ b/SwiftUICore/Sources/SwiftUICore/List.swift @@ -52,7 +52,7 @@ extension List: PrimitiveView { path: listPath + "/" + key ) return Evaluator.resolve(rowBuilder(element), rowContext) - }) + }, path: listPath) let keys = elements.map { PropValue.string(identityString(keyFor($0))) } var props: [String: PropValue] = [ @@ -62,7 +62,7 @@ extension List: PrimitiveView { if let onRefresh = context.refreshSink?.action { let refreshID = callbacks.register(.void { Task { await onRefresh() } - }) + }, path: listPath) props["onRefresh"] = .int(Int(refreshID)) } return RenderNode(type: "List", id: context.path, props: props, count: elements.count) diff --git a/SwiftUICore/Sources/SwiftUICore/Modifiers/InteractionModifiers.swift b/SwiftUICore/Sources/SwiftUICore/Modifiers/InteractionModifiers.swift index 05438b4..68706d2 100644 --- a/SwiftUICore/Sources/SwiftUICore/Modifiers/InteractionModifiers.swift +++ b/SwiftUICore/Sources/SwiftUICore/Modifiers/InteractionModifiers.swift @@ -14,7 +14,7 @@ public struct _OnTapGestureModifier: RenderModifier, _CallbackModifier { let action: () -> Void public var _modifierNode: ModifierNode { ModifierNode(kind: "onTapGesture") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) return ModifierNode(kind: "onTapGesture", args: ["action": .int(Int(id))]) } } @@ -31,7 +31,7 @@ public struct _OnAppearModifier: RenderModifier, _CallbackModifier { let action: () -> Void public var _modifierNode: ModifierNode { ModifierNode(kind: "onAppear") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) return ModifierNode(kind: "onAppear", args: ["action": .int(Int(id))]) } } @@ -40,7 +40,7 @@ public struct _OnDisappearModifier: RenderModifier, _CallbackModifier { let action: () -> Void public var _modifierNode: ModifierNode { ModifierNode(kind: "onDisappear") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) return ModifierNode(kind: "onDisappear", args: ["action": .int(Int(id))]) } } @@ -83,8 +83,8 @@ public struct _TaskModifier: RenderModifier, _CallbackModifier { // when the view leaves the tree. let action = self.action let path = context.path - let start = context.callbacks.register(.void { _TaskRegistry.start(path: path, action: action) }) - let cancel = context.callbacks.register(.void { _TaskRegistry.cancel(path: path) }) + let start = context.registerCallback(.void { _TaskRegistry.start(path: path, action: action) }) + let cancel = context.registerCallback(.void { _TaskRegistry.cancel(path: path) }) return ModifierNode(kind: "task", args: ["start": .int(Int(start)), "cancel": .int(Int(cancel))]) } } @@ -102,7 +102,7 @@ public struct _OnChangeModifier: RenderModifier, _CallbackModifier let action: () -> Void public var _modifierNode: ModifierNode { ModifierNode(kind: "onChange") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) // The interpreter fires the action when this token changes between // evaluations (skipping the first composition). return ModifierNode(kind: "onChange", args: [ diff --git a/SwiftUICore/Sources/SwiftUICore/Modifiers/KeyboardModifiers.swift b/SwiftUICore/Sources/SwiftUICore/Modifiers/KeyboardModifiers.swift index c5eb18e..f0755c6 100644 --- a/SwiftUICore/Sources/SwiftUICore/Modifiers/KeyboardModifiers.swift +++ b/SwiftUICore/Sources/SwiftUICore/Modifiers/KeyboardModifiers.swift @@ -56,7 +56,7 @@ public struct _OnSubmitModifier: RenderModifier, _CallbackModifier { let action: () -> Void public var _modifierNode: ModifierNode { ModifierNode(kind: "onSubmit") } public func _callbackNode(in context: ResolveContext) -> ModifierNode { - let id = context.callbacks.register(.void(action)) + let id = context.registerCallback(.void(action)) return ModifierNode(kind: "onSubmit", args: ["action": .int(Int(id))]) } } diff --git a/SwiftUICore/Sources/SwiftUICore/Navigation.swift b/SwiftUICore/Sources/SwiftUICore/Navigation.swift index b0423e2..e630343 100644 --- a/SwiftUICore/Sources/SwiftUICore/Navigation.swift +++ b/SwiftUICore/Sources/SwiftUICore/Navigation.swift @@ -107,7 +107,7 @@ extension NavigationStack: PrimitiveView { resolveScreen(view, index: offset + 1, canDismiss: true) } - let popID = context.callbacks.register(.void { model.pop() }) + let popID = context.registerCallback(.void { model.pop() }) return RenderNode( type: "NavStack", id: context.path, @@ -155,7 +155,7 @@ extension NavigationLink: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let model = context.environment.object(of: NavigationModel.self) let destination = self.destination - let callbackID = context.callbacks.register(.void { + let callbackID = context.registerCallback(.void { switch destination { case .view(let content): model?.pushView(content) case .value(let value): model?.pushValue(value) @@ -203,7 +203,7 @@ public struct _SearchableView: View { extension _SearchableView: _ResolutionEffectView { public func _applyEffect(_ context: inout ResolveContext) -> any View { let binding = text - let id = context.callbacks.register(.string { binding.wrappedValue = $0 }) + let id = context.registerCallback(.string { binding.wrappedValue = $0 }) context.titleSink?.searchText = text.wrappedValue context.titleSink?.searchCallbackID = id context.titleSink?.searchPrompt = prompt diff --git a/SwiftUICore/Sources/SwiftUICore/Presentation.swift b/SwiftUICore/Sources/SwiftUICore/Presentation.swift index a334192..795c377 100644 --- a/SwiftUICore/Sources/SwiftUICore/Presentation.swift +++ b/SwiftUICore/Sources/SwiftUICore/Presentation.swift @@ -32,7 +32,7 @@ extension _SheetView: PrimitiveView { return node } let binding = isPresented - let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + let dismissID = context.registerCallback(.void { binding.wrappedValue = false }) var sheetContext = context.descending("sheet") sheetContext.environment.values.dismiss = DismissAction { binding.wrappedValue = false } @@ -120,7 +120,7 @@ extension _AlertView: PrimitiveView { var buttonNodes: [PropValue] = [] for button in buttons { let action = button.action - let id = context.callbacks.register(.void { + let id = context.registerCallback(.void { action() binding.wrappedValue = false }) @@ -130,7 +130,7 @@ extension _AlertView: PrimitiveView { .int(Int(id)), ])) } - let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + let dismissID = context.registerCallback(.void { binding.wrappedValue = false }) var props: [String: PropValue] = [ "title": .string(title), @@ -195,7 +195,7 @@ extension _ConfirmationDialogView: PrimitiveView { var buttonNodes: [PropValue] = [] for button in buttons { let action = button.action - let id = context.callbacks.register(.void { + let id = context.registerCallback(.void { action() binding.wrappedValue = false }) @@ -205,7 +205,7 @@ extension _ConfirmationDialogView: PrimitiveView { .int(Int(id)), ])) } - let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + let dismissID = context.registerCallback(.void { binding.wrappedValue = false }) // The title only shows when explicitly made visible (matching iOS, where // a confirmation dialog hides its title by default). diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/Button.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/Button.swift index 1a84c8c..228e329 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/Button.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/Button.swift @@ -25,7 +25,7 @@ public extension Button where Label == Text { extension Button: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { - let callbackID = context.callbacks.register(.void(action)) + let callbackID = context.registerCallback(.void(action)) return RenderNode( type: "Button", id: context.path, diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/ComposableView.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/ComposableView.swift index df47e49..51522a2 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/ComposableView.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/ComposableView.swift @@ -37,13 +37,13 @@ public enum ComposableAction { case int((Int) -> Void) case string((String) -> Void) - internal func register(in callbacks: CallbackRegistry) -> Int64 { + internal func register(in callbacks: CallbackRegistry, path: String) -> Int64 { switch self { - case .void(let action): return callbacks.register(.void(action)) - case .bool(let action): return callbacks.register(.bool(action)) - case .double(let action): return callbacks.register(.double(action)) - case .int(let action): return callbacks.register(.int(action)) - case .string(let action): return callbacks.register(.string(action)) + case .void(let action): return callbacks.register(.void(action), path: path) + case .bool(let action): return callbacks.register(.bool(action), path: path) + case .double(let action): return callbacks.register(.double(action), path: path) + case .int(let action): return callbacks.register(.int(action), path: path) + case .string(let action): return callbacks.register(.string(action), path: path) } } } @@ -83,7 +83,7 @@ extension ComposableView: PrimitiveView { // Each action registers a callback; its id crosses as a prop the factory // reads back as a typed lambda. for (key, action) in actions { - props[key] = .int(Int(action.register(in: context.callbacks))) + props[key] = .int(Int(action.register(in: context.callbacks, path: context.path))) } return RenderNode( type: "Composable", diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/Controls.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/Controls.swift index 5785f5a..fa09eb6 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/Controls.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/Controls.swift @@ -26,7 +26,7 @@ public extension Toggle where Label == Text { extension Toggle: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let binding = isOn - let callbackID = context.callbacks.register(.bool { binding.wrappedValue = $0 }) + let callbackID = context.registerCallback(.bool { binding.wrappedValue = $0 }) return RenderNode( type: "Toggle", id: context.path, diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/DatePicker.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/DatePicker.swift index c600282..5b15502 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/DatePicker.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/DatePicker.swift @@ -31,7 +31,7 @@ public extension DatePicker where Label == Text { extension DatePicker: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let binding = selection - let callbackID = context.callbacks.register(.double { millis in + let callbackID = context.registerCallback(.double { millis in binding.wrappedValue = Date(timeIntervalSince1970: millis / 1000) }) return RenderNode( diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/DisclosureGroup.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/DisclosureGroup.swift index ad15050..092c42f 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/DisclosureGroup.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/DisclosureGroup.swift @@ -54,7 +54,7 @@ extension DisclosureGroup: PrimitiveView { if let isExpanded { props["isExpanded"] = .bool(isExpanded.wrappedValue) let binding = isExpanded - let id = context.callbacks.register(.bool { binding.wrappedValue = $0 }) + let id = context.registerCallback(.bool { binding.wrappedValue = $0 }) props["onToggle"] = .int(Int(id)) } return RenderNode( diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift index 2676734..0096b95 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift @@ -55,7 +55,7 @@ extension GeometryReader: PrimitiveView { GeometrySizeStore() } store.onChange = context.storage.onChange - let id = context.callbacks.register(.string { [store] payload in + let id = context.registerCallback(.string { [store] payload in store.update(from: payload) }) // first pass resolves against .zero; the report brings the real size diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift index 289e29e..7854287 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift @@ -60,6 +60,7 @@ internal func _lazyStackNode( let callbacks = context.callbacks let environment = context.environment let basePath = context.path + "/content" + let containerPath = context.path let count = provider._elementCount let providerID = callbacks.register(.item { index in @@ -70,7 +71,7 @@ internal func _lazyStackNode( path: basePath ) return provider._resolveElement(at: index, in: elementContext) - }) + }, path: containerPath) props["itemProvider"] = .int(Int(providerID)) props["keys"] = .array((0.. RenderNode { let binding = selection - let callbackID = context.callbacks.register(.string { raw in + let callbackID = context.registerCallback(.string { raw in guard let value = SelectionValue(raw) else { return } binding.wrappedValue = value }) diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/Popover.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/Popover.swift index 6e9728d..43232d8 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/Popover.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/Popover.swift @@ -29,7 +29,7 @@ extension _PopoverView: PrimitiveView { // write the flag back on an outside tap var bodyNodes: [RenderNode] = [] if binding.wrappedValue { - let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + let dismissID = context.registerCallback(.void { binding.wrappedValue = false }) props["onDismiss"] = .int(Int(dismissID)) var popoverContext = context.descending("popover") popoverContext.environment.values.dismiss = DismissAction { binding.wrappedValue = false } diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/Stepper.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/Stepper.swift index 7977f80..3612bae 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/Stepper.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/Stepper.swift @@ -61,8 +61,8 @@ public extension Stepper where Label == Text { extension Stepper: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { - let incrementID = context.callbacks.register(.void(onIncrement)) - let decrementID = context.callbacks.register(.void(onDecrement)) + let incrementID = context.registerCallback(.void(onIncrement)) + let decrementID = context.registerCallback(.void(onDecrement)) return RenderNode( type: "Stepper", id: context.path, diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/Views.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/Views.swift index 9f7fceb..378bdcd 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/Views.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/Views.swift @@ -104,7 +104,7 @@ public struct Slider: View { extension Slider: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let binding = value - let callbackID = context.callbacks.register(.double { binding.wrappedValue = $0 }) + let callbackID = context.registerCallback(.double { binding.wrappedValue = $0 }) return RenderNode( type: "Slider", id: context.path, @@ -135,7 +135,7 @@ public struct TextField: View { extension TextField: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let binding = text - let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 }) + let callbackID = context.registerCallback(.string { binding.wrappedValue = $0 }) return RenderNode( type: "TextField", id: context.path, @@ -166,7 +166,7 @@ public struct SecureField: View { extension SecureField: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { let binding = text - let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 }) + let callbackID = context.registerCallback(.string { binding.wrappedValue = $0 }) return RenderNode( type: "TextField", id: context.path, diff --git a/SwiftUICore/Sources/SwiftUICore/TabView.swift b/SwiftUICore/Sources/SwiftUICore/TabView.swift index 154d097..f0039db 100644 --- a/SwiftUICore/Sources/SwiftUICore/TabView.swift +++ b/SwiftUICore/Sources/SwiftUICore/TabView.swift @@ -27,7 +27,7 @@ extension TabView: PrimitiveView { // each child flattens to one node carrying its tabItem/tag as modifiers let tabs = Evaluator.resolveChildren(content, context.descending("content")) let binding = selection - let selectID = context.callbacks.register(.int { binding.wrappedValue = $0 }) + let selectID = context.registerCallback(.int { binding.wrappedValue = $0 }) return RenderNode( type: "TabView", id: context.path, diff --git a/SwiftUICore/Sources/SwiftUICore/ViewHost.swift b/SwiftUICore/Sources/SwiftUICore/ViewHost.swift index 4507cf4..4170d5e 100644 --- a/SwiftUICore/Sources/SwiftUICore/ViewHost.swift +++ b/SwiftUICore/Sources/SwiftUICore/ViewHost.swift @@ -44,7 +44,7 @@ public final class ViewHost: @unchecked Sendable { /// Resolves the current view tree to a node tree. Each call opens a fresh /// callback generation, so ids in the returned tree are current. public func evaluate() -> RenderNode { - callbacks.beginGeneration() + callbacks.beginPass() let context = ResolveContext(storage: storage, callbacks: callbacks, path: "root") #if canImport(Observation) // Track @Observable reads during evaluation: a later mutation of any diff --git a/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift b/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift index 57813b7..df245bf 100644 --- a/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift +++ b/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift @@ -932,3 +932,46 @@ struct DisclosureGroupTests { #expect(node.children[0].type == "Label") } } + +@Suite("Callback identity") +struct CallbackIdentityTests { + + @Test("A view's callback id is stable across re-evaluation") + func stableAcrossEvaluations() { + struct Screen: View { + @State var taps = 0 + var body: some View { + Button("Tap \(taps)") { taps += 1 } + } + } + let host = ViewHost(Screen()) + let first = host.evaluate() + guard case .int(let id1)? = first.props["onTap"] else { Issue.record("no onTap"); return } + // a state change + re-eval must NOT churn the id (patching relies on this) + host.callbacks.invokeVoid(Int64(id1)) + let second = host.evaluate() + guard case .int(let id2)? = second.props["onTap"] else { Issue.record("no onTap"); return } + #expect(id1 == id2) + // and it still dispatches after many passes (no generation eviction) + for _ in 0 ..< 10 { _ = host.evaluate() } + host.callbacks.invokeVoid(Int64(id1)) + let after = host.evaluate() + #expect(firstTextString(after) == "Tap 2") // one tap before, one just now + } + + @Test("Distinct callbacks at one view get distinct stable ids") + func distinctOrdinals() { + struct Screen: View { + @State var n = 0 + var body: some View { + Stepper("n \(n)", value: $n, in: 0...10) + } + } + let node = ViewHost(Screen()).evaluate() + guard case .int(let inc)? = node.props["onIncrement"], + case .int(let dec)? = node.props["onDecrement"] else { + Issue.record("missing stepper callbacks"); return + } + #expect(inc != dec) + } +} From 5ad844568674bd7f8b05ff100d63af0a557ce21f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 02/11] Record subtree re-entry anchors during evaluation --- .../Sources/SwiftUICore/SubtreeAnchors.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 SwiftUICore/Sources/SwiftUICore/SubtreeAnchors.swift diff --git a/SwiftUICore/Sources/SwiftUICore/SubtreeAnchors.swift b/SwiftUICore/Sources/SwiftUICore/SubtreeAnchors.swift new file mode 100644 index 0000000..d127f5c --- /dev/null +++ b/SwiftUICore/Sources/SwiftUICore/SubtreeAnchors.swift @@ -0,0 +1,80 @@ +// +// SubtreeAnchors.swift +// SwiftUICore +// +// Re-entry points for subtree re-evaluation. During a pass the evaluator +// records, at every path where state installs, the outermost view value of +// that path's wrapper chain together with the context it was resolved with. +// When a state write later dirties that path, the host re-resolves just the +// recorded view — reproducing the node (including parent-applied modifiers, +// which live on the same wrapper chain) without walking the rest of the tree. +// + +/// The outermost view value + context of the wrapper chain currently being +/// unwrapped at one identity path. Captured at the first `resolve` entry of +/// the chain so that a re-resolution replays parent-applied modifiers and +/// resolution effects, not just the innermost view. +final class ChainAnchor { + let view: any View + let context: ResolveContext + + init(view: any View, context: ResolveContext) { + self.view = view + self.context = context + } +} + +/// Anchors recorded during evaluation, keyed by state-install path. +public final class AnchorStore { + + struct Anchor { + /// Outermost view of the wrapper chain producing this path's node. + var view: any View + /// The context that chain was entered with. + var context: ResolveContext + /// The id of the node the chain produced — the splice target. + var nodeID: String + } + + private(set) var anchors: [String: Anchor] = [:] + /// Paths of lazy containers. State under one belongs to a row that lives + /// outside the main tree (fetched on demand), so it can't be patched in. + private(set) var lazyBoundaries: Set = [] + + public init() {} + + func record(path: String, chain: ChainAnchor, nodeID: String) { + anchors[path] = Anchor(view: chain.view, context: chain.context, nodeID: nodeID) + } + + func anchor(at path: String) -> Anchor? { + anchors[path] + } + + /// Drops the anchors that produced these nodes — used when a group-spread + /// modifier lands on them after the fact, which a re-resolution from the + /// anchor couldn't reproduce. Their deeper descendants stay patchable. + func invalidate>(nodeIDs: IDs) { + let ids = Set(nodeIDs) + for (path, anchor) in anchors where ids.contains(anchor.nodeID) { + anchors.removeValue(forKey: path) + } + } + + /// Marks `path` as a lazy container boundary. + public func markLazyBoundary(_ path: String) { + lazyBoundaries.insert(path) + } + + /// Whether `path` is inside a lazy container's on-demand content. + func isUnderLazyBoundary(_ path: String) -> Bool { + lazyBoundaries.contains { path.hasPrefix($0 + "/") || path.hasPrefix($0 + ".") } + } + + /// Clears everything ahead of a full pass; a subtree pass instead + /// overwrites just the anchors it revisits. + func reset() { + anchors.removeAll(keepingCapacity: true) + lazyBoundaries.removeAll(keepingCapacity: true) + } +} From 3dfe0966ec8144320c71a2c55d34541260221dcf Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 03/11] Capture wrapper chains and record anchors while resolving --- .../Sources/SwiftUICore/Evaluator.swift | 77 +++++++++++++++++-- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/SwiftUICore/Sources/SwiftUICore/Evaluator.swift b/SwiftUICore/Sources/SwiftUICore/Evaluator.swift index 200cf15..1d2d20f 100644 --- a/SwiftUICore/Sources/SwiftUICore/Evaluator.swift +++ b/SwiftUICore/Sources/SwiftUICore/Evaluator.swift @@ -23,6 +23,15 @@ public struct ResolveContext { public var preferences: PreferenceCollector? public var path: String public var depth: Int + /// Re-entry points recorded during the pass; `nil` disables recording + /// (tests driving the evaluator directly don't need anchors). + public var anchors: AnchorStore? + /// Monotonic evaluation counter, stamped on lazy containers so their rows + /// re-fetch when (and only when) the container was re-evaluated. + public var passVersion: Int = 0 + /// The wrapper chain being unwrapped at the current path; cleared when the + /// path descends. See `ChainAnchor`. + var chainAnchor: ChainAnchor? public init( storage: StateStorage, @@ -51,11 +60,23 @@ public struct ResolveContext { callbacks.register(callback, path: path) } + /// Starts a wrapper chain at this path if one isn't already open: the + /// captured view + context are the re-entry point for subtree patching. + mutating func beginChainIfNeeded(_ view: any View) { + guard anchors != nil, chainAnchor == nil else { return } + // the captured context drops the store reference (the store holds + // the capture — a cycle otherwise); the host restores it on re-entry + var captured = self + captured.anchors = nil + chainAnchor = ChainAnchor(view: view, context: captured) + } + /// Context for a child at a structurally stable position. public func descending(_ component: String) -> ResolveContext { var context = self context.path += "/" + component context.depth += 1 + context.chainAnchor = nil return context } } @@ -129,6 +150,17 @@ public final class TitleSink { public var searchCallbackID: Int64? public var searchPrompt: String? public init() {} + + /// Whether a subtree pass re-published the same values the last full pass + /// left in `other` — the condition under which the pass can stay a patch + /// (the enclosing screen's rendering of these values is already correct). + func matches(_ other: TitleSink) -> Bool { + title == other.title + && detents == other.detents + && searchText == other.searchText + && searchCallbackID == other.searchCallbackID + && searchPrompt == other.searchPrompt + } } /// Carries a pending `refreshable` action to the enclosing List. @@ -155,6 +187,11 @@ public enum Evaluator { assertionFailure("View \(type(of: view)) exceeded maximum resolve depth") return RenderNode(type: "EmptyView", id: context.path) } + // First entry of a wrapper chain: remember the outermost view + context + // so a dirty descendant can re-resolve from here — reproducing parent- + // applied modifiers and effects, which sit on this same chain. + var context = context + context.beginChainIfNeeded(view) switch view { case let modifier as _ModifierProvider: // identity-transparent: resolve content at the SAME path, then prepend @@ -170,18 +207,31 @@ public enum Evaluator { // selection) and read @Environment, so wire both before rendering context.storage.install(in: view, path: context.path) EnvironmentInjector.inject(context.environment, into: view) - return primitive._render(in: context) + let node = primitive._render(in: context) + if let anchors = context.anchors, let chain = context.chainAnchor { + anchors.record(path: context.path, chain: chain, nodeID: node.id) + } + return node case let anyView as AnyView: - return resolve(anyView.storage, context.descending("any")) + // identity-transparent wrappers stay on the chain: the anchor must + // cover modifiers applied outside them + var child = context.descending("any") + child.chainAnchor = context.chainAnchor + return resolve(anyView.storage, child) case let writer as _AnyEnvironmentWriter: var child = context.descending("env") + child.chainAnchor = context.chainAnchor child.environment.set(writer._object) return resolve(writer._content, child) default: let child = context.descending("\(type(of: view))") child.storage.install(in: view, path: child.path) EnvironmentInjector.inject(child.environment, into: view) - return resolve(body(of: view), child) + let node = resolve(body(of: view), child) + if let anchors = context.anchors, let chain = context.chainAnchor { + anchors.record(path: child.path, chain: chain, nodeID: node.id) + } + return node } } @@ -198,24 +248,35 @@ public enum Evaluator { assertionFailure("View \(type(of: view)) exceeded maximum flatten depth") return } + var context = context + context.beginChainIfNeeded(view) switch view { case is EmptyView: return case let group as _GroupView: group._flatten(into: &nodes, context: context) case let modifier as _ModifierProvider: - var before = nodes.count + let before = nodes.count flatten(modifier._modifiedContent, into: &nodes, context: context) // apply the modifier to each node the content produced let modifierNode = modifier.resolvedModifierNode(in: context) - while before < nodes.count { - nodes[before].modifiers.insert(modifierNode, at: 0) - before += 1 + for index in before ..< nodes.count { + nodes[index].modifiers.insert(modifierNode, at: 0) + } + // a modifier spread over a group's nodes can't be reproduced by + // re-resolving any one of them — drop their re-entry anchors + if nodes.count - before > 1 { + context.anchors?.invalidate(nodeIDs: nodes[before...].map(\.id)) } case let anyView as AnyView: - flatten(anyView.storage, into: &nodes, context: context.descending("any")) + // identity-transparent wrappers stay on the chain: the anchor must + // cover modifiers applied outside them + var child = context.descending("any") + child.chainAnchor = context.chainAnchor + flatten(anyView.storage, into: &nodes, context: child) case let writer as _AnyEnvironmentWriter: var child = context.descending("env") + child.chainAnchor = context.chainAnchor child.environment.set(writer._object) flatten(writer._content, into: &nodes, context: child) case let effect as _ResolutionEffectView: From 7f171814fbbe14e4ce84251a60c069426940a52f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 04/11] Expose whether a preference collector recorded anything --- SwiftUICore/Sources/SwiftUICore/Preferences.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SwiftUICore/Sources/SwiftUICore/Preferences.swift b/SwiftUICore/Sources/SwiftUICore/Preferences.swift index 5e594ae..79d6046 100644 --- a/SwiftUICore/Sources/SwiftUICore/Preferences.swift +++ b/SwiftUICore/Sources/SwiftUICore/Preferences.swift @@ -27,6 +27,11 @@ public final class PreferenceCollector { public init() {} + /// Whether anything published into this collector. A subtree pass uses a + /// fresh collector and falls back to a full pass when this trips — + /// preferences flow to ancestors a patch can't reach. + var hasRecords: Bool { !values.isEmpty } + /// Folds one published value in, starting from the key's default so the /// reduction is the same whether or not anything published before. internal func record(_ key: K.Type, _ value: K.Value) { From 59e1e8b2bacd3c86de77508903d13cae1ef00f8c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 05/11] Report the owning path from state writes and patch dirty subtrees --- .../Sources/SwiftUICore/Navigation.swift | 6 +- .../Primitives/GeometryReader.swift | 5 +- SwiftUICore/Sources/SwiftUICore/State.swift | 10 +- .../Sources/SwiftUICore/ViewHost.swift | 126 +++++++++++++++--- 4 files changed, 125 insertions(+), 22 deletions(-) diff --git a/SwiftUICore/Sources/SwiftUICore/Navigation.swift b/SwiftUICore/Sources/SwiftUICore/Navigation.swift index e630343..282f9dc 100644 --- a/SwiftUICore/Sources/SwiftUICore/Navigation.swift +++ b/SwiftUICore/Sources/SwiftUICore/Navigation.swift @@ -70,7 +70,11 @@ extension NavigationStack: PrimitiveView { let model = context.storage.persistentObject(at: context.path + ".navModel") { NavigationModel() } - model.onChange = context.storage.onChange + // a push/pop dirties this stack's own path — the whole stack (its + // screens included) is the subtree to re-evaluate + let storage = context.storage + let path = context.path + model.onChange = { storage.onChange?(path) } // resolve each screen with the model in scope; a per-screen title sink // captures its navigationTitle diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift index 0096b95..5f10aa1 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/GeometryReader.swift @@ -54,7 +54,10 @@ extension GeometryReader: PrimitiveView { let store = context.storage.persistentObject(at: context.path + ".geometry") { GeometrySizeStore() } - store.onChange = context.storage.onChange + // a size report dirties this reader's path; its content is the subtree + let storage = context.storage + let path = context.path + store.onChange = { storage.onChange?(path) } let id = context.registerCallback(.string { [store] payload in store.update(from: payload) }) diff --git a/SwiftUICore/Sources/SwiftUICore/State.swift b/SwiftUICore/Sources/SwiftUICore/State.swift index bda6395..1721ea3 100644 --- a/SwiftUICore/Sources/SwiftUICore/State.swift +++ b/SwiftUICore/Sources/SwiftUICore/State.swift @@ -100,8 +100,10 @@ public final class StateStorage { private var boxes: [String: _AnyStateBox] = [:] private let reflector: StateReflector - /// Fired after any state write; the runtime schedules a re-evaluation. - public var onChange: (() -> Void)? + /// Fired after any state write with the identity path of the view that + /// owns the written box; the runtime schedules a re-evaluation and uses + /// the path to re-evaluate from the nearest enclosing subtree. + public var onChange: ((_ path: String) -> Void)? public init(reflector: StateReflector = MirrorStateReflector()) { self.reflector = reflector @@ -132,7 +134,9 @@ public final class StateStorage { } } else { boxes[key] = box - box._setOnChange(onChange) + // the persisted box carries its owning view's path for life — + // a write reports exactly which subtree went dirty + box._setOnChange { [weak self] in self?.onChange?(path) } } } } diff --git a/SwiftUICore/Sources/SwiftUICore/ViewHost.swift b/SwiftUICore/Sources/SwiftUICore/ViewHost.swift index 4170d5e..c94c8f9 100644 --- a/SwiftUICore/Sources/SwiftUICore/ViewHost.swift +++ b/SwiftUICore/Sources/SwiftUICore/ViewHost.swift @@ -7,11 +7,23 @@ // agnostic: the Android bridge and the desktop test rig both drive it the // same way; the tests drive it directly. // +// State writes carry the identity path of the owning view, and evaluation +// records a re-entry anchor at every such path — so a coalesced update whose +// dirt is confined to one path re-resolves just that subtree and hands the +// bridge a patch instead of a full root. +// #if canImport(Observation) import Observation #endif +/// What one update pass produced: a whole tree, or a subtree replacing the +/// node whose id is `target`. +public enum TreeUpdate { + case full(RenderNode) + case patch(target: String, node: RenderNode) +} + // Main-thread confined by contract (evaluation, callbacks, and state writes // all happen on the platform main thread); @unchecked so the observation // change handler — which is @Sendable — can reach onStateChange. @@ -20,6 +32,7 @@ public final class ViewHost: @unchecked Sendable { private let root: any View private let storage: StateStorage public let callbacks = CallbackRegistry() + private let anchors = AnchorStore() /// Called after a state write triggers a re-evaluation. The platform layer /// wires this to schedule an `evaluate()` on the main looper and push the @@ -30,44 +43,123 @@ public final class ViewHost: @unchecked Sendable { /// the (coalesced) evaluation the write scheduled. private var pendingAnimation: Animation? + /// Identity paths dirtied by state writes since the last pass. + private var dirtyPaths: Set = [] + /// Set when only a full pass can be trusted: first render, an @Observable + /// mutation (no owning path), or a failed patch attempt. + private var needsFullPass = true + /// Bumped per pass; lazy containers stamp it so rows re-fetch when (and + /// only when) their container was re-evaluated. + private var passVersion = 0 + public init(_ root: any View, reflector: StateReflector = MirrorStateReflector()) { self.root = root self.storage = StateStorage(reflector: reflector) - self.storage.onChange = { [weak self] in + self.storage.onChange = { [weak self] path in if let animation = Transaction._current { self?.pendingAnimation = animation } + self?.dirtyPaths.insert(path) self?.onStateChange?() } } - /// Resolves the current view tree to a node tree. Each call opens a fresh - /// callback generation, so ids in the returned tree are current. + /// Resolves the current view tree to a full node tree. The bridge uses + /// `evaluateUpdate()` instead to get patches; tests and the fallback path + /// use this directly. public func evaluate() -> RenderNode { + dirtyPaths.removeAll() + needsFullPass = false + callbacks.beginPass() + anchors.reset() + passVersion += 1 + var context = ResolveContext(storage: storage, callbacks: callbacks, path: "root") + context.anchors = anchors + context.passVersion = passVersion + var node = tracked { Evaluator.resolve(root, context) } + stampAnimation(on: &node) + return node + } + + /// Produces the smallest update the dirt allows: when every write since + /// the last pass landed at one anchored path (and not inside a lazy + /// container's on-demand rows), re-resolves that subtree alone. + public func evaluateUpdate() -> TreeUpdate { + let dirty = dirtyPaths + dirtyPaths.removeAll() + if !needsFullPass, + dirty.count == 1, + let path = dirty.first, + !anchors.isUnderLazyBoundary(path), + let anchor = anchors.anchor(at: path), + let node = resolveSubtree(anchor) { + var node = node + stampAnimation(on: &node) + // the subtree's node id can change (a branch switch); splice by + // the id recorded when the tree last contained it + let target = anchor.nodeID + anchors.record( + path: path, + chain: ChainAnchor(view: anchor.view, context: anchor.context), + nodeID: node.id + ) + return .patch(target: target, node: node) + } + return .full(evaluate()) + } + + /// Re-resolves one anchored subtree. Returns `nil` when the subtree + /// published data an ancestor renders (a changed title, search state, or + /// any preference) — those need a full pass to propagate. + private func resolveSubtree(_ anchor: AnchorStore.Anchor) -> RenderNode? { callbacks.beginPass() - let context = ResolveContext(storage: storage, callbacks: callbacks, path: "root") + passVersion += 1 + var context = anchor.context + context.anchors = anchors + context.passVersion = passVersion + context.chainAnchor = nil + // scope ancestor-visible sinks so changes are detected, not lost + let previousSink = context.titleSink + let scopedSink = previousSink.map { _ in TitleSink() } + context.titleSink = scopedSink + let scopedPreferences = context.preferences != nil ? PreferenceCollector() : nil + context.preferences = scopedPreferences + let node = tracked { Evaluator.resolve(anchor.view, context) } + if let scopedSink, let previousSink, !scopedSink.matches(previousSink) { + needsFullPass = true + return nil + } + if let scopedPreferences, scopedPreferences.hasRecords { + needsFullPass = true + return nil + } + return node + } + + private func tracked(_ body: () -> RenderNode) -> RenderNode { #if canImport(Observation) // Track @Observable reads during evaluation: a later mutation of any // observed property schedules a re-evaluation, exactly like a @State - // write. Re-arms itself because the change handler triggers evaluate(). - var node = withObservationTracking { - Evaluator.resolve(root, context) - } onChange: { [weak self] in + // write — but with no owning path, so only a full pass is safe. + // Re-arms itself because the change handler triggers evaluation. + withObservationTracking(body) { [weak self] in if let animation = Transaction._current { self?.pendingAnimation = animation } + self?.needsFullPass = true self?.onStateChange?() } #else - var node = Evaluator.resolve(root, context) + body() #endif - // A tree produced by a withAnimation write carries the animation on its - // root; the interpreter eases changed modifier values while it applies. - if let animation = pendingAnimation { - pendingAnimation = nil - node.props["animationCurve"] = .string(animation.curve) - node.props["animationDurationMs"] = .double(animation.duration * 1000) - } - return node + } + + /// A tree produced by a withAnimation write carries the animation on its + /// root (or patch root); the interpreter eases changed modifier values. + private func stampAnimation(on node: inout RenderNode) { + guard let animation = pendingAnimation else { return } + pendingAnimation = nil + node.props["animationCurve"] = .string(animation.curve) + node.props["animationDurationMs"] = .double(animation.duration * 1000) } } From fb707e59f927667bb4c6cd0aceb29dec8741fe67 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 06/11] Version lazy containers and mark their patch boundaries --- SwiftUICore/Sources/SwiftUICore/List.swift | 5 +++++ SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/SwiftUICore/Sources/SwiftUICore/List.swift b/SwiftUICore/Sources/SwiftUICore/List.swift index dcc829b..3304e7a 100644 --- a/SwiftUICore/Sources/SwiftUICore/List.swift +++ b/SwiftUICore/Sources/SwiftUICore/List.swift @@ -54,10 +54,15 @@ extension List: PrimitiveView { return Evaluator.resolve(rowBuilder(element), rowContext) }, path: listPath) + // rows are fetched on demand, outside the tree: mark the boundary so a + // row-state change forces a full pass, and stamp the pass version so + // cached rows re-fetch exactly when this container re-evaluated + context.anchors?.markLazyBoundary(listPath) let keys = elements.map { PropValue.string(identityString(keyFor($0))) } var props: [String: PropValue] = [ "keys": .array(keys), "itemProvider": .int(Int(providerID)), + "contentVersion": .int(context.passVersion), ] if let onRefresh = context.refreshSink?.action { let refreshID = callbacks.register(.void { diff --git a/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift b/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift index 7854287..0206bae 100644 --- a/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift +++ b/SwiftUICore/Sources/SwiftUICore/Primitives/LazyStacks.swift @@ -74,6 +74,10 @@ internal func _lazyStackNode( }, path: containerPath) props["itemProvider"] = .int(Int(providerID)) props["keys"] = .array((0.. Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 07/11] Test subtree patching --- .../SwiftUICoreTests/EvaluatorTests.swift | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift b/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift index df245bf..bfad4e4 100644 --- a/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift +++ b/SwiftUICore/Tests/SwiftUICoreTests/EvaluatorTests.swift @@ -975,3 +975,123 @@ struct CallbackIdentityTests { #expect(inc != dec) } } + +@Suite("Subtree updates") +struct SubtreeUpdateTests { + + struct Counter: View { + let label: String + @State var taps = 0 + var body: some View { + Button("\(label) \(taps)") { taps += 1 } + } + } + + struct Screen: View { + var body: some View { + VStack { + Counter(label: "A") + Counter(label: "B") + } + } + } + + private func tapID(_ node: RenderNode) -> Int64? { + if case .int(let id)? = node.props["onTap"] { return Int64(id) } + return nil + } + + @Test("A single-view state change becomes a patch of just that subtree") + func patchesOneSubtree() { + let host = ViewHost(Screen()) + guard case .full(let tree) = host.evaluateUpdate() else { + Issue.record("first pass must be full"); return + } + let counterA = tree.children[0] + guard let tap = tapID(counterA) else { Issue.record("no onTap"); return } + host.callbacks.invokeVoid(tap) + guard case .patch(let target, let node) = host.evaluateUpdate() else { + Issue.record("expected a patch"); return + } + #expect(target == counterA.id) + #expect(node.id == counterA.id) + #expect(firstTextString(node) == "A 1") + // sibling B untouched; a second tap patches again with the same target + host.callbacks.invokeVoid(tap) + guard case .patch(let target2, let node2) = host.evaluateUpdate() else { + Issue.record("expected a second patch"); return + } + #expect(target2 == target) + #expect(firstTextString(node2) == "A 2") + } + + @Test("Writes to two different views fall back to a full pass") + func multiplePathsGoFull() { + let host = ViewHost(Screen()) + guard case .full(let tree) = host.evaluateUpdate() else { + Issue.record("first pass must be full"); return + } + guard let tapA = tapID(tree.children[0]), + let tapB = tapID(tree.children[1]) else { + Issue.record("missing taps"); return + } + host.callbacks.invokeVoid(tapA) + host.callbacks.invokeVoid(tapB) + guard case .full(let next) = host.evaluateUpdate() else { + Issue.record("expected full"); return + } + #expect(firstTextString(next.children[0]) == "A 1") + #expect(firstTextString(next.children[1]) == "B 1") + } + + @Test("A patched subtree keeps parent-applied modifiers") + func patchKeepsOuterModifiers() { + struct Padded: View { + var body: some View { + VStack { + Counter(label: "P").padding(8) + } + } + } + let host = ViewHost(Padded()) + guard case .full(let tree) = host.evaluateUpdate() else { + Issue.record("first pass must be full"); return + } + let counter = tree.children[0] + #expect(counter.modifiers.contains { $0.kind == "padding" }) + guard let tap = tapID(counter) else { Issue.record("no onTap"); return } + host.callbacks.invokeVoid(tap) + guard case .patch(_, let node) = host.evaluateUpdate() else { + Issue.record("expected a patch"); return + } + #expect(node.modifiers.contains { $0.kind == "padding" }) + #expect(firstTextString(node) == "P 1") + } + + @Test("A changed navigationTitle forces a full pass") + func titleChangeGoesFull() { + struct Titled: View { + @State var taps = 0 + var body: some View { + Button("Tap \(taps)") { taps += 1 } + .navigationTitle("Taps \(taps)") + } + } + let host = ViewHost(NavigationStack { Titled() }) + guard case .full(let tree) = host.evaluateUpdate() else { + Issue.record("first pass must be full"); return + } + func onTap(in node: RenderNode) -> Int64? { + if let id = tapID(node) { return id } + for child in node.children { if let id = onTap(in: child) { return id } } + return nil + } + guard let tap = onTap(in: tree) else { Issue.record("no onTap"); return } + host.callbacks.invokeVoid(tap) + // the title the nav bar renders changed — a patch below the nav node + // couldn't update it + guard case .full = host.evaluateUpdate() else { + Issue.record("expected full pass for a title change"); return + } + } +} From ef932fd6e8e26d13f1a61b6880a064f4cae56d30 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 08/11] Splice a subtree by id with spine-only copies --- .../kotlin/com/pureswift/swiftui/ViewNode.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt index f39d64e..945b808 100644 --- a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt +++ b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt @@ -77,6 +77,21 @@ data class ViewNode( fun bool(key: String): Boolean? = (props[key] as? JsonPrimitive)?.booleanOrNull fun long(key: String): Long? = (props[key] as? JsonPrimitive)?.longOrNull + + /// Returns a copy of this tree with the node whose id is `targetId` + /// replaced by `replacement`, or `null` when the id isn't in this branch. + /// Only the spine down to the target is copied; every untouched sibling + /// subtree is shared, so recomposition stays proportional to the change. + fun replacingSubtree(targetId: String, replacement: ViewNode): ViewNode? { + if (id == targetId) return replacement + // ids are identity paths: a descendant's id extends this node's id + if (!targetId.startsWith("$id/")) return null + for ((index, child) in children.withIndex()) { + val patched = child.replacingSubtree(targetId, replacement) ?: continue + return copy(children = children.toMutableList().also { it[index] = patched }) + } + return null + } } internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull From 4e1573f958ad879b4e3a3137c9d3b5f9fd091a9e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 09/11] Add a patch entry point to the tree store --- .../commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt index 96b0161..c1062e2 100644 --- a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt +++ b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt @@ -18,6 +18,15 @@ class TreeStore { root = node } + /// Splices a re-evaluated subtree over the node whose id is `targetId`, + /// copying only the spine above it. Returns false when the target isn't in + /// the current tree — the caller falls back to a full update. + fun patch(targetId: String, node: ViewNode): Boolean { + val replaced = root?.replacingSubtree(targetId, node) ?: return false + root = replaced + return true + } + /// Debug/fixture path: accepts the Swift side's JSON dump. fun updateJson(json: String) { root = Json.decodeFromString(json) From ae685cc0af512f4dfb6cc11ffe11042f3bb03227 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 10/11] Re-fetch lazy rows when their container re-evaluates --- .../commonMain/kotlin/com/pureswift/swiftui/Render.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 1e70aaa..5b592be 100644 --- a/composeui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/composeui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -2097,6 +2097,8 @@ private fun RenderLazyStack(node: ViewNode, vertical: Boolean) { } val count = node.count ?: 0 val keys = node.stringArray("keys") + // bumps when Swift re-evaluated this container: cached elements re-fetch + val version = node.long("contentVersion") val spacing = (node.double("spacing") ?: 0.0).dp if (vertical) { LazyColumn( @@ -2109,7 +2111,7 @@ private fun RenderLazyStack(node: ViewNode, vertical: Boolean) { verticalArrangement = Arrangement.spacedBy(spacing), ) { items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> - val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + val element = remember(provider, version, index) { SwiftBridge.sink.itemNode(provider, index) } element?.let { RenderChild(it) } } } @@ -2124,7 +2126,7 @@ private fun RenderLazyStack(node: ViewNode, vertical: Boolean) { horizontalArrangement = Arrangement.spacedBy(spacing), ) { items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> - val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + val element = remember(provider, version, index) { SwiftBridge.sink.itemNode(provider, index) } element?.let { RenderChild(it) } } } @@ -2137,13 +2139,15 @@ private fun RenderList(node: ViewNode) { val provider = node.long("itemProvider") ?: return val count = node.count ?: 0 val keys = node.stringArray("keys") + // bumps when Swift re-evaluated this container: cached rows re-fetch + val version = node.long("contentVersion") val onRefresh = node.long("onRefresh") val list: @Composable () -> Unit = { LazyColumn(modifier = Modifier.fillMaxSize()) { items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> // one JNI call per newly-visible row; re-fetched when the tree // (hence the provider id) changes - val row = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + val row = remember(provider, version, index) { SwiftBridge.sink.itemNode(provider, index) } row?.let { Render(it) } } } From 8a3df87a9a49bf4e72710c1db5bef4119db9dab9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 14:09:50 -0400 Subject: [PATCH 11/11] Push patches across the bridge, falling back to full updates --- Sources/ComposeUI/BridgeRuntime.swift | 12 ++++++++++-- Sources/ComposeUI/KotlinBindings.swift | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/ComposeUI/BridgeRuntime.swift b/Sources/ComposeUI/BridgeRuntime.swift index 55a828e..0fe2b38 100644 --- a/Sources/ComposeUI/BridgeRuntime.swift +++ b/Sources/ComposeUI/BridgeRuntime.swift @@ -56,8 +56,16 @@ public final class BridgeRuntime { } func push() { - let tree = host.evaluate() - store.update(Materializer.materialize(tree)) + switch host.evaluateUpdate() { + case .full(let tree): + store.update(Materializer.materialize(tree)) + case .patch(let target, let node): + // splice miss (the target left the tree since it was recorded): + // recover with a full evaluation + if !store.patch(target, Materializer.materialize(node)) { + store.update(Materializer.materialize(host.evaluate())) + } + } } // Dispatch entry points, called by the callback sink. diff --git a/Sources/ComposeUI/KotlinBindings.swift b/Sources/ComposeUI/KotlinBindings.swift index 2203019..ac6deca 100644 --- a/Sources/ComposeUI/KotlinBindings.swift +++ b/Sources/ComposeUI/KotlinBindings.swift @@ -38,4 +38,9 @@ open class TreeStore: JavaObject { /// Assigns a freshly materialized tree; Compose recomposes changed subtrees. @JavaMethod open func update(_ node: ViewNodeObject?) + + /// Splices a re-evaluated subtree over the node with `targetId`; false + /// when the target isn't in the current tree (caller does a full update). + @JavaMethod + open func patch(_ targetId: String, _ node: ViewNodeObject?) -> Bool }