chore(cordis): 同步 Timer 1.1.4

This commit is contained in:
Shigma
2026-10-14 15:01:18 +08:00
parent c0bf2ac81d
commit 7618ccec69
12 changed files with 219 additions and 51 deletions
@@ -33,8 +33,8 @@
en: df635b29eaef982c
zh: ddaa4bd98359bd30
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map:
en: cbd46fb5821cfcc7
zh: 392e5cace2db6194
en: 22a43e93fd123200
zh: f8305186a748369b
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs:
en: 33f14a7fff7835e6
zh: 53838ac8d4a79600
@@ -64,7 +64,7 @@ The browser half is built on one principle: a dynamic package must ride the same
| [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router |
| [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) |
| [`src/client/timer.ts`](src/client/timer.ts) | The client timer service dynamic packages inject |
| [`src/client/timer.ts`](src/client/timer.ts) | Client timers with ordered concurrent interval reads and void-returning throttle/debounce wrappers that become inert on disposal |
### Inspecting slot inputs
@@ -64,7 +64,7 @@ kind: "package-reference"
| [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade |
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 |
| [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) |
| [`src/client/timer.ts`](src/client/timer.ts) | 动态包注入的 client 定时器服务 |
| [`src/client/timer.ts`](src/client/timer.ts) | client 定时器按顺序完成并发 interval 读取;throttle/debounce 包装函数返回 void,释放后不再执行 |
### 查询槽位输入
@@ -319,12 +319,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
@@ -21,7 +21,7 @@ declare module '@deepseek-ai/cordis' {
}
}
type WithDispose<T> = T & { dispose: () => void }
type Scheduled<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & { dispose: () => void }
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
// return/rejection values must pass through without narrowing them to one caller's invocation.
@@ -104,7 +104,7 @@ export class ClientTimerService extends Service {
/**
* Iterate over timer ticks.
* @param delay - interval in milliseconds.
* @returns async iterator of ticks.
* @returns Iterator completing concurrent reads in order, one per tick; disposal rejects all pending reads.
*/
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
@@ -118,33 +118,37 @@ export class ClientTimerService extends Service {
}
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
const dispose = this.ctx.effect(() => {
const timer = globalThis.setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
nextTasks.shift()?.resolve({ done: false, value: undefined })
}, delay)
return () => {
globalThis.clearInterval(timer)
if (done !== undefined) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
for (const task of nextTasks.splice(0)) task.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
if (done === undefined) {
const task = Promise.withResolvers<IteratorResult<void>>()
nextTasks.push(task)
return task.promise
}
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value: any) => {
if (done === undefined) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
for (const task of nextTasks.splice(0)) task.resolve({ done: true, value })
void dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason: any) => {
if (done === undefined) done = { kind: 'throw', reason }
nextTask?.reject(reason)
for (const task of nextTasks.splice(0)) task.reject(reason)
void dispose()
return Promise.resolve({ done: true, value: undefined })
},
@@ -155,15 +159,18 @@ export class ClientTimerService extends Service {
}
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
private schedule(label: string, trigger: (args: any[]) => number | undefined): any {
let timer: number | undefined
let disposed = false
const dispose = this.ctx.effect(() => () => {
disposed = true
globalThis.clearTimeout(timer)
timer = undefined
}, label)
const wrapper: any = (...args: any[]): void => {
if (disposed) return
globalThis.clearTimeout(timer)
timer = trigger(args, disposed)
timer = trigger(args)
}
wrapper.dispose = dispose
return wrapper
@@ -174,33 +181,32 @@ export class ClientTimerService extends Service {
* @param callback - Function to throttle.
* @param delay - Minimum interval between calls in milliseconds.
* @param noTrailing - Whether to suppress a delayed trailing call.
* @returns Throttled function with an early disposer.
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
*/
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): Scheduled<F> {
let lastCall = -Infinity
const execute = (...args: Parameters<F>): void => {
lastCall = Date.now()
callback(...args)
}
return this.schedule('ctx.throttle()', (args, disposed) => {
return this.schedule('ctx.throttle()', (args) => {
const remaining = delay - Date.now() + lastCall
if (remaining <= 0) {
execute(...args as Parameters<F>)
} else if (!disposed) {
} else if (!noTrailing) {
return globalThis.setTimeout(execute, remaining, ...args)
}
}, noTrailing)
})
}
/**
* Return a debounced function whose timer is disposed with the calling Fiber.
* @param callback - Function to debounce.
* @param delay - Quiet period in milliseconds.
* @returns Debounced function with an early disposer.
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
*/
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.schedule('ctx.debounce()', (args, disposed) => {
if (disposed) return
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
return this.schedule('ctx.debounce()', (args) => {
return globalThis.setTimeout(callback, delay, ...args)
})
}
@@ -0,0 +1,53 @@
/** @vitest-environment jsdom */
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { ClientTimerService } from '../src/client/timer.ts'
describe('client Timer', () => {
let ctx: Context
beforeEach(async () => {
vi.useFakeTimers()
ctx = new Context()
await ctx.plugin(ClientTimerService)
})
afterEach(async () => {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
}
})
it('delivers concurrent reads in order and rejects the remaining reads on teardown', async () => {
const ticks = ctx.interval(20)
const outcomes: string[] = []
for (let index = 0; index < 3; index++) {
void ticks.next().then(() => outcomes.push(`tick ${index}`), () => outcomes.push(`disposed ${index}`))
}
await vi.advanceTimersByTimeAsync(20)
expect(outcomes).toEqual(['tick 0'])
await ctx.fiber.dispose()
expect(outcomes).toEqual(['tick 0', 'disposed 1', 'disposed 2'])
expect(vi.getTimerCount()).toBe(0)
})
it('keeps wrapper calls void and inert after disposal', async () => {
const work = vi.fn((value: string) => value.length)
const throttle = ctx.throttle(work, 20)
const debounce = ctx.debounce(work, 20)
expectTypeOf(throttle).returns.toBeVoid()
expectTypeOf(debounce).returns.toBeVoid()
throttle('leading')
throttle('trailing')
debounce('delayed')
throttle.dispose()
debounce.dispose()
await vi.advanceTimersByTimeAsync(40)
throttle('disposed')
debounce('disposed')
expect(work).toHaveBeenCalledExactlyOnceWith('leading')
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -3305,12 +3305,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
+101
View File
@@ -0,0 +1,101 @@
/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */
import { Context } from '@deepseek-ai/cordis'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
describe('Cordis Timer', () => {
let ctx: Context
beforeEach(async () => {
vi.useFakeTimers()
ctx = new Context()
await ctx.plugin(Timer)
})
afterEach(async () => {
try {
await ctx.fiber.dispose()
} finally {
vi.useRealTimers()
}
})
it('completes concurrent interval reads in order, one per tick', async () => {
const iterator = ctx.interval(10)
const completed: number[] = []
const reads = [0, 1, 2].map(index => iterator.next().then(
(value) => { completed.push(index); return value },
() => undefined,
))
for (let index = 0; index < reads.length; index++) {
await vi.advanceTimersByTimeAsync(10)
expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i))
}
expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined }))
await iterator.return!()
expect(vi.getTimerCount()).toBe(0)
})
it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => {
const iterator = ctx.interval<string>(10)
const completed: PromiseSettledResult<IteratorResult<void, string>>[] = []
for (let index = 0; index < 3; index++) {
void iterator.next().then(
value => completed.push({ status: 'fulfilled', value }),
(reason: unknown) => completed.push({ status: 'rejected', reason }),
)
}
const error = new Error('stopped')
if (operation === 'return') await iterator.return!('finished')
else if (operation === 'throw') await iterator.throw!(error)
else await ctx.fiber.dispose()
await vi.advanceTimersByTimeAsync(0)
expect(completed).toHaveLength(3)
if (operation === 'return') {
expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } }))
await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' })
} else {
const reason = operation === 'throw' ? error : new Error('Context has been disposed')
expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason }))
await expect(iterator.next()).rejects.toThrow(reason.message)
}
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => {
const callback = vi.fn((value: string) => value.length)
const throttled = ctx.throttle(callback, 10, noTrailing)
expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>()
expectTypeOf(throttled).returns.toBeVoid()
throttled('first')
throttled('second')
await vi.advanceTimersByTimeAsync(10)
expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']])
throttled('pending')
const calls = callback.mock.calls.length
throttled.dispose()
await vi.advanceTimersByTimeAsync(20)
throttled('after disposal')
expect(callback).toHaveBeenCalledTimes(calls)
expect(vi.getTimerCount()).toBe(0)
})
it('cancels debounce work and makes both wrappers inert after owner disposal', async () => {
const callback = vi.fn((value: string) => value.length)
const debounced = ctx.debounce(callback, 10)
const throttled = ctx.throttle(callback, 10)
expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>()
expectTypeOf(debounced).returns.toBeVoid()
debounced('first')
debounced('last')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledExactlyOnceWith('last')
debounced('pending')
await ctx.fiber.dispose()
debounced('disposed')
throttled('disposed')
await vi.advanceTimersByTimeAsync(10)
expect(callback).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
})
+2 -2
View File
@@ -1007,11 +1007,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
jsDoc: '/** Return an async iterator of timer ticks. */',
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */',
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */',
},
],
+2 -2
View File
@@ -20,7 +20,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` |
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
@@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
+6 -4
View File
@@ -18,8 +18,7 @@ const dispose = root.timeout(() => {
dispose()
```
Timer handles are registered on the current fiber, so they are cleared
automatically when the plugin that created them is disposed.
Timer handles are registered on the current fiber, so they are cleared automatically when the plugin that created them is disposed.
## API
@@ -32,5 +31,8 @@ automatically when the plugin that created them is disposed.
| `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. |
| `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. |
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for
`ctx.timeout()` and `ctx.interval()`.
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for `ctx.timeout()` and `ctx.interval()`.
Concurrent `next()` calls on an interval iterator complete in request order, one per tick. Returning from or throwing into the iterator settles every pending read; disposing the owning fiber rejects every pending read. Ticks without a pending read are discarded.
Throttle and debounce wrappers return `void`, regardless of the callback return type. Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later calls inert. `noTrailing` suppresses delayed throttle calls while retaining leading calls until disposal.
+22 -16
View File
@@ -6,7 +6,7 @@ declare module '@deepseek-ai/cordis' {
}
}
type WithDispose<T> = T & { dispose: () => void }
type Scheduled<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & { dispose: () => void }
/** Disposable timer helpers mixed into Cordis contexts. */
export class TimerService extends Service {
@@ -66,33 +66,37 @@ export class TimerService extends Service {
}, 'ctx.interval()')
} else {
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
const dispose = this.ctx.effect(() => {
const timer = setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
nextTasks.shift()?.resolve({ done: false, value: undefined })
}, delay)
return () => {
clearInterval(timer)
if (done) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
for (const task of nextTasks.splice(0)) task.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (!done) return (nextTask = Promise.withResolvers()).promise
if (!done) {
const task = Promise.withResolvers<IteratorResult<void>>()
nextTasks.push(task)
return task.promise
}
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value) => {
if (!done) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
for (const task of nextTasks.splice(0)) task.resolve({ done: true, value })
dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason) => {
if (!done) done = { kind: 'throw', reason }
nextTask?.reject(reason)
for (const task of nextTasks.splice(0)) task.reject(reason)
dispose()
return Promise.resolve({ done: true, value: undefined })
},
@@ -103,42 +107,44 @@ export class TimerService extends Service {
}
}
private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) {
private _schedule(label: string, trigger: (args: any[]) => number | NodeJS.Timeout | undefined) {
let timer: number | NodeJS.Timeout | undefined
let isDisposed = false
const dispose = this.ctx.effect(() => () => {
isDisposed = true
clearTimeout(timer)
timer = undefined
}, label)
const wrapper: any = (...args: any[]) => {
if (isDisposed) return
clearTimeout(timer)
timer = trigger(args, isDisposed)
timer = trigger(args)
}
wrapper.dispose = dispose
return wrapper
}
/** Return a throttled function whose timer is disposed with the current fiber. */
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): Scheduled<F> {
let lastCall = -Infinity
const execute = (...args: any[]) => {
lastCall = Date.now()
callback(...args)
}
return this._schedule('ctx.throttle()', (args, isDisposed) => {
return this._schedule('ctx.throttle()', (args) => {
const now = Date.now()
const remaining = delay - now + lastCall
if (remaining <= 0) {
execute(...args)
} else if (!isDisposed) {
} else if (!noTrailing) {
return setTimeout(execute, remaining, ...args)
}
}, noTrailing)
})
}
/** Return a debounced function whose timer is disposed with the current fiber. */
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this._schedule('ctx.debounce()', (args, isDisposed) => {
if (isDisposed) return
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
return this._schedule('ctx.debounce()', (args) => {
return setTimeout(callback, delay, ...args)
})
}