From 67cec940c036e018cbe7bb57486867a711103720 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 13 Jun 2026 19:39:51 +0200 Subject: [PATCH] feat(timer): optional key (auto-keyed timers) + close raw-timer/DOM violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src/arts/timer/README.md | 13 ++-- src/arts/timer/engine-timers.ts | 26 +++++--- src/arts/timer/test/engine-timers.test.ts | 63 +++++++++++++++++++ src/libs/timer/types.ts | 28 ++++++++- .../components/code-block/code-block.svelte | 12 ++-- .../relative-time/relative-time.svelte | 17 ++--- .../s-text-virtual-list.svelte | 10 +-- src/uix/eidos/components/s-text/s-text.svelte | 6 +- .../cropper/cropper-provider.svelte.ts | 7 ++- .../textarea/textarea-provider.svelte.ts | 4 +- 10 files changed, 151 insertions(+), 35 deletions(-) diff --git a/src/arts/timer/README.md b/src/arts/timer/README.md index 3e59a05ca..7c37401ea 100644 --- a/src/arts/timer/README.md +++ b/src/arts/timer/README.md @@ -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) diff --git a/src/arts/timer/engine-timers.ts b/src/arts/timer/engine-timers.ts index 2374c07e9..f59fe1f06 100644 --- a/src/arts/timer/engine-timers.ts +++ b/src/arts/timer/engine-timers.ts @@ -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 }); diff --git a/src/arts/timer/test/engine-timers.test.ts b/src/arts/timer/test/engine-timers.test.ts index 6b516def6..cf21eac2f 100644 --- a/src/arts/timer/test/engine-timers.test.ts +++ b/src/arts/timer/test/engine-timers.test.ts @@ -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); + }); +}); diff --git a/src/libs/timer/types.ts b/src/libs/timer/types.ts index aee11f0f0..220e91f13 100644 --- a/src/libs/timer/types.ts +++ b/src/libs/timer/types.ts @@ -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 diff --git a/src/uix/eidos/components/code-block/code-block.svelte b/src/uix/eidos/components/code-block/code-block.svelte index 6fc62f27b..e9330b57e 100644 --- a/src/uix/eidos/components/code-block/code-block.svelte +++ b/src/uix/eidos/components/code-block/code-block.svelte @@ -11,7 +11,9 @@ * (title + language badge + copy button) and a `
` 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(null);
 	let copied = $state(false);
-	let copyTimer: ReturnType | 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());
 
 
 
| 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(); }); diff --git a/src/uix/eidos/components/s-text-virtual-list/s-text-virtual-list.svelte b/src/uix/eidos/components/s-text-virtual-list/s-text-virtual-list.svelte index 736698520..2e3084e80 100644 --- a/src/uix/eidos/components/s-text-virtual-list/s-text-virtual-list.svelte +++ b/src/uix/eidos/components/s-text-virtual-list/s-text-virtual-list.svelte @@ -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; diff --git a/src/uix/eidos/components/s-text/s-text.svelte b/src/uix/eidos/components/s-text/s-text.svelte index d1bb5d8fb..d74d4f25a 100644 --- a/src/uix/eidos/components/s-text/s-text.svelte +++ b/src/uix/eidos/components/s-text/s-text.svelte @@ -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'; diff --git a/src/uix/soma/components/cropper/cropper-provider.svelte.ts b/src/uix/soma/components/cropper/cropper-provider.svelte.ts index decda275a..327138ec1 100644 --- a/src/uix/soma/components/cropper/cropper-provider.svelte.ts +++ b/src/uix/soma/components/cropper/cropper-provider.svelte.ts @@ -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 { diff --git a/src/uix/soma/components/textarea/textarea-provider.svelte.ts b/src/uix/soma/components/textarea/textarea-provider.svelte.ts index 2e61d3f6c..f63e21740 100644 --- a/src/uix/soma/components/textarea/textarea-provider.svelte.ts +++ b/src/uix/soma/components/textarea/textarea-provider.svelte.ts @@ -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;