feat(adom): dom.measure — coalesced post-layout read scheduler

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 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 232a36c159
commit 99a7882a64

@ -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`:

@ -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<Window, { reads: Set<() => 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();
}

@ -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<number, FrameRequestCallback>;
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);
});
});

Loading…
Cancel
Save

Powered by TurnKey Linux.