Skip to content

Commit

Permalink
feat(timer): add throttle and debounce
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jan 5, 2024
1 parent cf098f2 commit 3367ff6
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 34 deletions.
51 changes: 51 additions & 0 deletions packages/timer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import { remove } from 'cosmokit'
declare module 'cordis' {
interface Context {
timer: TimerService
setTimeout(callback: () => void, delay: number): () => void
setInterval(callback: () => void, delay: number): () => void
sleep(delay: number): Promise<void>
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F>
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F>
}
}

type WithDispose<T> = T & { dispose: () => void }

class TimerService extends Service {
constructor(ctx: Context) {
super(ctx, 'timer', true)
Expand Down Expand Up @@ -46,6 +53,50 @@ class TimerService extends Service {
})
})
}

private createWrapper(callback: (args: any[], check: () => boolean) => any, isDisposed = false) {
const caller = this[Context.current]
caller.scope.assertActive()

let timer: number | NodeJS.Timeout | undefined
const dispose = () => {
isDisposed = true
remove(caller.scope.disposables, dispose)
clearTimeout(timer)
}

const wrapper: any = (...args: any[]) => {
clearTimeout(timer)
timer = callback(args, () => !isDisposed && caller.scope.isActive)
}
wrapper.dispose = dispose
caller.scope.disposables.push(dispose)
return wrapper
}

throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity
const execute = (...args: any[]) => {
lastCall = Date.now()
callback(...args)
}
return this.createWrapper((args, isActive) => {
const now = Date.now()
const remaining = delay - (now - lastCall)
if (remaining <= 0) {
execute(...args)
} else if (isActive()) {
return setTimeout(execute, remaining, ...args)
}
}, noTrailing)
}

debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.createWrapper((args, isActive) => {
if (!isActive()) return
return setTimeout(callback, delay, ...args)
})
}
}

export default TimerService
132 changes: 98 additions & 34 deletions packages/timer/tests/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import { afterEach, beforeEach, describe, mock, test } from 'node:test'
import { describe, mock, test } from 'node:test'
import { FakeTimerInstallOpts, install, InstalledClock } from '@sinonjs/fake-timers'
import { Context } from 'cordis'
import { expect } from 'chai'
import Timer from '../src'
import assert from 'node:assert'
import Timer from '../src'

function tick(delay = 0) {
mock.timers.tick(delay)
return new Promise<void>(resolve => process.nextTick(resolve))
declare module 'cordis' {
interface Context {
clock: InstalledClock
}
}

beforeEach(() => {
mock.timers.enable()
})

afterEach(() => {
mock.timers.reset()
})

function withContext(callback: (ctx: Context) => Promise<void>) {
function withContext(callback: (ctx: Context) => Promise<void>, config?: FakeTimerInstallOpts) {
return () => new Promise<void>((resolve, reject) => {
const ctx = new Context()
ctx.clock = install(config)
ctx.plugin(Timer)
ctx.plugin(() => {
callback(ctx).then(resolve, reject)
callback(ctx).then(resolve, reject).finally(() => ctx.clock.uninstall())
})
})
}
Expand All @@ -31,35 +25,35 @@ describe('ctx.setTimeout()', () => {
test('basic support', withContext(async (ctx) => {
const callback = mock.fn()
ctx.setTimeout(callback, 1000)
expect(callback.mock.calls).to.have.length(0)
await tick(1000)
expect(callback.mock.calls).to.have.length(1)
await tick(1000)
expect(callback.mock.calls).to.have.length(1)
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(1000)
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(1000)
assert.strictEqual(callback.mock.calls.length, 1)
}))

test('dispose', withContext(async (ctx) => {
const callback = mock.fn()
const dispose = ctx.setTimeout(callback, 1000)
expect(callback.mock.calls).to.have.length(0)
assert.strictEqual(callback.mock.calls.length, 0)
dispose()
await tick(5000)
expect(callback.mock.calls).to.have.length(0)
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 0)
}))
})

describe('ctx.setInterval()', () => {
test('basic support', withContext(async (ctx) => {
const callback = mock.fn()
const dispose = ctx.setInterval(callback, 1000)
expect(callback.mock.calls).to.have.length(0)
await tick(1000)
expect(callback.mock.calls).to.have.length(1)
await tick(1000)
expect(callback.mock.calls).to.have.length(2)
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(1000)
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(1000)
assert.strictEqual(callback.mock.calls.length, 2)
dispose()
await tick(5000)
expect(callback.mock.calls).to.have.length(2)
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 2)
}))
})

Expand All @@ -68,15 +62,85 @@ describe('ctx.sleep()', () => {
const resolve = mock.fn()
const reject = mock.fn()
ctx.sleep(1000).then(resolve, reject)
await tick(500)
await ctx.clock.tickAsync(500)
assert.strictEqual(resolve.mock.calls.length, 0)
assert.strictEqual(reject.mock.calls.length, 0)
await tick(500)
await ctx.clock.tickAsync(500)
assert.strictEqual(resolve.mock.calls.length, 1)
assert.strictEqual(reject.mock.calls.length, 0)
ctx.scope.dispose()
await tick(5000)
await ctx.clock.tickAsync(2000)
assert.strictEqual(resolve.mock.calls.length, 1)
assert.strictEqual(reject.mock.calls.length, 0)
}))
})

describe('ctx.throttle()', () => {
test('basic support', withContext(async (ctx) => {
const callback = mock.fn()
const throttled = ctx.throttle(callback, 1000)
throttled()
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(600)
throttled()
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(600)
throttled()
assert.strictEqual(callback.mock.calls.length, 2)
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 3)
}))

test('trailing mode', withContext(async (ctx) => {
const callback = mock.fn()
const throttled = ctx.throttle(callback, 1000)
throttled()
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(500)
throttled()
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(500)
assert.strictEqual(callback.mock.calls.length, 2)
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 2)
}))

test('disposed', withContext(async (ctx) => {
const callback = mock.fn()
const throttled = ctx.throttle(callback, 1000)
throttled.dispose()
throttled()
assert.strictEqual(callback.mock.calls.length, 1)
await ctx.clock.tickAsync(500)
throttled()
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 1)
}))
})

describe('ctx.debounce()', () => {
test('basic support', withContext(async (ctx) => {
const callback = mock.fn()
const debounced = ctx.debounce(callback, 1000)
debounced()
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(400)
debounced()
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(400)
debounced()
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(1000)
assert.strictEqual(callback.mock.calls.length, 1)
}))

test('disposed', withContext(async (ctx) => {
const callback = mock.fn()
const debounced = ctx.debounce(callback, 1000)
debounced.dispose()
debounced()
assert.strictEqual(callback.mock.calls.length, 0)
await ctx.clock.tickAsync(2000)
assert.strictEqual(callback.mock.calls.length, 0)
}))
})

0 comments on commit 3367ff6

Please sign in to comment.