Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Sources/ComposeUI/BridgeRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Sources/ComposeUI/KotlinBindings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
69 changes: 43 additions & 26 deletions SwiftUICore/Sources/SwiftUICore/CallbackRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
84 changes: 76 additions & 8 deletions SwiftUICore/Sources/SwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,11 +53,30 @@ 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)
}

/// 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
}
}
Expand Down Expand Up @@ -122,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.
Expand All @@ -148,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
Expand All @@ -163,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
}
}

Expand All @@ -191,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:
Expand Down
2 changes: 1 addition & 1 deletion SwiftUICore/Sources/SwiftUICore/FocusState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions SwiftUICore/Sources/SwiftUICore/Gesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))])
}
}
Expand Down
9 changes: 7 additions & 2 deletions SwiftUICore/Sources/SwiftUICore/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,22 @@ extension List: PrimitiveView {
path: listPath + "/" + key
)
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 {
Task { await onRefresh() }
})
}, path: listPath)
props["onRefresh"] = .int(Int(refreshID))
}
return RenderNode(type: "List", id: context.path, props: props, count: elements.count)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))])
}
}
Expand All @@ -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))])
}
}
Expand All @@ -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))])
}
}
Expand Down Expand Up @@ -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))])
}
}
Expand All @@ -102,7 +102,7 @@ public struct _OnChangeModifier<V: Equatable>: 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: [
Expand Down
Loading
Loading