From 723e3096bfe0a646ec812a9191285ac81aa4e891 Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Mon, 25 May 2026 12:32:54 +0200 Subject: [PATCH] fix: preserve EffectViewInput identity across rerenders --- .../EffectView/EffectViewInput.swift | 13 ++++++- Sources/EffectView/Transducer/SendFunc.swift | 7 +++- Tests/EffectViewTests/EffectViewTests.swift | 36 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Sources/EffectView/EffectView/EffectViewInput.swift b/Sources/EffectView/EffectView/EffectViewInput.swift index 2200e6a..466ee41 100644 --- a/Sources/EffectView/EffectView/EffectViewInput.swift +++ b/Sources/EffectView/EffectView/EffectViewInput.swift @@ -1,3 +1,5 @@ +import Foundation + /// A `Sendable` handle for dispatching events into the effect engine. /// /// `EffectViewInput` provides three dispatch strategies with different semantics: @@ -22,17 +24,20 @@ /// - `Event`: The event type dispatched into the state machine. /// - `Output`: The value returned by ``request(_:)``. /// Use `Void` when no return value is needed. -public struct EffectViewInput: TransducerInput, Sendable { +public struct EffectViewInput: TransducerInput, Identifiable, Sendable { @MainActor init(_ send: Send) { self._send = { @MainActor (event, input, continuation) async throws -> Void in try await send(event, input: input, continuation: continuation) } + id = send.id } let _send: @MainActor (Event, EffectViewInput, Continuation?) async throws -> Void + public let id: UUID + /// Sends the given event into the transducer. /// @@ -162,3 +167,9 @@ public struct EffectViewInput: TransducerInput, Sendable { } } + +extension EffectViewInput: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Sources/EffectView/Transducer/SendFunc.swift b/Sources/EffectView/Transducer/SendFunc.swift index d7a7b06..2f07677 100644 --- a/Sources/EffectView/Transducer/SendFunc.swift +++ b/Sources/EffectView/Transducer/SendFunc.swift @@ -1,8 +1,10 @@ +import Foundation + /// Low-level runtime handle for routing events and control messages. /// /// Most feature code should use higher-level entry points such as /// ``EffectView/Input-swift.struct`` or ``EffectObservable/Input-swift.struct``. -public struct Send +public struct Send: Identifiable where Input: TransducerInput & Sendable { typealias TaggedEvent = EffectView::TaggedEvent typealias SendFunc = (isolated any Actor, Event, Input?, Continuation?) async throws -> Void @@ -14,7 +16,10 @@ where Input: TransducerInput & Sendable { init(send: @escaping SendFunc, control: @escaping ControlFunc) { self.send = send self.control = control + self.id = .init() } + + public let id: UUID /// Sends `event` through the runtime using the provided `input` handle. /// diff --git a/Tests/EffectViewTests/EffectViewTests.swift b/Tests/EffectViewTests/EffectViewTests.swift index c168dc9..fd402f1 100644 --- a/Tests/EffectViewTests/EffectViewTests.swift +++ b/Tests/EffectViewTests/EffectViewTests.swift @@ -145,6 +145,42 @@ struct EffectViewTests { } } + @Test func inputIdentityRemainsStableAcrossRerenders() async throws { + enum T: Transducer { + struct State: Equatable { var count = 0 } + enum Event: Sendable { case increment } + + static func update(_ state: inout State, event: Event) -> Effect? { + state.count += 1 + return nil + } + } + + var capturedInputs: [EffectViewInput] = [] + let rerenderExpectation = Expectation() + let timeout: UInt64 = 5_000_000_000 + + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in + Text("\(state.count)") + .onAppear { + capturedInputs.append(input) + } + .onChange(of: state.count) { _, _ in + capturedInputs.append(input) + rerenderExpectation.fulfill() + } + } + } expect: { + #expect(capturedInputs.count == 1) + try await capturedInputs[0].send(.increment) + try await rerenderExpectation.await(nanoseconds: timeout) + #expect(capturedInputs.count == 2) + #expect(capturedInputs[0] == capturedInputs[1]) + #expect(capturedInputs[0].id == capturedInputs[1].id) + } + } + // MARK: - initialEvent @Test func initialEventFiresOnAppear() async throws {