diff --git a/ElementX/Sources/Other/CurrentValuePublisher.swift b/ElementX/Sources/Other/CurrentValuePublisher.swift index c469aadac..d54359425 100644 --- a/ElementX/Sources/Other/CurrentValuePublisher.swift +++ b/ElementX/Sources/Other/CurrentValuePublisher.swift @@ -13,33 +13,41 @@ import Combine /// /// `CurrentValueSubject` is documented as thread-safe but is not formally `Sendable`, hence `@unchecked`. nonisolated struct CurrentValuePublisher: Publisher, @unchecked Sendable { - private let subject: CurrentValueSubject - /// Retains the upstream subscription feeding ``subject`` when this publisher is derived via ``map(_:)``. - private let cancellable: AnyCancellable? + private let upstream: AnyPublisher + private let valueProvider: () -> Output - init(_ subject: CurrentValueSubject, cancellable: AnyCancellable? = nil) { - self.subject = subject - self.cancellable = cancellable + init(_ subject: CurrentValueSubject) { + upstream = subject.eraseToAnyPublisher() + valueProvider = { subject.value } } init(_ value: Output) { self.init(CurrentValueSubject(value)) } + private init(upstream: AnyPublisher, valueProvider: @escaping () -> Output) { + self.upstream = upstream + self.valueProvider = valueProvider + } + func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { - subject.receive(subscriber: subscriber) + upstream.receive(subscriber: subscriber) } var value: Output { - subject.value + valueProvider() } } nonisolated extension CurrentValuePublisher where Failure == Never { + /// Transforms the published values and ``value``. + /// + /// The transform runs lazily, once per subscriber and per ``value`` read. It must stay lazy: + /// this struct is discarded when the mapped publisher is chained inline, so anything eager + /// (a live subscription feeding a second subject) would be cancelled and stop the updates. func map(_ transform: @escaping (Output) -> T) -> CurrentValuePublisher { - let subject = CurrentValueSubject(transform(value)) - let cancellable = sink { subject.send(transform($0)) } - return .init(subject, cancellable: cancellable) + .init(upstream: upstream.map(transform).eraseToAnyPublisher(), + valueProvider: { transform(value) }) } } diff --git a/UnitTests/Sources/CurrentValuePublisherTests.swift b/UnitTests/Sources/CurrentValuePublisherTests.swift new file mode 100644 index 000000000..7c3b135ad --- /dev/null +++ b/UnitTests/Sources/CurrentValuePublisherTests.swift @@ -0,0 +1,48 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +@testable import ElementX +import Testing + +struct CurrentValuePublisherTests { + @Test + func publisherReplaysCurrentValueAndDeliversSubsequentOnes() { + let subject = CurrentValueSubject(1) + var received = [Int]() + let cancellable = subject.asCurrentValuePublisher().sink { received.append($0) } + subject.send(2) + subject.send(3) + #expect(received == [1, 2, 3]) + cancellable.cancel() + } + + @Test + func mappedPublisherChainedInlineDeliversSubsequentValues() { + let subject = CurrentValueSubject(1) + var received = [Int]() + // The mapped publisher is intentionally chained without being stored: the struct is + // discarded mid-chain, which used to cancel an eager map's subscription and stop updates. + let cancellable = subject.asCurrentValuePublisher() + .map { $0 * 2 } + .removeDuplicates() + .sink { received.append($0) } + subject.send(2) + subject.send(3) + #expect(received == [2, 4, 6]) + cancellable.cancel() + } + + @Test + func mappedPublisherValueStaysCurrent() { + let subject = CurrentValueSubject(1) + let mapped = subject.asCurrentValuePublisher().map { $0 * 2 } + #expect(mapped.value == 2) + subject.send(5) + #expect(mapped.value == 10) + } +}