diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift index 6814b7b44..66b9785f7 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift @@ -435,6 +435,36 @@ try test("Date") { try expectEqual(date3 < date1, true) } +// make the timers global to prevent early deallocation +var timeout: JSTimer? +var interval: JSTimer? + +try test("Timer") { + let start = JSDate().valueOf() + let timeoutMilliseconds = 5.0 + + timeout = JSTimer(millisecondsDelay: timeoutMilliseconds, isRepeating: false) { + // verify that at least `timeoutMilliseconds` passed since the `timeout` timer started + try! expectEqual(start + timeoutMilliseconds <= JSDate().valueOf(), true) + } + + var count = 0.0 + let maxCount = 5.0 + interval = JSTimer(millisecondsDelay: 5, isRepeating: true) { + // verify that at least `timeoutMilliseconds * count` passed since the `timeout` + // timer started + try! expectEqual(start + timeoutMilliseconds * count <= JSDate().valueOf(), true) + + guard count < maxCount else { + // stop the timer after `maxCount` reached + interval = nil + return + } + + count += 1 + } +} + try test("Error") { let message = "test error" let error = JSError(message: message) diff --git a/Sources/JavaScriptKit/BasicObjects/JSTimer.swift b/Sources/JavaScriptKit/BasicObjects/JSTimer.swift new file mode 100644 index 000000000..1ba7b8e42 --- /dev/null +++ b/Sources/JavaScriptKit/BasicObjects/JSTimer.swift @@ -0,0 +1,58 @@ +/** This timer type hides [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) +/ [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/clearInterval) and +[`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) +/ [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout) +pairs of calls for you. It intentionally doesn't match the JavaScript API, as a special care is +needed to hold a reference to the timer closure and to call `JSClosure.release()` on it when the +timer is deallocated. As a user, you have to hold a reference to a `JSTimer` instance for it to stay +valid. The `JSTimer` API is also intentionally trivial, the timer is started right away, and the +only way to invalidate the timer is to bring the reference count of the `JSTimer` instance to zero, +either by storing the timer in an optional property and assigning `nil` to it or by deallocating the +object that owns it for invalidation. +*/ +public final class JSTimer { + /// Indicates whether this timer instance calls its callback repeatedly at a given delay. + public let isRepeating: Bool + + private let closure: JSClosure + + /** Node.js and browser APIs are slightly different. `setTimeout`/`setInterval` return an object + in Node.js, while browsers return a number. Fortunately, clearTimeout and clearInterval take + corresponding types as their arguments, and we can store either as JSValue, so we can treat both + cases uniformly. + */ + private let value: JSValue + private let global = JSObject.global + + /** + Creates a new timer instance that calls `setInterval` or `setTimeout` JavaScript functions for you + under the hood. + - Parameters: + - millisecondsDelay: the amount of milliseconds before the `callback` closure is executed. + - isRepeating: when `true` the `callback` closure is executed repeatedly at given + `millisecondsDelay` intervals indefinitely until the timer is deallocated. + - callback: the closure to be executed after a given `millisecondsDelay` interval. + */ + public init(millisecondsDelay: Double, isRepeating: Bool = false, callback: @escaping () -> ()) { + closure = JSClosure { _ in callback() } + self.isRepeating = isRepeating + if isRepeating { + value = global.setInterval.function!(closure, millisecondsDelay) + } else { + value = global.setTimeout.function!(closure, millisecondsDelay) + } + } + + /** Makes a corresponding `clearTimeout` or `clearInterval` call, depending on whether this timer + instance is repeating. The `closure` instance is released manually here, as it is required for + bridged closure instances. + */ + deinit { + if isRepeating { + global.clearInterval.function!(value) + } else { + global.clearTimeout.function!(value) + } + closure.release() + } +}