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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# Unreleased (0.5.0)

**Additions (strictly non-breaking — the public API is additive only):**

- New virtual-clock seam for deterministic scheduling: the `JSClockSource` protocol
(current time + a timer factory mirroring `JSTimer`'s shape, returning a
`JSClockCancellable` token) and the production `DefaultJSClockSource` backed by
`JSDate.now()`/`JSTimer`. `JSScheduler` routes every scheduling path — immediate,
one-shot, repeating, and the async `sleep(for:)`/`timer(interval:)` bridges — through
the seam. The new `JSScheduler.init(clock:)` injects a custom clock; the existing
`init()` is unchanged and keeps the default behavior
([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14))
- Deterministic scheduler tests via a manually advanced `VirtualClock` test double; the
exact-value timing suites need no JavaScript runtime and now run on the host lane as
well as on wasm ([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14))

**Documentation:**

- `JSScheduler`'s concurrency model is now an explicit documented contract: the class is
intentionally **not** `Sendable` (its unsynchronized state is only safe on the
single-threaded JS event loop, and Apple-side Combine could legally call a `Sendable`
scheduler from arbitrary threads, so `@unchecked Sendable` was evaluated and rejected).
The module overview gains a "Deterministic testing" guide for the clock seam
([#14](https://github.com/IGRSoft/OpenCombineJS/issues/14))

# 0.4.0 (2026-06-11)

**Declared change — Apple platforms only:**
Expand Down
44 changes: 44 additions & 0 deletions Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The library provides these components:
|---|---|
| ``JSScheduler`` | A Combine `Scheduler` backed by `setTimeout`/`setInterval` that enables time-based operators in the browser. |
| ``JSScheduler/sleep(for:)`` / ``JSScheduler/timer(interval:)`` | Async/await counterparts of the scheduling APIs: a one-shot JS-timer delay and an `AsyncStream` of repeating ticks. |
| ``JSClockSource`` | The injectable time-and-timer seam behind ``JSScheduler``; swap in a virtual clock for deterministic tests. ``DefaultJSClockSource`` is the production implementation. |
| `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. Its async counterpart is JavaScriptKit's `JSPromise.value` (`import JavaScriptEventLoop`). |
| `JSValueDecoder` (`TopLevelDecoder`) | A retroactive conformance that lets `JSValueDecoder` work with the `.decode(type:decoder:)` Combine operator. |

Expand Down Expand Up @@ -50,6 +51,42 @@ for await _ in scheduler.timer(interval: .seconds(1)) { // repeating setInterva

The Combine surface is not deprecated: publishers and async APIs are supported side by side.

### Deterministic testing with a custom clock

``JSScheduler`` performs all time observation and timer creation through the ``JSClockSource``
seam. ``JSScheduler/init()`` uses ``DefaultJSClockSource`` (`JSDate.now()` + `JSTimer`);
``JSScheduler/init(clock:)`` accepts any conforming implementation. Because **every**
scheduling path flows through the seam — immediate, one-shot, repeating, and the async
``JSScheduler/sleep(for:)``/``JSScheduler/timer(interval:)`` bridges — injecting a manually
advanced clock makes scheduler behavior fully deterministic: no real timers, no jitter
tolerance bands, and no JavaScript runtime required, so such tests can run on any platform:

```swift
final class VirtualClock: JSClockSource {
private(set) var now: Double = 0
// makeTimer(millisecondsDelay:isRepeating:callback:) records pending timers;
// advance(by:) fires the due ones in order and moves `now`.
}

let clock = VirtualClock()
let scheduler = JSScheduler(clock: clock)

var fired = false
scheduler.schedule(
after: scheduler.now.advanced(by: .milliseconds(50)),
tolerance: scheduler.minimumTolerance,
options: nil
) { fired = true }

clock.advance(by: 49) // fired == false — not due yet
clock.advance(by: 1) // fired == true — exactly on time
```

The package's own test suite contains a complete reference implementation
(`Tests/OpenCombineJSTests/VirtualClock.swift`) that mirrors JavaScript timer semantics:
due-time ordering with creation-order ties, negative delays clamping to zero, and repeating
timers firing once per elapsed period.

### JavaScript event-loop context

All code in OpenCombineJS runs on the single-threaded JavaScript event loop. There is no
Expand Down Expand Up @@ -127,6 +164,13 @@ let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) {
- ``JSScheduler/sleep(for:)``
- ``JSScheduler/timer(interval:)``

### Clock Injection

- ``JSClockSource``
- ``JSClockCancellable``
- ``DefaultJSClockSource``
- ``JSScheduler/init(clock:)``

### JavaScript Promise Integration

OpenCombineJS extends `JSPromise` (from JavaScriptKit) with a `publisher` property and two
Expand Down
110 changes: 110 additions & 0 deletions Sources/OpenCombineJS/JSClockSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2020 OpenCombineJS contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Virtual-clock seam for JSScheduler (issue #14). Strictly additive: the default
// implementation reproduces the historical JSDate.now()/JSTimer behavior exactly.

import JavaScriptKit

/// A cancellation token for a timer created by a ``JSClockSource``.
///
/// ``JSScheduler`` keeps the token alive while the timer is pending and calls ``cancel()``
/// exactly when the timer must stop firing (on explicit cancellation, or as harmless cleanup
/// after a one-shot timer has already fired — implementations must treat cancelling a fired
/// or already-cancelled timer as a no-op, mirroring JavaScript's `clearTimeout`).
///
/// Conforming types should also stop the timer when the token is deinitialized, mirroring
/// `JSTimer`'s reference semantics. `JSScheduler` always cancels explicitly before releasing
/// a token, so deinit-cancellation is only a defensive backstop.
public protocol JSClockCancellable: AnyObject {
/// Stops the timer. Calling this on a fired or already-cancelled timer is a no-op.
func cancel()
}

/// The time-and-timer seam used by ``JSScheduler``.
///
/// `JSScheduler` performs **all** of its time observation and timer creation through this
/// protocol — the immediate, one-shot, and repeating Combine scheduling paths as well as the
/// async ``JSScheduler/sleep(for:)`` and ``JSScheduler/timer(interval:)`` bridges. Injecting
/// a deterministic implementation via ``JSScheduler/init(clock:)`` therefore makes every
/// scheduler behavior reproducible without real timers — including on platforms without a
/// JavaScript runtime (see the "Deterministic testing" section in the module overview).
///
/// The default implementation, ``DefaultJSClockSource``, is backed by `JSDate.now()` and
/// `JSTimer` (`setTimeout`/`setInterval`) and is what ``JSScheduler/init()`` uses.
///
/// Implementations inherit the scheduler's single-threaded contract: all calls are made from
/// the thread that owns the scheduler, and conforming types are not required to be `Sendable`.
public protocol JSClockSource {
/// The current time in milliseconds since the Unix epoch (the `Date.now()` convention).
var now: Double { get }

/// Creates and starts a timer, mirroring `JSTimer.init(millisecondsDelay:isRepeating:callback:)`.
///
/// - Parameters:
/// - millisecondsDelay: Delay before the first (or only) firing, in milliseconds.
/// Negative values must clamp to `0`, matching JavaScript timer semantics.
/// - isRepeating: When `true`, `callback` fires repeatedly every `millisecondsDelay`
/// milliseconds until the returned token is cancelled; when `false`, it fires once.
/// - callback: The closure to invoke when the timer fires.
/// - Returns: A token that keeps the timer alive and stops it when cancelled.
func makeTimer(
millisecondsDelay: Double,
isRepeating: Bool,
callback: @escaping () -> ()
) -> any JSClockCancellable
}

/// The production ``JSClockSource``: real JavaScript time and timers.
///
/// `now` reads `JSDate.now()`; ``makeTimer(millisecondsDelay:isRepeating:callback:)`` creates
/// a `JSTimer` (`setTimeout`/`setInterval`) — exactly the behavior `JSScheduler` had before
/// the seam existed. Requires a live JavaScript runtime (WebAssembly/browser/Node.js);
/// constructing the value itself is side-effect free.
public struct DefaultJSClockSource: JSClockSource {
/// Creates the default JavaScript-backed clock source.
public init() {}

/// The current time as reported by `Date.now()`, in milliseconds since the Unix epoch.
public var now: Double {
JSDate.now()
}

/// Starts a `JSTimer` and returns a token whose cancellation releases the timer
/// (dropping the last `JSTimer` reference calls `clearTimeout`/`clearInterval`).
public func makeTimer(
millisecondsDelay: Double,
isRepeating: Bool,
callback: @escaping () -> ()
) -> any JSClockCancellable {
JSTimerCancellable(
JSTimer(millisecondsDelay: millisecondsDelay, isRepeating: isRepeating, callback: callback)
)
}
}

/// Token wrapping a `JSTimer`. `JSTimer` has no explicit `invalidate()` — releasing the last
/// strong reference clears the underlying JS timer in `deinit` — so cancellation simply drops
/// the reference. Repeated cancellation is a natural no-op.
private final class JSTimerCancellable: JSClockCancellable {
private var timer: JSTimer?

init(_ timer: JSTimer) {
self.timer = timer
}

func cancel() {
timer = nil
}
}
5 changes: 4 additions & 1 deletion Sources/OpenCombineJS/JSScheduler+Async.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public extension JSScheduler {
/// The delay is implemented with `setTimeout` — **not** `Task.sleep` — so it follows exactly
/// the same JS macrotask semantics as the Combine scheduling APIs and shares the scheduler's
/// internal timer bookkeeping (the timer entry is removed when it fires; nothing leaks).
/// Like every scheduling path, the delay is driven by the scheduler's ``JSClockSource``, so
/// an injected deterministic clock controls this suspension too.
///
/// The method never resumes early: the continuation is resumed by the JS timer callback, which
/// the JS runtime fires no earlier than the requested delay. Zero or negative intervals resume
Expand All @@ -55,7 +57,8 @@ public extension JSScheduler {

/// Returns an `AsyncStream` that yields once per `interval`, backed by the same repeating
/// JS timer (`setInterval`) semantics as
/// ``JSScheduler/schedule(after:interval:tolerance:options:_:)``.
/// ``JSScheduler/schedule(after:interval:tolerance:options:_:)`` — and, like that path,
/// driven by the scheduler's ``JSClockSource``.
///
/// The first tick arrives one full `interval` after the call — the same first-fire semantics
/// as the Combine repeating schedule. The stream never finishes on its own; iteration ends
Expand Down
Loading
Loading