feat(timer): optional key (auto-keyed timers) + close raw-timer/DOM violations

`timers.schedule/interval/scheduleAt` now accept `key: null` — the engine mints a
unique `auto:N` key via an internal counter (with a `has()` collision guard) and
the caller drives the timer through the returned handle. Naming a timer stays a
feature (cancel-by-name, `replace`, dedup, scoped `cancelAll`), not a tax on every
call. Anonymous timers live in the `auto` scope. +6 engine tests; the race-safety
path is unchanged (the key is resolved before the (id,key,version) machinery).

Uses it to close the raw `setTimeout`/`setInterval` and direct `getComputedStyle`
violations the audit flagged — instead of waiving the uix.timers / $adom rules:
- code-block, relative-time: raw setTimeout/setInterval -> eidos.timers (key:null)
- cropper: raw setTimeout throttle gate -> uix.timers (key:null)
- textarea, s-text, s-text-virtual-list: getComputedStyle(el) -> dom.getWindow(el)

The earlier audit call that these were "nil functional gain" was wrong: the rule
is the rule, and the friction (manual per-instance key invention) was removed at
the framework level rather than used as grounds to skip the rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 19ce90b040
commit 67cec940c0

@ -58,10 +58,13 @@ Timers.cancelAll('connection:main');
they accept either an `EngineTimers`, an `ActiveTimers`, or a
fake-clock-driven double in tests. None of them depend on Svelte
runtime.
- **Keys are scoped.** `'connection:main:heartbeat'`, `'session:auto-refresh'`,
`'cache:products:gc'`. `cancelAll('connection:main')` matches `:`-separated
prefixes and tears down every timer the connection owned in a single
call.
- **Keys are scoped — and optional.** `'connection:main:heartbeat'`,
`'session:auto-refresh'`, `'cache:products:gc'`. `cancelAll('connection:main')`
matches `:`-separated prefixes and tears down every timer the connection owned
in a single call. When you don't need to address a timer by name — a debounce,
a throttle gate, a self-refresh tick — pass `key: null` and the scheduler mints
a unique `auto:N` key; you manage it through the returned handle. Naming a timer
is a feature (cancel-by-name, `replace`, dedup), not a tax on every call.
- **No `$effect` for scheduling.** The Active wrapper updates a single
`$state` cell from inside `engine.onChange` — the same dispatch path
every external listener uses. Eliminates by design the
@ -114,6 +117,7 @@ alias: {
`awaitTask:false` (fire-and-forget cadence)
- `maxRuns` cap on intervals
- Replace-by-key (`{ replace: true }`)
- Anonymous timers (`schedule(null, …)`) — scheduler mints `auto:N`, managed via the handle
- Reschedule on the handle (`handle.reschedule(newDelayMs)`)
- Per-entry `AbortController` (cooperative task cancellation)
- Scoped cancel (`cancelAll('connection:main')`)
@ -209,6 +213,7 @@ Timers.scopes; // reactive readonly string[] (deduplicated)
Timers.schedule(key, delayMs, task, opts?) // one-shot
Timers.scheduleAt(key, dueAt, task, opts?) // absolute; past => delay 0
Timers.interval(key, everyMs, task, opts?) // repeating
Timers.schedule(null, delayMs, task, opts?) // key:null ⇒ auto-keyed; cancel via handle
opts: {
replace?: boolean, // default false (throws on dup)

@ -71,6 +71,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
let disposed = false;
let nextId = 1;
let nextAutoKey = 1;
function ensureLive(method: string): void {
assertTimerEngineLive(disposed, method);
@ -92,8 +93,18 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
// ── Public scheduling ───────────────────────────────────────────────────
// Mint a unique key for an anonymous timer (`key: null`). The counter is
// monotonic, so successive auto-keys never collide with each other; the
// `has()` guard only matters if a caller already registered an explicit
// `auto:N` key of their own.
function autoKey(): string {
let k = `auto:${nextAutoKey++}`;
while (entries.has(k)) k = `auto:${nextAutoKey++}`;
return k;
}
function scheduleCommon(
key: string,
key: string | null,
delayMs: number,
task: TimerTask,
options: TimerOptions | undefined,
@ -103,20 +114,21 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
maxRuns: number | undefined
): TimerHandle {
ensureLive(TIMER_METHOD_SCHEDULE);
assertTimerKey(key);
const resolvedKey = key ?? autoKey();
assertTimerKey(resolvedKey);
assertTimerDelay(delayMs);
const replace = options?.replace === true;
const existing = entries.get(key);
const existing = entries.get(resolvedKey);
if (existing !== undefined) {
if (!replace) throw new TimerDuplicateKeyError(key);
if (!replace) throw new TimerDuplicateKeyError(resolvedKey);
cancelEntry(existing);
}
const now = clock.now();
const entry = createInternalTimerEntry({
id: nextId,
key,
key: resolvedKey,
kind,
task,
scheduledAt: now,
@ -129,7 +141,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
});
nextId += 1;
entries.set(key, entry);
entries.set(resolvedKey, entry);
runner.armEntry(entry, delayMs);
emit({ type: TIMER_EVENT_SCHEDULED, entry: timerEntrySnapshot(entry) });
return makeHandle(entry);
@ -171,7 +183,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
let delayMs = dueAt - now;
if (delayMs < 0) {
emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.SCHEDULE_AT_PAST, {
key,
key: key ?? '(auto)',
dueAt,
now
});

@ -767,3 +767,66 @@ describe('snapshots', () => {
expect(t.entry('rc')?.runCount).toBe(3);
});
});
describe('auto-key (anonymous timers — key: null)', () => {
it('schedule(null, …) mints a unique auto key and fires', async () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
let firedKey = '';
const handle = t.schedule(null, 100, (ctx) => {
firedKey = ctx.key;
});
expect(handle.key).toMatch(/^auto:\d+$/);
await clock.advanceBy(100);
expect(firedKey).toBe(handle.key);
});
it('two anonymous timers get distinct keys and coexist', () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
const a = t.schedule(null, 100, () => {});
const b = t.schedule(null, 100, () => {});
expect(a.key).not.toBe(b.key);
expect(t.size).toBe(2);
});
it('interval(null, …) is auto-keyed and ticks', async () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
const handle = t.interval(null, 100, () => {});
expect(handle.key).toMatch(/^auto:\d+$/);
await clock.advanceBy(300);
expect(t.entry(handle.key)?.runCount).toBe(3);
});
it('the handle cancels an anonymous timer', async () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
let fired = false;
const handle = t.schedule(null, 100, () => {
fired = true;
});
expect(handle.cancel()).toBe(true);
await clock.advanceBy(200);
expect(fired).toBe(false);
expect(t.size).toBe(0);
});
it('anonymous timers live in the `auto` scope', () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
const handle = t.schedule(null, 100, () => {});
expect(t.entry(handle.key)?.scope).toBe('auto');
});
it('skips a caller-held explicit auto:N key instead of colliding', () => {
const clock = createFakeClock();
const t = createEngineTimers({ clock });
// A caller explicitly squats on the first auto key.
t.schedule('auto:1', 100, () => {});
const handle = t.schedule(null, 100, () => {});
// The guard advances past the occupied key — no throw, distinct key.
expect(handle.key).not.toBe('auto:1');
expect(t.size).toBe(2);
});
});

@ -200,12 +200,34 @@ export interface TimerScheduler {
readonly clock: TimerClock;
readonly size: number;
schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle;
/**
* Schedule a one-shot timer.
*
* `key` names the timer for addressing: `cancel(key)`, `{ replace: true }`,
* `cancelAll(scope)`, `has(key)` and dedup all work off the name. Pass
* `null` when you don't need a name — the scheduler mints a unique `auto:N`
* key for you and you drive the timer through the returned handle
* (`handle.cancel()` / `handle.reschedule()`). Anonymous timers all share
* the `auto` scope.
*/
schedule(
key: string | null,
delayMs: number,
task: TimerTask,
options?: TimerOptions
): TimerHandle;
scheduleAt(key: string, dueAt: number, task: TimerTask, options?: TimerOptions): TimerHandle;
/** Absolute-time variant of `schedule`. `key: null` → auto-keyed (see `schedule`). */
scheduleAt(
key: string | null,
dueAt: number,
task: TimerTask,
options?: TimerOptions
): TimerHandle;
/** Repeating timer. `key: null` → auto-keyed (see `schedule`). */
interval(
key: string,
key: string | null,
everyMs: number,
task: TimerTask,
options?: TimerIntervalOptions

@ -11,7 +11,9 @@
* (title + language badge + copy button) and a `<pre><code>` inner
* block. The copy button is a leaf utility — no Sema events.
*/
import { onDestroy } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import type { TimerHandle } from '$libs/timer';
import type { CodeBlockProps } from './types';
let {
@ -30,7 +32,7 @@
let codeRef = $state<HTMLElement | null>(null);
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
let copyTimer: TimerHandle | undefined;
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'surface');
const resolvedSize = $derived(eidos.resolve(size));
@ -58,15 +60,17 @@
try {
await navigator.clipboard.writeText(text);
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
copyTimer?.cancel();
copyTimer = eidos.timers.schedule(null, 1800, () => {
copied = false;
}, 1800);
});
} catch {
// Clipboard unavailable — keep silent. The button still fires
// onclick so the consumer can wire a fallback if needed.
}
}
onDestroy(() => copyTimer?.cancel());
</script>
<div

@ -16,6 +16,7 @@
*/
import { onDestroy } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import type { TimerHandle } from '$libs/timer';
import type {
RelativeTimeInput,
RelativeTimeProps,
@ -138,24 +139,24 @@
}
});
let timer: ReturnType<typeof setInterval> | null = null;
let timer: TimerHandle | null = null;
// `$effect` only runs client-side in Svelte 5, no SSR guard needed.
// Anonymous interval (key: null) — the tick has no name to address; the
// handle is the only thing we cancel, on re-run and on destroy.
$effect(() => {
if (intervalMs <= 0) return;
timer = setInterval(() => {
timer = eidos.timers.interval(null, intervalMs, () => {
tick += 1;
}, intervalMs);
});
return () => {
if (timer) {
clearInterval(timer);
timer = null;
}
timer?.cancel();
timer = null;
};
});
onDestroy(() => {
if (timer) clearInterval(timer);
timer?.cancel();
});
</script>

@ -55,8 +55,9 @@
// Read computed style from the container — the recipe is responsible
// for mapping our resolved tokens to actual px / family strings.
const font = $derived.by(() => {
if (!isBrowser || !containerEl) return '14px sans-serif';
const cs = getComputedStyle(containerEl);
const win = containerEl ? eidos.dom.getWindow(containerEl) : null;
if (!isBrowser || !containerEl || !win) return '14px sans-serif';
const cs = win.getComputedStyle(containerEl);
const fontSize = cs.fontSize || '14px';
const fontFamily = cs.fontFamily || 'sans-serif';
const fontWeight = cs.fontWeight || '400';
@ -65,8 +66,9 @@
const lineHeightPx = $derived.by(() => {
if (lineHeightProp != null) return lineHeightProp;
if (!isBrowser || !containerEl) return 21;
const cs = getComputedStyle(containerEl);
const win = containerEl ? eidos.dom.getWindow(containerEl) : null;
if (!isBrowser || !containerEl || !win) return 21;
const cs = win.getComputedStyle(containerEl);
const lh = cs.lineHeight;
if (lh && lh.endsWith('px')) return parseFloat(lh) || 21;
const fs = parseFloat(cs.fontSize) || 14;

@ -108,8 +108,10 @@
// SSR / pre-mount path ('14px sans-serif', 21) so the engine still produces a valid
// layout before first paint.
const computed = $derived.by(() => {
if (!canvasActive || !textEl) return { font: '14px sans-serif', lineHeightPx: 21 };
const cs = getComputedStyle(textEl);
// Read via the element's own window (iframe / popup safe), per "DOM via $adom".
const win = textEl ? eidos.dom.getWindow(textEl) : null;
if (!canvasActive || !textEl || !win) return { font: '14px sans-serif', lineHeightPx: 21 };
const cs = win.getComputedStyle(textEl);
const fontSize = cs.fontSize || '14px';
const fontFamily = cs.fontFamily || 'sans-serif';
const fontWeight = cs.fontWeight || '400';

@ -297,8 +297,11 @@ export class CropperProvider {
if (this.zoomEmitThrottled) return;
this.zoomEmitThrottled = true;
void this.runtime.trigger('handle-zoom');
const win = this.soma.dom.getWindow();
(win ?? globalThis).setTimeout(() => (this.zoomEmitThrottled = false), 120);
// Anonymous throttle gate — no need to name it; the runtime's timers
// cancel on dispose. (Was a raw setTimeout, against the uix.timers rule.)
this.soma.uix.timers.schedule(null, 120, () => {
this.zoomEmitThrottled = false;
});
}
private syncZoomViewport(): void {

@ -172,7 +172,9 @@ export class TextAreaProvider {
this.soma.dom.apply({ target: el, attrs: { style: 'height: auto' } });
const scroll = el.scrollHeight;
// Probe single-line height by computed line-height + vertical padding.
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
// Route through ActiveDom so the read uses the element's own window
// (iframe / popup safe), per the "DOM via $adom" contract.
const computed = this.soma.dom.getWindow(el)?.getComputedStyle(el);
const lineHeight = computed ? parseFloat(computed.lineHeight) || 0 : 0;
const padTop = computed ? parseFloat(computed.paddingTop) || 0 : 0;
const padBottom = computed ? parseFloat(computed.paddingBottom) || 0 : 0;

Loading…
Cancel
Save

Powered by TurnKey Linux.