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
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,36 @@ public extension View {

// MARK: - task

/// Running `.task` closures, keyed by the view's stable identity path. Keying by
/// path (not the generation-scoped callback id) lets the cancel fired on disappear
/// stop the right Task no matter how many times the view re-evaluated in between.
enum _TaskRegistry {
nonisolated(unsafe) static var running: [String: Task<Void, Never>] = [:]

static func start(path: String, action: @escaping @Sendable () async -> Void) {
running[path]?.cancel() // replace a stale run at this path
running[path] = Task { await action() }
}

static func cancel(path: String) {
running[path]?.cancel()
running[path] = nil
}
}

public struct _TaskModifier: RenderModifier, _CallbackModifier {
let action: @Sendable () async -> Void
public var _modifierNode: ModifierNode { ModifierNode(kind: "task") }
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
// The interpreter fires this once on appear; the closure runs as a Task.
// (v1 does not cancel it on disappear.)
// Two callbacks: the interpreter fires `start` on appear and `cancel` on
// disappear. The launched Task is stored by identity path, so the closure
// it runs is cooperatively cancelled (Task.isCancelled / Task.sleep throws)
// when the view leaves the tree.
let action = self.action
let id = context.callbacks.register(.void { Task { await action() } })
return ModifierNode(kind: "task", args: ["action": .int(Int(id))])
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) })
return ModifierNode(kind: "task", args: ["start": .int(Int(start)), "cancel": .int(Int(cancel))])
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ struct ModifierTests {
#expect(node.modifiers.contains { $0.kind == "onDisappear" })
}

@Test("task emits start and cancel callback ids that drive the same Task")
func taskStartAndCancel() async {
let host = ViewHost(Text("x").task { try? await Task.sleep(nanoseconds: 10_000_000_000) })
let node = host.evaluate()
let task = node.modifiers.first { $0.kind == "task" }
guard case .int(let start)? = task?.args["start"],
case .int(let cancel)? = task?.args["cancel"] else {
Issue.record("missing start/cancel ids"); return
}
#expect(start != cancel)
host.callbacks.invokeVoid(Int64(start)) // launches the Task
#expect(!_TaskRegistry.running.isEmpty)
host.callbacks.invokeVoid(Int64(cancel)) // cancels and clears it
#expect(_TaskRegistry.running.isEmpty)
}

@Test("onChange emits a token describing the observed value")
func onChange() {
let node = ViewHost(Text("x").onChange(of: 42) {}).evaluate()
Expand Down
22 changes: 22 additions & 0 deletions Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ struct InteractionPlayground: View {
@State private var slider = 0.0
@State private var sliderChanges = 0
@State private var blocked = true
@State private var ticking = false
@State private var ticks = 0
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Expand All @@ -35,6 +37,26 @@ struct InteractionPlayground: View {
}
.onChange(of: slider) { sliderChanges += 1 }
}
Example(".task (cancels on disappear)") {
VStack(alignment: .leading, spacing: 8) {
Text("Ticks: \(ticks)")
Button(ticking ? "Hide (cancels task)" : "Show") { ticking.toggle() }
if ticking {
Text("Ticking every 0.5s while visible")
.foregroundColor(.white)
.padding()
.background(Color.green)
.cornerRadius(8)
.task {
while true {
do { try await Task.sleep(nanoseconds: 500_000_000) }
catch { break } // cancelled on disappear → stop
ticks += 1
}
}
}
}
}
Example("disabled") {
VStack(alignment: .leading, spacing: 8) {
Button(blocked ? "Disabled button" : "Enabled button") { taps += 1 }
Expand Down
16 changes: 13 additions & 3 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,9 @@ private fun zStackAlignment(node: ViewNode): Alignment {
private fun RenderEffects(node: ViewNode) {
val onAppear = node.modifiers.firstOrNull { it.kind == "onAppear" }?.args?.long("action")
val onDisappear = node.modifiers.firstOrNull { it.kind == "onDisappear" }?.args?.long("action")
val task = node.modifiers.firstOrNull { it.kind == "task" }?.args?.long("action")
val taskMod = node.modifiers.firstOrNull { it.kind == "task" }
val taskStart = taskMod?.args?.long("start")
val taskCancel = taskMod?.args?.long("cancel")
val onChange = node.modifiers.firstOrNull { it.kind == "onChange" }

if (onAppear != null || onDisappear != null) {
Expand All @@ -773,8 +775,16 @@ private fun RenderEffects(node: ViewNode) {
onDispose { onDisappear?.let { SwiftBridge.sink.invokeVoid(it) } }
}
}
if (task != null) {
LaunchedEffect(Unit) { SwiftBridge.sink.invokeVoid(task) }
if (taskStart != null && taskCancel != null) {
// The cancel id changes each evaluation; onDispose can fire generations
// after appear, so hand it the freshest id (kept in a remembered holder)
// — still resolvable, and it cancels by stable path regardless.
val cancelHolder = remember { longArrayOf(taskCancel) }
cancelHolder[0] = taskCancel
DisposableEffect(Unit) {
SwiftBridge.sink.invokeVoid(taskStart)
onDispose { SwiftBridge.sink.invokeVoid(cancelHolder[0]) }
}
}
if (onChange != null) {
val token = onChange.args.string("token")
Expand Down
Loading