refactor(cordis): reuse TimerService in Client runner
This commit is contained in:
1 parent
f10be1f63a
commit
e821bc31d6
17 files changed
+79
-307
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: 8b8de9b0fc2e3fc3
|
||||
zh: ffa20b7ffa7540ef
|
||||
/timer-wrapper-return-types-and-disposal/change:
|
||||
en: 978657a23b852a1e
|
||||
zh: 1402cc87410cfc7a
|
||||
en: 6ecd1dc8667a321d
|
||||
zh: 150f620b06934133
|
||||
/timer-wrapper-return-types-and-disposal/migration:
|
||||
en: c07c4d66ff9fd1ff
|
||||
zh: ecebc12b08a9be47
|
||||
@@ -9,7 +9,7 @@ English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. This applies to Host Timer and browser ClientTimerService.
|
||||
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. Host and browser use the same `TimerService` implementation.
|
||||
|
||||
Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later wrapper calls inert. A disposed throttle wrapper previously could still execute a leading call; debounce wrappers already ignored calls after disposal.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后
|
||||
|
||||
## 变更
|
||||
|
||||
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。此变更适用于 Host Timer 和浏览器 ClientTimerService。
|
||||
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。Host 与浏览器使用同一份 `TimerService` 实现。
|
||||
|
||||
调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|
|
||||
* identity to share — the framework itself is a requested module-table row
|
||||
* (external), while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\/(?:(?:cosmokit|schemastery)(?:\/|$)|cordis-plugin-timer$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: df635b29eaef982c
|
||||
zh: ddaa4bd98359bd30
|
||||
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map:
|
||||
en: f69f511aa85127f2
|
||||
zh: 651ea63d81bbd648
|
||||
en: 49412a5fd3800d54
|
||||
zh: 9ba50539d19c0e86
|
||||
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs:
|
||||
en: 33f14a7fff7835e6
|
||||
zh: 53838ac8d4a79600
|
||||
|
||||
@@ -55,6 +55,8 @@ The browser half is built on one principle: a dynamic package must ride the same
|
||||
|
||||
### Source map
|
||||
|
||||
The entry installs the vendored Cordis `TimerService`, shared with the Host, in the runner's context. The browser bundle inlines Timer and uses the module table's Cordis instance. Timer calls belong to the calling plugin's fiber; disposing it cancels pending work. `ClientTimerService` is an export alias of `TimerService`.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/client/index.ts`](src/client/index.ts) | Plugin entry: runner, orchestrator, inspect registry, forwarded-event subscription |
|
||||
@@ -64,7 +66,6 @@ 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) | Browser timer Service matching the Host Timer API |
|
||||
|
||||
### Inspecting slot inputs
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ kind: "package-reference"
|
||||
|
||||
### 源码地图
|
||||
|
||||
入口在 runner 的上下文中安装与 Host 共用的 vendored Cordis `TimerService`。浏览器包内联 Timer,并使用模块表中的 Cordis 实例。定时任务归调用插件的 fiber 所有,释放该 fiber 会取消尚未执行的任务。`ClientTimerService` 是 `TimerService` 的导出别名。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/client/index.ts`](src/client/index.ts) | 插件入口:runner、编排器、inspect 注册表、转发事件订阅 |
|
||||
@@ -64,7 +66,6 @@ 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) | 与 Host Timer API 一致的浏览器定时器 Service |
|
||||
|
||||
### 查询槽位输入
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult,
|
||||
DynamicCordisInventoryRow,
|
||||
@@ -26,7 +27,6 @@ import { DynamicCordisPackageRunner } from './runtime.ts'
|
||||
import { CordisRunOrchestrator } from './orchestrator.ts'
|
||||
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
|
||||
import { clientInspectProviders } from './providers.ts'
|
||||
import { provideClientTimer } from './timer.ts'
|
||||
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
|
||||
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
|
||||
|
||||
@@ -49,7 +49,7 @@ export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from '
|
||||
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
|
||||
export { dynamicCordisContext } from './guard.ts'
|
||||
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
|
||||
export { ClientTimerService } from './timer.ts'
|
||||
export { TimerService as ClientTimerService } from '@deepseek-ai/cordis-plugin-timer'
|
||||
// Re-exported so consumers of the service face and the two events can name
|
||||
// their subjects without reaching into the wire contract themselves.
|
||||
export type {
|
||||
@@ -186,7 +186,7 @@ export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCo
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
provideClientTimer(ctx)
|
||||
new TimerService(ctx)
|
||||
const inspect = new ClientCordisInspectRegistry({
|
||||
sync: async (providers) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
/** Browser implementation of the Cordis timer Service. */
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/*
|
||||
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
|
||||
* async-iterator return and rejection values, so narrowing these positions would change the public API.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
|
||||
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
|
||||
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
|
||||
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
|
||||
/** Browser timer Service used by the mixed-in Context helpers. */
|
||||
timer: ClientTimerService
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
|
||||
export class ClientTimerService extends Service {
|
||||
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'timer')
|
||||
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once through {@link timeout}.
|
||||
* @param callback - Work to run after the delay.
|
||||
* @param delay - Delay in milliseconds.
|
||||
* @returns Disposer that cancels the pending callback early.
|
||||
* @deprecated Use `ctx.timeout()` instead.
|
||||
*/
|
||||
setTimeout(callback: () => void, delay: number): () => void {
|
||||
return this.timeout(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly through {@link interval}.
|
||||
* @param callback - Work to run on each tick.
|
||||
* @param delay - Interval in milliseconds.
|
||||
* @returns Disposer that stops the interval early.
|
||||
* @deprecated Use `ctx.interval()` instead.
|
||||
*/
|
||||
setInterval(callback: () => void, delay: number): () => void {
|
||||
return this.interval(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once after a delay.
|
||||
* @param callback - work to run.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns disposer that cancels the callback.
|
||||
*/
|
||||
timeout(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Wait for a delay.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns promise resolved after the delay.
|
||||
*/
|
||||
timeout(delay: number): Promise<void>
|
||||
timeout(...args: any[]): any {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void dispose()
|
||||
callback()
|
||||
}, delay)
|
||||
return () => { globalThis.clearTimeout(timer) }
|
||||
}, 'ctx.timeout()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(resolve, delay)
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer)
|
||||
reject(new Error('Context has been disposed'))
|
||||
}
|
||||
}, 'ctx.timeout()')
|
||||
return promise.finally(() => { void dispose() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly.
|
||||
* @param callback - work to run on each tick.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @returns disposer that stops the interval.
|
||||
*/
|
||||
interval(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Iterate over timer ticks.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @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 {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
return this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(callback, delay)
|
||||
return () => { globalThis.clearInterval(timer) }
|
||||
}, 'ctx.interval()')
|
||||
}
|
||||
|
||||
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
|
||||
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(() => {
|
||||
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') }
|
||||
for (const task of nextTasks.splice(0)) task.reject(done.reason)
|
||||
}
|
||||
}, 'ctx.interval()')
|
||||
return {
|
||||
next: () => {
|
||||
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 }
|
||||
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 }
|
||||
for (const task of nextTasks.splice(0)) task.reject(reason)
|
||||
void dispose()
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this
|
||||
},
|
||||
} satisfies AsyncIterableIterator<void>
|
||||
}
|
||||
|
||||
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
|
||||
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)
|
||||
}
|
||||
wrapper.dispose = dispose
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a throttled function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to throttle.
|
||||
* @param delay - Minimum interval between calls in milliseconds.
|
||||
* @param noTrailing - Whether to suppress a delayed trailing call.
|
||||
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
|
||||
*/
|
||||
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) => {
|
||||
const remaining = delay - Date.now() + lastCall
|
||||
if (remaining <= 0) {
|
||||
execute(...args as Parameters<F>)
|
||||
} else if (!noTrailing) {
|
||||
return globalThis.setTimeout(execute, remaining, ...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a debounced function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to debounce.
|
||||
* @param delay - Quiet period in milliseconds.
|
||||
* @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert.
|
||||
*/
|
||||
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
|
||||
return this.schedule('ctx.debounce()', (args) => {
|
||||
return globalThis.setTimeout(callback, delay, ...args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the browser timer Service on one Client composition.
|
||||
* @param ctx - Client context that owns the Service and mixed-in helpers.
|
||||
* @returns Nothing after registering the Service.
|
||||
*/
|
||||
export function provideClientTimer(ctx: Context): void {
|
||||
new ClientTimerService(ctx)
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
@@ -68,8 +69,7 @@ interface Bench {
|
||||
}
|
||||
|
||||
/** Mount the browser half over a module table and a loader standing on real fibers. */
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
async function boot(ctx = new Context()): Promise<Bench> {
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const factories = new Map<string, () => unknown>()
|
||||
const fibers = new Map<string, { fiber: unknown }>()
|
||||
@@ -194,6 +194,50 @@ async function boot(): Promise<Bench> {
|
||||
}
|
||||
|
||||
describe('browser half', () => {
|
||||
it('installs the shared Timer and disposes scheduled work with its consumer', async () => {
|
||||
const moduleLoader = Object.getOwnPropertyDescriptor(globalThis, '__ModuleLoader__')
|
||||
const ctx = new Context()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const bench = await boot(ctx)
|
||||
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
|
||||
expect(ClientHalf.ClientTimerService).toBe(TimerService)
|
||||
const timeout = vi.fn()
|
||||
const interval = vi.fn()
|
||||
const delayed = vi.fn()
|
||||
const consumer = bench.ctx.plugin({
|
||||
inject: ['timer'],
|
||||
apply(ctx: Context) {
|
||||
ctx.timeout(timeout, 20)
|
||||
ctx.interval(interval, 20)
|
||||
ctx.debounce(delayed, 40)()
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(timeout).toHaveBeenCalledOnce()
|
||||
expect(interval).toHaveBeenCalledOnce()
|
||||
expect(delayed).not.toHaveBeenCalled()
|
||||
await consumer.dispose()
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
expect(interval).toHaveBeenCalledOnce()
|
||||
expect(delayed).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
|
||||
await bench.dispose()
|
||||
expect(bench.ctx.get('timer')).toBeUndefined()
|
||||
expect(bench.ctx.timeout).toBeUndefined()
|
||||
} finally {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (moduleLoader) Object.defineProperty(globalThis, '__ModuleLoader__', moduleLoader)
|
||||
else Reflect.deleteProperty(globalThis, '__ModuleLoader__')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('provides the load engine as the page run-state face', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/** @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)
|
||||
})
|
||||
|
||||
it('retains leading calls and suppresses trailing calls with noTrailing', async () => {
|
||||
const work = vi.fn()
|
||||
const throttle = ctx.throttle(work, 20, true)
|
||||
throttle('first')
|
||||
throttle('suppressed')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(work).toHaveBeenCalledExactlyOnceWith('first')
|
||||
throttle('next window')
|
||||
expect(work.mock.calls).toEqual([['first'], ['next window']])
|
||||
throttle.dispose()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
throttle('disposed')
|
||||
expect(work).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
|
||||
Generated
+3
@@ -9332,6 +9332,9 @@ importers:
|
||||
'@deepseek-ai/cordis-plugin-loader':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/cordis-plugin-timer':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/timer
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
|
||||
@@ -184,6 +184,17 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
|
||||
const timer = '@deepseek-ai/cordis-plugin-timer'
|
||||
expect(resolveId(timer)).toBeNull()
|
||||
expect(() => resolveId(`${timer}/src/index.ts`)).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/cordis-plugin-hmr')).toThrow(/purity/)
|
||||
const config = clientConfigs(packageDir('extensions/cordis-client-runner'))[0]
|
||||
const deps = config?.deps as { neverBundle: (specifier: string) => boolean }
|
||||
expect(deps.neverBundle(timer)).toBe(false)
|
||||
expect(deps.neverBundle('@deepseek-ai/cordis')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
|
||||
|
||||
@@ -200,7 +200,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
|
||||
sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API',
|
||||
inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API',
|
||||
timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API',
|
||||
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
||||
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
|
||||
|
||||
@@ -261,7 +261,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [
|
||||
* identity to share — the framework itself is a requested module-table row
|
||||
* (external), while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(?:(?:cosmokit|schemastery)(?:\\/|$)|cordis-plugin-timer$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
|
||||
expect: 1,
|
||||
|
||||
Reference in new issue
Block a user