From 2d60703ab4fcfed168c955f8639609b2313d4f5c Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 2 Jun 2026 13:56:38 +0200 Subject: [PATCH] feat(adom): canonize dom.raf() frame scheduler + migrate Words overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/arts/adom/README.md | 71 ++++++++++++++++++- src/arts/adom/active-dom.svelte.ts | 36 ++++++++++ src/arts/adom/index.ts | 2 + src/uix/active-uix/active-uix.svelte.ts | 6 +- .../words/words-block-gutter.svelte | 23 +++--- .../words/components/words-bubble-menu.svelte | 29 +++++--- .../words/components/words-slash-menu.svelte | 29 +++++--- 7 files changed, 166 insertions(+), 30 deletions(-) diff --git a/src/arts/adom/README.md b/src/arts/adom/README.md index 6e962a59f..1111adaab 100644 --- a/src/arts/adom/README.md +++ b/src/arts/adom/README.md @@ -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. diff --git a/src/arts/adom/active-dom.svelte.ts b/src/arts/adom/active-dom.svelte.ts index 05eb8b39e..544a23fac 100644 --- a/src/arts/adom/active-dom.svelte.ts +++ b/src/arts/adom/active-dom.svelte.ts @@ -73,6 +73,7 @@ export interface ActiveDomRemoveNodeOptions { export type ActiveDomListenerCleanup = () => void; export type ActiveDomObserverCleanup = () => void; +export type ActiveDomFrameCleanup = () => void; export interface ActiveDom { breakpoints: Active; @@ -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, diff --git a/src/arts/adom/index.ts b/src/arts/adom/index.ts index f285ac9a5..2323f566f 100644 --- a/src/arts/adom/index.ts +++ b/src/arts/adom/index.ts @@ -35,7 +35,9 @@ export { createActiveDom } from './active-dom.svelte.js'; export type { ActiveDom, + ActiveDomFrameCleanup, ActiveDomListenerCleanup, + ActiveDomObserverCleanup, ActiveDomNodeHost, ActiveDomProps, ActiveDomRemoveNodeOptions, diff --git a/src/uix/active-uix/active-uix.svelte.ts b/src/uix/active-uix/active-uix.svelte.ts index b3811b425..2f307fa68 100644 --- a/src/uix/active-uix/active-uix.svelte.ts +++ b/src/uix/active-uix/active-uix.svelte.ts @@ -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}`, diff --git a/src/uix/eidos/components/words/words-block-gutter.svelte b/src/uix/eidos/components/words/words-block-gutter.svelte index 536b86ddb..1fb56566f 100644 --- a/src/uix/eidos/components/words/words-block-gutter.svelte +++ b/src/uix/eidos/components/words/words-block-gutter.svelte @@ -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 diff --git a/src/uix/soma/components/words/components/words-bubble-menu.svelte b/src/uix/soma/components/words/components/words-bubble-menu.svelte index 7e2208e9a..02493e6f0 100644 --- a/src/uix/soma/components/words/components/words-bubble-menu.svelte +++ b/src/uix/soma/components/words/components/words-bubble-menu.svelte @@ -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; } diff --git a/src/uix/soma/components/words/components/words-slash-menu.svelte b/src/uix/soma/components/words/components/words-slash-menu.svelte index e169f985f..d9fd5aeaf 100644 --- a/src/uix/soma/components/words/components/words-slash-menu.svelte +++ b/src/uix/soma/components/words/components/words-slash-menu.svelte @@ -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; }