From d750029fa3064b3f5ef35023f4bfc5465eb0cd88 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 16 Sep 2026 00:34:16 +0800 Subject: [PATCH] fix(gesture): prevent layout gesture reset crashes --- .../Event/Gesture/LayoutGesture.swift | 30 ++++-- .../Event/Gesture/LayoutGestureTests.swift | 93 +++++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift b/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift index a18e6e4d4..c0c71f060 100644 --- a/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift +++ b/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift @@ -171,14 +171,16 @@ private final class LayoutGestureBox { debugData = .reset(attribute.value) } phase = nil - subgraph?.willInvalidate(isInserted: true) - subgraph?.invalidate() + if let subgraph { + subgraph.willInvalidate(isInserted: true) + subgraph.invalidate() + } subgraph = nil responder.resetGesture() } events = [:] seenEventIDs = [] - resetDelta &+= 1 + resetDelta.unsafeIncrement() } } @@ -194,8 +196,7 @@ private final class LayoutGestureBox { } self.resetSeed = resetSeed for index in children.indices { - children[index].reset() - seed &+= 1 + resetChild(at: index) } seed &+= 1 } @@ -223,8 +224,7 @@ private final class LayoutGestureBox { } while index < count { count -= 1 - children[count].reset() - seed &+= 1 + resetChild(at: count) children.removeLast() changed = true } @@ -292,10 +292,22 @@ private final class LayoutGestureBox { children[index].phase!.value.isTerminal else { continue } - children[index].reset() - seed &+= 1 + resetChild(at: index) } } + + private func resetChild(at index: Int) { + // OpenSwiftUI Addition: + // SwiftUI's original implementation will trigger a crash with + // SWIFTUI_GESTURE_CONTAINER=0 && SWIFTUI_EVENT_DEBUG=G + // + // children[index].reset() + var child = children[index] + child.reset() + children[index] = child + + seed.unsafeIncrement() + } } // MARK: - LayoutGestureChildProxy diff --git a/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift b/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift index 6f6d8b045..ae2ae0c01 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift @@ -216,6 +216,49 @@ struct LayoutGestureTests { } } + @Test(arguments: ResetTrigger.allCases) + func childResetEvaluatesLazyDebugInputs(trigger: ResetTrigger) { + withFixture(debugOutput: true) { fixture in + let child = LazyDebugResponder(phase: trigger == .terminal ? .ended(()) : .active(())) + fixture.setChildren([child]) + fixture.send(to: child) + + if trigger != .terminal { + #expect(fixture.outputs.phase.value.isActive) + #expect(child.debugData == nil) + switch trigger { + case .terminal: break + case .seed: fixture.resetSeed.value = 1 + case .removal: fixture.setChildren([]) + } + } + + let resetsBeforeEvaluation = child.resetCount + let phase = fixture.outputs.phase.value + switch trigger { + case .terminal: #expect(phase.isEnded) + case .seed: #expect(phase.isActive) + case .removal: #expect(phase.isFailed) + } + #expect(child.resetCount - resetsBeforeEvaluation == 1) + #expect(child.debugData?.resetSeed == (trigger == .seed ? 1 : 0)) + #expect(child.debugEventCount == 1) + + if trigger == .terminal { + let data = fixture.outputs.debugData!.value + #expect(data.children.count == 1) + #expect(data.children[0].phase.isEnded) + #expect(data.children[0].resetSeed == 0) + } + } + } + + enum ResetTrigger: CaseIterable { + case terminal + case seed + case removal + } + @Test(arguments: [ ({ @Sendable in [] }, "failed"), ({ @Sendable in [.failed, .failed] }, "failed"), @@ -407,6 +450,56 @@ private final class TestResponder: ViewResponder { } } +private final class LazyDebugResponder: ViewResponder { + let phase: GesturePhase + var debugData: GestureDebug.Data? + var debugEventCount: Int? + var resetCount = 0 + + init(phase: GesturePhase) { + self.phase = phase + super.init() + } + + override func makeGesture(inputs: _GestureInputs) -> _GestureOutputs { + var outputs = _GestureOutputs(phase: Attribute(value: phase)) + outputs.debugData = Attribute(Debug( + responder: self, + phase: outputs.phase, + events: inputs.events, + resetSeed: inputs.resetSeed + )) + return outputs + } + + override func resetGesture() { + resetCount += 1 + } + + private struct Debug: Rule { + let responder: LazyDebugResponder + @Attribute var phase: GesturePhase + @Attribute var events: [EventID: any EventType] + @Attribute var resetSeed: UInt32 + + var value: GestureDebug.Data { + let data = GestureDebug.Data( + kind: .primitive, + type: TestGesture.self, + children: .init(), + phase: phase, + attribute: $phase.identifier, + resetSeed: resetSeed, + frame: .zero, + properties: .init() + ) + responder.debugEventCount = events.count + responder.debugData = data + return data + } + } +} + private final class TestHost: ViewRendererHost, EventGraphHost { let viewGraph: ViewGraph let eventBindingManager = EventBindingManager()