Skip to content

Commit 1bb96d2

Browse files
authored
feat: time-budgeted UI yields, block-cache templates, and MINSERT params (#161)
1 parent 6439a0f commit 1bb96d2

19 files changed

Lines changed: 1090 additions & 199 deletions

packages/common/__tests__/AcCmTaskScheduler.spec.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,37 @@ describe('AcCmTaskScheduler', () => {
4747
expect(complete).toHaveBeenCalledWith(3)
4848
})
4949

50-
it('uses requestAnimationFrame path when window API exists', async () => {
50+
it('uses requestAnimationFrame path when available', async () => {
5151
const scheduler = new AcCmTaskScheduler<number, number>()
52-
const originalWindow = (globalThis as any).window
53-
54-
;(globalThis as any).window = {
55-
requestAnimationFrame: (cb: FrameRequestCallback) => cb(0)
52+
const raf = jest.fn((cb: FrameRequestCallback) => {
53+
cb(0)
54+
return 1
55+
})
56+
const previous = (globalThis as { requestAnimationFrame?: unknown })
57+
.requestAnimationFrame
58+
;(
59+
globalThis as unknown as { requestAnimationFrame: typeof raf }
60+
).requestAnimationFrame = raf
61+
62+
try {
63+
scheduler.addTask(new AddOneTask())
64+
const done = jest.fn()
65+
scheduler.setCompleteCallback(done)
66+
67+
await scheduler.run(10)
68+
69+
expect(raf).toHaveBeenCalled()
70+
expect(done).toHaveBeenCalledWith(11)
71+
} finally {
72+
if (previous == null) {
73+
delete (globalThis as { requestAnimationFrame?: unknown })
74+
.requestAnimationFrame
75+
} else {
76+
;(
77+
globalThis as unknown as { requestAnimationFrame: unknown }
78+
).requestAnimationFrame = previous
79+
}
5680
}
57-
58-
scheduler.addTask(new AddOneTask())
59-
const done = jest.fn()
60-
scheduler.setCompleteCallback(done)
61-
62-
await scheduler.run(10)
63-
64-
expect(done).toHaveBeenCalledWith(11)
65-
;(globalThis as any).window = originalWindow
6681
})
6782

6883
it('handles errors without interrupt and still completes', async () => {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
ACCM_DEFAULT_UI_YIELD_BUDGET_MS,
3+
AcCmUiYieldGate,
4+
accmYieldForPaint,
5+
accmYieldToUi
6+
} from '../src/AcCmYieldToUi'
7+
8+
describe('accmYieldToUi', () => {
9+
afterEach(() => {
10+
jest.restoreAllMocks()
11+
})
12+
13+
it('resolves via a single requestAnimationFrame when available', async () => {
14+
const callbacks: Array<() => void> = []
15+
const raf = jest.fn((cb: () => void) => {
16+
callbacks.push(cb)
17+
return callbacks.length
18+
})
19+
;(
20+
globalThis as unknown as { requestAnimationFrame: typeof raf }
21+
).requestAnimationFrame = raf
22+
23+
const done = accmYieldToUi()
24+
expect(raf).toHaveBeenCalledTimes(1)
25+
callbacks[0]()
26+
await done
27+
})
28+
29+
it('accmYieldForPaint uses double rAF', async () => {
30+
const callbacks: Array<() => void> = []
31+
const raf = jest.fn((cb: () => void) => {
32+
callbacks.push(cb)
33+
return callbacks.length
34+
})
35+
;(
36+
globalThis as unknown as { requestAnimationFrame: typeof raf }
37+
).requestAnimationFrame = raf
38+
39+
const done = accmYieldForPaint()
40+
expect(raf).toHaveBeenCalledTimes(1)
41+
callbacks[0]()
42+
expect(raf).toHaveBeenCalledTimes(2)
43+
callbacks[1]()
44+
await done
45+
})
46+
})
47+
48+
describe('AcCmUiYieldGate', () => {
49+
afterEach(() => {
50+
jest.restoreAllMocks()
51+
})
52+
53+
it('skips yields inside the budget and yields after it elapses', async () => {
54+
let now = 1_000
55+
jest.spyOn(performance, 'now').mockImplementation(() => now)
56+
57+
const yieldFn = jest.fn(() => Promise.resolve())
58+
const gate = new AcCmUiYieldGate(ACCM_DEFAULT_UI_YIELD_BUDGET_MS)
59+
60+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(false)
61+
expect(yieldFn).not.toHaveBeenCalled()
62+
63+
now = 1_000 + ACCM_DEFAULT_UI_YIELD_BUDGET_MS - 1
64+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(false)
65+
66+
now = 1_000 + ACCM_DEFAULT_UI_YIELD_BUDGET_MS
67+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(true)
68+
expect(yieldFn).toHaveBeenCalledTimes(1)
69+
70+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(false)
71+
expect(yieldFn).toHaveBeenCalledTimes(1)
72+
73+
now = 1_000 + ACCM_DEFAULT_UI_YIELD_BUDGET_MS * 2
74+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(true)
75+
expect(yieldFn).toHaveBeenCalledTimes(2)
76+
})
77+
78+
it('mark resets the budget clock without yielding', async () => {
79+
let now = 5_000
80+
jest.spyOn(performance, 'now').mockImplementation(() => now)
81+
82+
const yieldFn = jest.fn(() => Promise.resolve())
83+
const gate = new AcCmUiYieldGate(40)
84+
85+
now = 5_050
86+
gate.mark()
87+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(false)
88+
89+
now = 5_090
90+
await expect(gate.maybeYield(yieldFn)).resolves.toBe(true)
91+
expect(yieldFn).toHaveBeenCalledTimes(1)
92+
})
93+
})

packages/common/src/AcCmTaskScheduler.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { accmYieldToUi } from './AcCmYieldToUi'
2+
13
/**
24
* @fileoverview Task scheduling and execution system for the AutoCAD Common library.
35
*
@@ -172,30 +174,15 @@ export class AcCmTaskScheduler<TInitial, TFinal = TInitial> {
172174
/**
173175
* Schedules a task to be executed asynchronously.
174176
*
175-
* This method uses requestAnimationFrame in browser environments or setTimeout
176-
* in Node.js environments to schedule the task.
177+
* Yields via {@link accmYieldToUi} so the browser can paint between tasks,
178+
* then runs `callback` on the resumed turn.
177179
*
178180
* @param callback - The callback function to schedule
179181
* @returns Promise that resolves with the result of the callback
180182
*/
181-
private scheduleTask<T>(callback: () => T | Promise<T>): Promise<T> {
182-
return new Promise<T>((resolve, reject) => {
183-
const executeCallback = () => {
184-
// Execute the callback and handle the result
185-
Promise.resolve(callback()).then(resolve).catch(reject)
186-
}
187-
188-
if (
189-
typeof window !== 'undefined' &&
190-
typeof window.requestAnimationFrame === 'function'
191-
) {
192-
// Browser environment with requestAnimationFrame
193-
window.requestAnimationFrame(executeCallback)
194-
} else {
195-
// Node.js or fallback to setTimeout
196-
setTimeout(executeCallback, 0)
197-
}
198-
})
183+
private async scheduleTask<T>(callback: () => T | Promise<T>): Promise<T> {
184+
await accmYieldToUi()
185+
return callback()
199186
}
200187

201188
/**
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Default work-slice budget (ms) before a cooperative UI yield during
3+
* main-thread work. Large enough to keep throughput high; small enough that
4+
* spinners and progress UI still update.
5+
*/
6+
export const ACCM_DEFAULT_UI_YIELD_BUDGET_MS = 50
7+
8+
/**
9+
* Yields once to the event loop / next animation frame so the browser can
10+
* paint and handle input. Prefer this inside hot loops (time-gated via
11+
* {@link AcCmUiYieldGate}).
12+
*
13+
* Uses a single `requestAnimationFrame` when available (not a double-rAF),
14+
* falling back to `setTimeout(0)`.
15+
*
16+
* @returns Promise that resolves after one frame (or next timer tick).
17+
*/
18+
export function accmYieldToUi(): Promise<void> {
19+
return new Promise(resolve => {
20+
if (
21+
typeof globalThis !== 'undefined' &&
22+
typeof (globalThis as { requestAnimationFrame?: unknown })
23+
.requestAnimationFrame === 'function'
24+
) {
25+
;(
26+
globalThis as unknown as {
27+
requestAnimationFrame: (cb: () => void) => number
28+
}
29+
).requestAnimationFrame(() => resolve())
30+
} else {
31+
setTimeout(resolve, 0)
32+
}
33+
})
34+
}
35+
36+
/**
37+
* Waits until after at least one paint (double rAF). Use sparingly — e.g. once
38+
* before a long sync stretch so a loading overlay can appear. Do not call this
39+
* per chunk on large files.
40+
*
41+
* @returns Promise that resolves after two animation frames (or one timer tick).
42+
*/
43+
export function accmYieldForPaint(): Promise<void> {
44+
return new Promise(resolve => {
45+
if (
46+
typeof globalThis !== 'undefined' &&
47+
typeof (globalThis as { requestAnimationFrame?: unknown })
48+
.requestAnimationFrame === 'function'
49+
) {
50+
const raf = (
51+
globalThis as unknown as {
52+
requestAnimationFrame: (cb: () => void) => number
53+
}
54+
).requestAnimationFrame
55+
raf(() => raf(() => resolve()))
56+
} else {
57+
setTimeout(resolve, 0)
58+
}
59+
})
60+
}
61+
62+
/**
63+
* Time-budgeted cooperative yields: only awaits {@link accmYieldToUi} when at
64+
* least `budgetMs` of wall time has elapsed since the previous yield completed.
65+
*
66+
* Typical usage: construct one gate per long job, then `await gate.maybeYield()`
67+
* inside each loop iteration.
68+
*/
69+
export class AcCmUiYieldGate {
70+
/**
71+
* High-resolution timestamp (ms) of when the last yield finished, or when the
72+
* gate was constructed / {@link mark}ed.
73+
*/
74+
private _lastYieldCompletedAt: number
75+
76+
/**
77+
* Creates a yield gate.
78+
*
79+
* @param _budgetMs - Minimum wall time between yields, in milliseconds.
80+
* Defaults to {@link ACCM_DEFAULT_UI_YIELD_BUDGET_MS}.
81+
*/
82+
constructor(
83+
private readonly _budgetMs: number = ACCM_DEFAULT_UI_YIELD_BUDGET_MS
84+
) {
85+
this._lastYieldCompletedAt = AcCmUiYieldGate.now()
86+
}
87+
88+
/**
89+
* Minimum wall time between yields, in milliseconds.
90+
*/
91+
get budgetMs(): number {
92+
return this._budgetMs
93+
}
94+
95+
/**
96+
* Yields when at least {@link budgetMs} has elapsed since the last completed
97+
* yield (or since construction / {@link mark}).
98+
*
99+
* @param yieldFn - Async yield implementation. Defaults to {@link accmYieldToUi}.
100+
* @returns Whether a yield actually ran.
101+
*/
102+
async maybeYield(
103+
yieldFn: () => Promise<void> = accmYieldToUi
104+
): Promise<boolean> {
105+
const now = AcCmUiYieldGate.now()
106+
if (now - this._lastYieldCompletedAt < this._budgetMs) {
107+
return false
108+
}
109+
await yieldFn()
110+
this._lastYieldCompletedAt = AcCmUiYieldGate.now()
111+
return true
112+
}
113+
114+
/**
115+
* Marks the timeline without yielding (e.g. after an explicit paint wait).
116+
* Resets the budget clock so the next {@link maybeYield} waits a full
117+
* {@link budgetMs} from this point.
118+
*/
119+
mark(): void {
120+
this._lastYieldCompletedAt = AcCmUiYieldGate.now()
121+
}
122+
123+
/**
124+
* Current high-resolution time in milliseconds (`performance.now` when
125+
* available, otherwise `Date.now`).
126+
*
127+
* @returns Monotonic-ish timestamp in ms suitable for budget comparisons.
128+
*/
129+
private static now(): number {
130+
if (
131+
typeof performance !== 'undefined' &&
132+
typeof performance.now === 'function'
133+
) {
134+
return performance.now()
135+
}
136+
return Date.now()
137+
}
138+
}

packages/common/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export { AcCmTransparency } from './AcCmTransparency'
3434
export { AcCmTransparencyMethod } from './AcCmTransparencyMethod'
3535
export { AcCmTask, AcCmTaskScheduler } from './AcCmTaskScheduler'
3636
export type { AcCmCompleteCallback, AcCmTaskError } from './AcCmTaskScheduler'
37+
export {
38+
ACCM_DEFAULT_UI_YIELD_BUDGET_MS,
39+
AcCmUiYieldGate,
40+
accmYieldForPaint,
41+
accmYieldToUi
42+
} from './AcCmYieldToUi'
3743
export { AcCmLoader, AcCmLoadingManager, DefaultLoadingManager } from './loader'
3844
export type {
3945
AcCmLoaderProgressCallback,

0 commit comments

Comments
 (0)