feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays

Add `raf(callback, node?): ActiveDomFrameCleanup` to the ActiveDom surface —
a one-shot animation frame that returns an idempotent **disposer** (the same
`() => void` shape as `listen` / `observe*`), so an `$effect` can
`return dom.raf(...)` and Svelte cancels the pending frame on teardown. It
wraps the existing `requestFrame` / `cancelFrame` (which already resolve the
instance's `targetWindow` — iframe / popup / happy-dom safe), adding no new
scheduling logic. New `ActiveDomFrameCleanup` type exported from the barrel;
`raf` also implemented on the disabled-dom stub (throws, like `requestFrame`).

Why: the doctrine is "all DOM activity via ActiveDom". `listen`/`observe*`
already returned disposers; the animation frame was the gap — `requestFrame`
exposes a raw numeric handle (per-call bookkeeping + leak risk), and layout
components were falling back to the GLOBAL `requestAnimationFrame`, which
targets the wrong window in iframe/popup contexts (the exact bug getWindow/
getDocument fix elsewhere). `raf` closes it.

Migrated the 3 raw `requestAnimationFrame` sites the Words audit surfaced —
words-block-gutter (reposition), words-bubble-menu + words-slash-menu
(overlay position) — to `dom.raf(...)`. Bubble/slash keep their `tick()`
fallback for no-rAF environments.

Documented the decision + rationale as a dated Backlog entry at the end of
`src/arts/adom/README.md` (and listed `raf` in the API + imperative-actions
sections). Notes the kept distinction: `raf` is for layout frames, NOT the
`$timer` lifecycle scheduler; low-level requestFrame/cancelFrame stays for
consumers that already hold the handle (drawer/slider/splitter/floating/
focus-scope). Remaining raw rAF in other eidos components (tabs-indicator…)
left for when those are touched — flagged in the backlog.

Gates: npm run check 1 error (pre-existing grafito, not adom/Words) · soma
words + adom 479/479 · prettier clean · browser smoke: gutter repositions,
bubble menu positions, no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 1186d794cd
commit 2d60703ab4

@ -80,8 +80,9 @@ Runtime reactivo de DOM:
- `query(...)`, `elementFromPoint(...)`, `activeElement(...)` para consultas
contra el documento propietario
- `focus(...)`, `scrollIntoView(...)`, `scrollTo(...)`, `scrollWindowTo(...)`,
`scrollWindowBy(...)`, `requestFrame(...)` para acciones imperativas que no
deben depender del `window/document` global
`scrollWindowBy(...)`, `requestFrame(...)` / `raf(...)` para acciones
imperativas que no deben depender del `window/document` global (`raf`
devuelve un disposer — ver Backlog)
- `BodyScrollLock` como helper global de body scroll lock, sin bloquear eventos de puntero
- `DOMContext` como helper scoped para `Document` / `ShadowRoot`
- `RovingFocusGroup` como helper runtime para navegación compuesta por teclado
@ -162,6 +163,7 @@ export interface ActiveDom {
node?: Element | Window | Node | Document | null
): number;
cancelFrame(handle: number, node?: Element | Window | Node | Document | null): void;
raf(callback: FrameRequestCallback, node?: Element | Window | Node | Document | null): () => void;
scrollWindowBy(
arg: ScrollToOptions | number,
y?: number,
@ -353,3 +355,68 @@ Lo que queda para fases posteriores, si de verdad hace falta:
La regla importante por ahora es simple:
> `ActiveDom` es el servicio reactivo de DOM de la app; `$libs/dom` es su base pura.
---
## Backlog / Decisiones de evolución
Registro de cambios de superficie posteriores a la fase fundacional. Cada
entrada documenta **qué** se añadió y, sobre todo, **por qué** — para que la
decisión no se pierda y futuros consumidores entiendan el patrón canónico.
### 2026-06-02 — `dom.raf(callback, node?)`: frame de animación con disposer
**Qué.** Nuevo método en la superficie `ActiveDom`:
```ts
raf(callback: FrameRequestCallback, node?: …): ActiveDomFrameCleanup
```
Agenda un `requestAnimationFrame` de una sola pasada contra la ventana que
posee `node` (o el `targetWindow` de la instancia) y devuelve un **disposer**
`() => void` idempotente que cancela el frame pendiente. Tipo
`ActiveDomFrameCleanup` exportado junto a `ActiveDomListenerCleanup` /
`ActiveDomObserverCleanup`.
**Por qué.** La doctrina del proyecto es _"toda actividad de DOM pasa por
ActiveDom"_ (sin `window.addEventListener`, sin `new ResizeObserver`, sin
`document.querySelector` crudos en componentes). `listen` y `observe*` ya
cumplían esa regla devolviendo un **disposer** — la forma exacta que un
`$effect` de Svelte puede `return` para que el framework limpie en el
teardown. Pero el frame de animación quedaba fuera:
- `ActiveDom` exponía `requestFrame` / `cancelFrame` (resuelven bien la
ventana iframe/popup), pero devuelven el **handle numérico crudo**. Eso
obliga a cada consumidor a guardar el número, gestionar el guard de
"pendiente" y cancelar a mano en el teardown — bookkeeping repetido y
propenso a fugas.
- Los componentes que necesitaban un frame de _layout_ (medir y reposicionar
un overlay) caían en `requestAnimationFrame` / `cancelAnimationFrame`
**globales**. Eso apunta al `window` global, que es **incorrecto** en
contextos iframe / popup / happy-dom — el mismo bug que `getWindow` /
`getDocument` resuelven para el resto de la API.
`raf` cierra ese hueco: envuelve `requestFrame`/`cancelFrame` (cero lógica
nueva de scheduling) y devuelve el disposer simétrico. Un consumidor escribe
ahora `return dom.raf(reposition, node)` y Svelte cancela el frame solo.
**Detonante.** La auditoría del editor **Words** (2026-06-02) encontró 3
`requestAnimationFrame` crudos —`words-block-gutter`, `words-bubble-menu`,
`words-slash-menu`— que reposicionaban overlays apuntando al `window` global.
Migrados a `dom.raf(...)`. El precedente de rotura ya existía en otros sitios
del eidos (p. ej. `tabs-indicator`), así que esto canoniza el patrón, no es
un parche puntual de Words.
**Distinción que se mantiene.** `dom.raf` es para frames de **layout**
(medir/posicionar, ligados al ciclo de pintado). NO sustituye al servicio
`$timer` (`App.timers`), que es para _lifecycle timers con clave_
(debounce / heartbeat / intervalos / one-shots con delay). Son dominios
distintos: el timer no tiene primitiva de frame y `raf` no tiene clave ni
cancelación por scope. El `requestFrame`/`cancelFrame` de bajo nivel se
conserva para los pocos consumidores que ya guardan el handle (drawer,
slider, splitter, floating, focus-scope…) y aún no migran.
**Pendiente (no bloqueante).** Migrar los `requestAnimationFrame` crudos
restantes del eidos (`tabs-indicator`, etc.) a `dom.raf` cuando se toquen
esos componentes; no se hizo en barrido para no ensanchar el diff de la
auditoría de Words.

@ -73,6 +73,7 @@ export interface ActiveDomRemoveNodeOptions {
export type ActiveDomListenerCleanup = () => void;
export type ActiveDomObserverCleanup = () => void;
export type ActiveDomFrameCleanup = () => void;
export interface ActiveDom {
breakpoints: Active<Breakpoints>;
@ -160,6 +161,23 @@ export interface ActiveDom {
node?: Element | Window | Node | Document | null
): number;
cancelFrame(handle: number, node?: Element | Window | Node | Document | null): void;
/**
* Schedule a one-shot animation frame and get back a **disposer** —
* the same `() => void` shape as `listen` / `observe*`, so an `$effect`
* can `return dom.raf(...)` directly and Svelte cancels the pending
* frame on teardown. Wraps `requestFrame` / `cancelFrame` (so it
* resolves the instance's `targetWindow` — iframe / popup / happy-dom
* safe). The disposer is idempotent: calling it after the frame has
* already fired, or twice, is a no-op.
*
* Prefer this over a raw `requestAnimationFrame` in components: the bare
* call targets the global `window` (wrong in iframe / popup contexts)
* and leaks the cancel-on-teardown bookkeeping into every call site.
*/
raf(
callback: FrameRequestCallback,
node?: Element | Window | Node | Document | null
): ActiveDomFrameCleanup;
scrollWindowBy(
arg: ScrollToOptions | number,
y?: number,
@ -453,6 +471,24 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
cancelFrame(handle: number, node?: Element | Window | Node | Document | null): void {
resolveWindow(node).cancelAnimationFrame(handle);
},
raf(
callback: FrameRequestCallback,
node?: Element | Window | Node | Document | null
): ActiveDomFrameCleanup {
const win = resolveWindow(node);
let handle: number | null = win.requestAnimationFrame((t) => {
// The frame has fired; null the handle so the returned disposer
// becomes a no-op (cancelling an already-run frame is harmless,
// but this also makes double-dispose explicit and cheap).
handle = null;
callback(t);
});
return () => {
if (handle === null) return;
win.cancelAnimationFrame(handle);
handle = null;
};
},
scrollWindowBy(
arg: ScrollToOptions | number,
y?: number,

@ -35,7 +35,9 @@
export { createActiveDom } from './active-dom.svelte.js';
export type {
ActiveDom,
ActiveDomFrameCleanup,
ActiveDomListenerCleanup,
ActiveDomObserverCleanup,
ActiveDomNodeHost,
ActiveDomProps,
ActiveDomRemoveNodeOptions,

@ -277,6 +277,9 @@ function createDisabledActiveDom(): ActiveDom {
return disabled();
},
cancelFrame() {},
raf() {
return disabled();
},
scrollWindowBy() {},
scrollWindowTo() {},
getDocument() {
@ -421,8 +424,7 @@ class ActiveUixImpl implements ActiveUix {
// Some AT engines won't re-announce when the same string lands in
// the same region. Tiny zero-width-space toggle works around it.
const zwsp = '​';
region.textContent =
region.textContent === message ? message + zwsp : message;
region.textContent = region.textContent === message ? message + zwsp : message;
if (timeout > 0 && message.length > 0) {
this.timers.schedule(
`uix:announce:${priority}`,

@ -50,7 +50,9 @@
// grip) makes it a forgiving target and keeps the grip clear of the line.
let inGutter = $state(false);
let raf = 0;
// Disposer for the pending pointer-move frame (null when none scheduled).
// Doubles as the throttle guard: non-null ⇒ a frame is already queued.
let cancelFrame: (() => void) | null = null;
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
@ -133,14 +135,16 @@
function onPointerMove(event: Event) {
if (menuOpen) return;
if (raf) return;
if (cancelFrame) return; // a frame is already pending — throttle to 1/frame
const e = event as PointerEvent;
const x = e.clientX;
const y = e.clientY;
raf = requestAnimationFrame(() => {
raf = 0;
// `dom.raf` resolves the content's window (iframe / popup safe) and
// returns a disposer; we keep it so teardown can cancel a pending frame.
cancelFrame = dom.raf(() => {
cancelFrame = null;
locate(x, y);
});
}, content);
}
function onPointerLeave() {
@ -158,14 +162,17 @@
];
return () => {
for (const dispose of disposers) dispose?.();
if (raf) cancelAnimationFrame(raf);
cancelFrame?.();
cancelFrame = null;
};
});
// Re-measure after edits change the block layout.
// Re-measure after edits change the block layout. The one-shot frame is
// fire-and-forget (reposition is idempotent + cheap); we still route it
// through `dom.raf` so it targets the content's window, not the global.
$effect(() => {
void api.document;
if (show || menuOpen) requestAnimationFrame(reposition);
if (show || menuOpen) dom.raf(reposition, content);
});
// The soma menu owns close-on-select (closeOnSelect defaults true); the

@ -32,26 +32,37 @@
return props;
});
let frame: number | null = null;
// Disposer for the pending position frame. Routed through `soma.dom.raf`
// (the canonical ActiveDom frame scheduler — iframe / popup / happy-dom
// safe) instead of a bare `requestAnimationFrame`. `null` when no frame is
// queued; the SSR / no-rAF fallback uses `tick()` and leaves this null.
let cancelFrame: (() => void) | null = null;
$effect(() => {
state.open;
state.provider.selection;
state.provider.html;
schedulePosition();
return () => {
cancelFrame?.();
cancelFrame = null;
};
});
function schedulePosition() {
if (frame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(frame);
frame = null;
}
cancelFrame?.();
cancelFrame = null;
if (typeof requestAnimationFrame === 'function') {
frame = requestAnimationFrame(() => {
frame = null;
// `ref` (the menu element) anchors the window resolution to the right
// document in iframe / popup contexts; falls back to the ActiveDom's
// own targetWindow when the ref isn't mounted yet.
const dom = state.provider.soma.dom;
const win = dom.getWindow(ref);
if (typeof win.requestAnimationFrame === 'function') {
cancelFrame = dom.raf(() => {
cancelFrame = null;
state.updatePosition();
});
}, ref);
return;
}

@ -30,13 +30,21 @@
return props;
});
let frame: number | null = null;
// Disposer for the pending position frame. Routed through `soma.dom.raf`
// (the canonical ActiveDom frame scheduler — iframe / popup / happy-dom
// safe) instead of a bare `requestAnimationFrame`. `null` when no frame is
// queued; the SSR / no-rAF fallback uses `tick()` and leaves this null.
let cancelFrame: (() => void) | null = null;
$effect(() => {
state.open;
state.provider.selection;
state.provider.html;
schedulePosition();
return () => {
cancelFrame?.();
cancelFrame = null;
};
});
$effect(() => {
@ -44,16 +52,19 @@
});
function schedulePosition() {
if (frame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(frame);
frame = null;
}
cancelFrame?.();
cancelFrame = null;
if (typeof requestAnimationFrame === 'function') {
frame = requestAnimationFrame(() => {
frame = null;
// `ref` (the menu element) anchors the window resolution to the right
// document in iframe / popup contexts; falls back to the ActiveDom's
// own targetWindow when the ref isn't mounted yet.
const dom = state.provider.soma.dom;
const win = dom.getWindow(ref);
if (typeof win.requestAnimationFrame === 'function') {
cancelFrame = dom.raf(() => {
cancelFrame = null;
state.updatePosition();
});
}, ref);
return;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.