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

@ -71,6 +71,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
let disposed = false; let disposed = false;
let nextId = 1; let nextId = 1;
let nextAutoKey = 1;
function ensureLive(method: string): void { function ensureLive(method: string): void {
assertTimerEngineLive(disposed, method); assertTimerEngineLive(disposed, method);
@ -92,8 +93,18 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
// ── Public scheduling ─────────────────────────────────────────────────── // ── 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( function scheduleCommon(
key: string, key: string | null,
delayMs: number, delayMs: number,
task: TimerTask, task: TimerTask,
options: TimerOptions | undefined, options: TimerOptions | undefined,
@ -103,20 +114,21 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
maxRuns: number | undefined maxRuns: number | undefined
): TimerHandle { ): TimerHandle {
ensureLive(TIMER_METHOD_SCHEDULE); ensureLive(TIMER_METHOD_SCHEDULE);
assertTimerKey(key); const resolvedKey = key ?? autoKey();
assertTimerKey(resolvedKey);
assertTimerDelay(delayMs); assertTimerDelay(delayMs);
const replace = options?.replace === true; const replace = options?.replace === true;
const existing = entries.get(key); const existing = entries.get(resolvedKey);
if (existing !== undefined) { if (existing !== undefined) {
if (!replace) throw new TimerDuplicateKeyError(key); if (!replace) throw new TimerDuplicateKeyError(resolvedKey);
cancelEntry(existing); cancelEntry(existing);
} }
const now = clock.now(); const now = clock.now();
const entry = createInternalTimerEntry({ const entry = createInternalTimerEntry({
id: nextId, id: nextId,
key, key: resolvedKey,
kind, kind,
task, task,
scheduledAt: now, scheduledAt: now,
@ -129,7 +141,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
}); });
nextId += 1; nextId += 1;
entries.set(key, entry); entries.set(resolvedKey, entry);
runner.armEntry(entry, delayMs); runner.armEntry(entry, delayMs);
emit({ type: TIMER_EVENT_SCHEDULED, entry: timerEntrySnapshot(entry) }); emit({ type: TIMER_EVENT_SCHEDULED, entry: timerEntrySnapshot(entry) });
return makeHandle(entry); return makeHandle(entry);
@ -171,7 +183,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
let delayMs = dueAt - now; let delayMs = dueAt - now;
if (delayMs < 0) { if (delayMs < 0) {
emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.SCHEDULE_AT_PAST, { emitTimerDiagnostic(diagnostics, TIMER_DIAGNOSTIC_EVENTS.SCHEDULE_AT_PAST, {
key, key: key ?? '(auto)',
dueAt, dueAt,
now now
}); });

@ -767,3 +767,66 @@ describe('snapshots', () => {
expect(t.entry('rc')?.runCount).toBe(3); 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 clock: TimerClock;
readonly size: number; 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( interval(
key: string, key: string | null,
everyMs: number, everyMs: number,
task: TimerTask, task: TimerTask,
options?: TimerIntervalOptions options?: TimerIntervalOptions

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

@ -16,6 +16,7 @@
*/ */
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { ActiveEidos } from '$uix/eidos'; import { ActiveEidos } from '$uix/eidos';
import type { TimerHandle } from '$libs/timer';
import type { import type {
RelativeTimeInput, RelativeTimeInput,
RelativeTimeProps, 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. // `$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(() => { $effect(() => {
if (intervalMs <= 0) return; if (intervalMs <= 0) return;
timer = setInterval(() => { timer = eidos.timers.interval(null, intervalMs, () => {
tick += 1; tick += 1;
}, intervalMs); });
return () => { return () => {
if (timer) { timer?.cancel();
clearInterval(timer); timer = null;
timer = null;
}
}; };
}); });
onDestroy(() => { onDestroy(() => {
if (timer) clearInterval(timer); timer?.cancel();
}); });
</script> </script>

@ -55,8 +55,9 @@
// Read computed style from the container — the recipe is responsible // Read computed style from the container — the recipe is responsible
// for mapping our resolved tokens to actual px / family strings. // for mapping our resolved tokens to actual px / family strings.
const font = $derived.by(() => { const font = $derived.by(() => {
if (!isBrowser || !containerEl) return '14px sans-serif'; const win = containerEl ? eidos.dom.getWindow(containerEl) : null;
const cs = getComputedStyle(containerEl); if (!isBrowser || !containerEl || !win) return '14px sans-serif';
const cs = win.getComputedStyle(containerEl);
const fontSize = cs.fontSize || '14px'; const fontSize = cs.fontSize || '14px';
const fontFamily = cs.fontFamily || 'sans-serif'; const fontFamily = cs.fontFamily || 'sans-serif';
const fontWeight = cs.fontWeight || '400'; const fontWeight = cs.fontWeight || '400';
@ -65,8 +66,9 @@
const lineHeightPx = $derived.by(() => { const lineHeightPx = $derived.by(() => {
if (lineHeightProp != null) return lineHeightProp; if (lineHeightProp != null) return lineHeightProp;
if (!isBrowser || !containerEl) return 21; const win = containerEl ? eidos.dom.getWindow(containerEl) : null;
const cs = getComputedStyle(containerEl); if (!isBrowser || !containerEl || !win) return 21;
const cs = win.getComputedStyle(containerEl);
const lh = cs.lineHeight; const lh = cs.lineHeight;
if (lh && lh.endsWith('px')) return parseFloat(lh) || 21; if (lh && lh.endsWith('px')) return parseFloat(lh) || 21;
const fs = parseFloat(cs.fontSize) || 14; 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 // SSR / pre-mount path ('14px sans-serif', 21) so the engine still produces a valid
// layout before first paint. // layout before first paint.
const computed = $derived.by(() => { const computed = $derived.by(() => {
if (!canvasActive || !textEl) return { font: '14px sans-serif', lineHeightPx: 21 }; // Read via the element's own window (iframe / popup safe), per "DOM via $adom".
const cs = getComputedStyle(textEl); 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 fontSize = cs.fontSize || '14px';
const fontFamily = cs.fontFamily || 'sans-serif'; const fontFamily = cs.fontFamily || 'sans-serif';
const fontWeight = cs.fontWeight || '400'; const fontWeight = cs.fontWeight || '400';

@ -297,8 +297,11 @@ export class CropperProvider {
if (this.zoomEmitThrottled) return; if (this.zoomEmitThrottled) return;
this.zoomEmitThrottled = true; this.zoomEmitThrottled = true;
void this.runtime.trigger('handle-zoom'); void this.runtime.trigger('handle-zoom');
const win = this.soma.dom.getWindow(); // Anonymous throttle gate — no need to name it; the runtime's timers
(win ?? globalThis).setTimeout(() => (this.zoomEmitThrottled = false), 120); // 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 { private syncZoomViewport(): void {

@ -172,7 +172,9 @@ export class TextAreaProvider {
this.soma.dom.apply({ target: el, attrs: { style: 'height: auto' } }); this.soma.dom.apply({ target: el, attrs: { style: 'height: auto' } });
const scroll = el.scrollHeight; const scroll = el.scrollHeight;
// Probe single-line height by computed line-height + vertical padding. // 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 lineHeight = computed ? parseFloat(computed.lineHeight) || 0 : 0;
const padTop = computed ? parseFloat(computed.paddingTop) || 0 : 0; const padTop = computed ? parseFloat(computed.paddingTop) || 0 : 0;
const padBottom = computed ? parseFloat(computed.paddingBottom) || 0 : 0; const padBottom = computed ? parseFloat(computed.paddingBottom) || 0 : 0;

Loading…
Cancel
Save

Powered by TurnKey Linux.