What breaks. UIStream.run calls Task.sleep(for:) directly, and the package ships no test target (1.2.0: five source files under Sources/, nothing under Tests/). So nothing that consumes it can assert its sequencing without waiting real time, and the package's own lifecycle guarantees (animateIn, rest or block, animateOut, endDelay, hidden; .reject on cancel before animateIn; animateOut then hidden on cancel mid-show) are enforced by nothing. The concrete consequence today: sprintly#4 asks whether Sprintly's splash converges onto this package, and the recommendation is to converge, but the port cannot be measured. The instrument for that (run the same members under a real clock and an instant one, diff the emission logs) needs a seam this package does not have.
Fix. Two parts, one PR:
- Inject the clock.
public init(clock: any Clock<Duration> = ContinuousClock()), stored, and every Task.sleep(for:) in run and animateOutAndHide becomes clock.sleep(for:). Default argument is a value, not a closure literal; see the note below on why that matters.
- Add
Tests/UIStreamTests with a test clock that returns immediately and records requested durations, and assert at least: full lifecycle order for a timed member; a blocker waits for unblock(); cancelAll() mid-show emits animateOut then hidden and nothing after; enqueueExclusively emits .reject for members still queued and starts the replacement only after the interrupted member is hidden. Each test seen to fail on a planted defect before it is trusted.
Do not put an async closure literal in a public default argument. On Swift 6.2.4 that shape is compiled into every importing module with a different async frame size and the linker mixes the copies (swiftlang/swift#92017, hit on Sprintly's ui-queue and fixed there in sprintly#15). A Clock value as the default is fine; a { try await Task.sleep(...) } default is not.
Origin. Surfaced by the UIQueue demo work behind sprintly#4 on 2026-09-08; the comparison against ui-queue is on that ticket.
What breaks.
UIStream.runcallsTask.sleep(for:)directly, and the package ships no test target (1.2.0: five source files underSources/, nothing underTests/). So nothing that consumes it can assert its sequencing without waiting real time, and the package's own lifecycle guarantees (animateIn, rest or block, animateOut, endDelay, hidden;.rejecton cancel before animateIn; animateOut then hidden on cancel mid-show) are enforced by nothing. The concrete consequence today: sprintly#4 asks whether Sprintly's splash converges onto this package, and the recommendation is to converge, but the port cannot be measured. The instrument for that (run the same members under a real clock and an instant one, diff the emission logs) needs a seam this package does not have.Fix. Two parts, one PR:
public init(clock: any Clock<Duration> = ContinuousClock()), stored, and everyTask.sleep(for:)inrunandanimateOutAndHidebecomesclock.sleep(for:). Default argument is a value, not a closure literal; see the note below on why that matters.Tests/UIStreamTestswith a test clock that returns immediately and records requested durations, and assert at least: full lifecycle order for a timed member; a blocker waits forunblock();cancelAll()mid-show emits animateOut then hidden and nothing after;enqueueExclusivelyemits.rejectfor members still queued and starts the replacement only after the interrupted member is hidden. Each test seen to fail on a planted defect before it is trusted.Do not put an async closure literal in a public default argument. On Swift 6.2.4 that shape is compiled into every importing module with a different async frame size and the linker mixes the copies (swiftlang/swift#92017, hit on Sprintly's ui-queue and fixed there in sprintly#15). A
Clockvalue as the default is fine; a{ try await Task.sleep(...) }default is not.Origin. Surfaced by the UIQueue demo work behind sprintly#4 on 2026-09-08; the comparison against ui-queue is on that ticket.