From 99a7882a641e4b8cc8e0bdf5f30ecd27a93a0cbe Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 29 Jun 2026 15:22:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(adom):=20dom.measure=20=E2=80=94=20coalesc?= =?UTF-8?q?ed=20post-layout=20read=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ActiveDom.measure(read, node?)` — schedule a layout-forcing read (getBoundingClientRect / getComputedStyle / offset* / scroll*) in a coalesced animation frame instead of synchronously. All reads queued in one turn run together in a single rAF per window, so a read never forces a synchronous reflow mid-write-turn — the cause of "[Violation] Forced reflow while executing JavaScript". Returns the same idempotent disposer shape as `raf`; `dispose()` cancels pending frames. Reads-only by design (writes sequence through `apply`). The sanctioned home for layout reads in components: it owns *when* the read runs (post-turn, coalesced), not *which* element. - active-dom.svelte.ts — measure() + per-window queue + dispose cleanup - test/active-dom.test.ts — 6 tests (defer / coalesce / dispose / throw-isolation) - README.md — API + dated decision entry Co-Authored-By: Claude Opus 4.8 --- src/arts/adom/README.md | 28 +++++++ src/arts/adom/active-dom.svelte.ts | 67 +++++++++++++++++ src/arts/adom/test/active-dom.test.ts | 101 +++++++++++++++++++++++++- 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/src/arts/adom/README.md b/src/arts/adom/README.md index b1fa4d97d..f499e3650 100644 --- a/src/arts/adom/README.md +++ b/src/arts/adom/README.md @@ -157,6 +157,7 @@ export interface ActiveDom { ): number; cancelFrame(handle: number, node?: Element | Window | Node | Document | null): void; raf(callback: FrameRequestCallback, node?: Element | Window | Node | Document | null): () => void; + measure(read: () => void, node?: Element | Window | Node | Document | null): () => void; scrollWindowBy( arg: ScrollToOptions | number, y?: number, @@ -357,6 +358,33 @@ 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-29 — `dom.measure(read, node?)`: lectura de layout coalescida post-layout + +**Qué.** Nuevo método en `ActiveDom`: + +```ts +measure(read: () => void, node?: …): ActiveDomFrameCleanup +``` + +Agenda una lectura que fuerza layout (`getBoundingClientRect`, `getComputedStyle`, +`offset*`, `scroll*`) en un **rAF coalescido por ventana** en vez de síncronamente. +Todas las lecturas encoladas en un mismo turno corren juntas en un único frame, así +una lectura nunca fuerza un reflow EN MEDIO de un turno de escritura — el origen de +`[Violation] Forced reflow while executing JavaScript`. Devuelve el mismo disposer +idempotente que `raf`; `dispose()` cancela los frames pendientes. + +**Por qué.** El framework gobierna las ESCRITURAS (`apply`) y el TIEMPO +(`uix.timers`) pero no tenía superficie para el *timing* de las LECTURAS de layout, +que solo son seguras post-layout. `measure` es el hogar sancionado: posee *cuándo* +corre la lectura (post-turno, coalescida), no *qué* elemento. **Solo lecturas** — las +escrituras ya secuencian por `apply` + el runtime; un segundo eje `mutate`/dos-fases +duplicaría lo que `apply` + Svelte ya hacen (sobre-ingeniería descartada). + +**Disciplina.** Está prohibido leer layout síncronamente justo tras una escritura de +DOM/estilo. Un `raf` que haga la lectura cumple igual; `measure` añade el coalescing +y la intención semántica. Patrón canónico ya seguido por `Tabs.Indicator` (mide vía +rAF diferido + guard de coalescing). + ### 2026-06-02 — `dom.raf(callback, node?)`: frame de animación con disposer **Qué.** Nuevo método en la superficie `ActiveDom`: diff --git a/src/arts/adom/active-dom.svelte.ts b/src/arts/adom/active-dom.svelte.ts index abde8b469..18b0a57b1 100644 --- a/src/arts/adom/active-dom.svelte.ts +++ b/src/arts/adom/active-dom.svelte.ts @@ -188,6 +188,24 @@ export interface ActiveDom { callback: FrameRequestCallback, node?: Element | Window | Node | Document | null ): ActiveDomFrameCleanup; + /** + * Schedule a layout-forcing READ (`getBoundingClientRect`, `getComputedStyle`, + * `offset*`, `scroll*`, …) to run in a **coalesced animation frame** instead of + * synchronously, and get back a disposer (same shape as {@link raf}). Every + * `measure` read queued in one turn runs together in a single rAF per window, + * so a read never forces a synchronous reflow in the middle of a write turn — + * the cause of "[Violation] Forced reflow while executing JavaScript". + * + * This is the sanctioned home for layout reads in components: it owns *when* + * the read runs (post-turn, coalesced), not *which* element. A bare {@link raf} + * that performs the read is equally compliant; what's forbidden is reading + * layout synchronously right after a DOM / style write. Reads-only by design — + * writes already sequence through `apply` + the framework runtime. + */ + measure( + read: () => void, + node?: Element | Window | Node | Document | null + ): ActiveDomFrameCleanup; scrollWindowBy( arg: ScrollToOptions | number, y?: number, @@ -253,6 +271,12 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom { let disposed = false; + // Coalesced post-layout read queue (FastDOM-style, reads only). `measure` + // defers layout-forcing reads out of the synchronous write turn into one rAF + // per window, so a read never forces a reflow mid-mutation. Keyed by the + // resolved window (iframe / popup safe). + const measureQueues = new Map void>; handle: number }>(); + function resolveStyleHost(host?: ActiveDomStyleHost): HTMLElement | null { if (host !== undefined) return typeof host === 'function' ? host() : host; return props.targetWindow?.document.head ?? getDocumentBare().head; @@ -505,6 +529,45 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom { handle = null; }; }, + measure( + read: () => void, + node?: Element | Window | Node | Document | null + ): ActiveDomFrameCleanup { + const win = resolveWindow(node); + let queue = measureQueues.get(win); + if (!queue) { + queue = { reads: new Set<() => void>(), handle: 0 }; + measureQueues.set(win, queue); + } + const q = queue; + q.reads.add(read); + if (q.handle === 0) { + q.handle = win.requestAnimationFrame(() => { + q.handle = 0; + const batch = [...q.reads]; + q.reads.clear(); + measureQueues.delete(win); + for (const fn of batch) { + try { + fn(); + } catch { + // A throwing read must not sink the rest of the coalesced batch. + } + } + }); + } + let active = true; + return () => { + if (!active) return; + active = false; + q.reads.delete(read); + if (q.reads.size === 0 && q.handle !== 0) { + win.cancelAnimationFrame(q.handle); + q.handle = 0; + measureQueues.delete(win); + } + }; + }, scrollWindowBy( arg: ScrollToOptions | number, y?: number, @@ -538,6 +601,10 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom { dispose(): void { if (disposed) return; disposed = true; + for (const [win, queue] of measureQueues) { + if (queue.handle !== 0) win.cancelAnimationFrame(queue.handle); + } + measureQueues.clear(); tracker.dispose(); motion.dispose(); } diff --git a/src/arts/adom/test/active-dom.test.ts b/src/arts/adom/test/active-dom.test.ts index 10f1adf78..1d5382ad1 100644 --- a/src/arts/adom/test/active-dom.test.ts +++ b/src/arts/adom/test/active-dom.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readableActive } from '$reactive'; import { createActiveDom } from '../active-dom.svelte'; @@ -210,3 +210,102 @@ describe('ActiveDom', () => { } }); }); + +describe('ActiveDom.measure — coalesced post-layout reads', () => { + let rafCallbacks: Map; + let nextHandle: number; + let realRaf: typeof window.requestAnimationFrame; + let realCancel: typeof window.cancelAnimationFrame; + + beforeEach(() => { + rafCallbacks = new Map(); + nextHandle = 1; + realRaf = window.requestAnimationFrame; + realCancel = window.cancelAnimationFrame; + window.requestAnimationFrame = ((cb: FrameRequestCallback) => { + const handle = nextHandle++; + rafCallbacks.set(handle, cb); + return handle; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((handle: number) => { + rafCallbacks.delete(handle); + }) as typeof window.cancelAnimationFrame; + }); + + afterEach(() => { + window.requestAnimationFrame = realRaf; + window.cancelAnimationFrame = realCancel; + }); + + const flushFrames = () => { + const callbacks = [...rafCallbacks.values()]; + rafCallbacks.clear(); + for (const cb of callbacks) cb(0); + }; + + it('defers a read to the next frame instead of running it synchronously', () => { + const dom = createActiveDom(); + const read = vi.fn(); + dom.measure(read); + expect(read).not.toHaveBeenCalled(); + expect(rafCallbacks.size).toBe(1); + flushFrames(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it('coalesces many reads in one turn into a single frame', () => { + const dom = createActiveDom(); + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + dom.measure(a); + dom.measure(b); + dom.measure(c); + expect(rafCallbacks.size).toBe(1); + flushFrames(); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(c).toHaveBeenCalledTimes(1); + }); + + it('the disposer cancels a still-pending read', () => { + const dom = createActiveDom(); + const a = vi.fn(); + const b = vi.fn(); + const cancelA = dom.measure(a); + dom.measure(b); + cancelA(); + flushFrames(); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('cancels the frame once the last pending read is disposed', () => { + const dom = createActiveDom(); + const cancel = dom.measure(vi.fn()); + expect(rafCallbacks.size).toBe(1); + cancel(); + expect(rafCallbacks.size).toBe(0); + }); + + it('keeps running the batch when one read throws', () => { + const dom = createActiveDom(); + const boom = vi.fn(() => { + throw new Error('read failed'); + }); + const ok = vi.fn(); + dom.measure(boom); + dom.measure(ok); + flushFrames(); + expect(boom).toHaveBeenCalledTimes(1); + expect(ok).toHaveBeenCalledTimes(1); + }); + + it('cancels pending measure frames on dispose', () => { + const dom = createActiveDom(); + dom.measure(vi.fn()); + expect(rafCallbacks.size).toBe(1); + dom.dispose(); + expect(rafCallbacks.size).toBe(0); + }); +});