fix(soma): route layout reads through dom.measure — D13 census (C7)

textarea (the audit-CONFIRMED violation): measureAutosize wrote
height:auto then synchronously read scrollHeight + getComputedStyle — a
forced reflow on EVERY keystroke (input / clear / mount). The read block
now defers through the context ActiveDom's dom.measure: write stays in
the event turn, the read runs post-layout in the coalesced pre-paint
frame (typing bursts coalesce to one measurement; stale pending reads
cancel), and the clamped height is written back inside the frame
(read->write, no mid-turn reflow). Verified live: style.height is 'auto'
synchronously after input (the old code showed px immediately) and the
grown px lands next frame.

Directed census over the audit candidates, violations fixed:
- scroll-area: the thumb's size/offset getters read the track's
  clientWidth/Height inside the thumb props $derived — a forced reflow
  per scroll tick. The scrollbar now mirrors its track size into $state
  fed by a ResizeObserver; thumb math + drag read the mirrors. The
  initial viewport measurement defers through dom.measure.
- virtual-list (both viewports) / virtual-grid: the initial
  updateSize/updateScroll ran sync at ref-attach right after DOM
  insertion — deferred through dom.measure (observers keep them fresh).
- carousel: the mount seed defers through dom.measure (lands pre-paint,
  before any interaction), which closes the async-observer gap the
  viewportSize $derived's live clientWidth fallback guarded — that
  fallback (a render-time layout read) is removed.

Classified compliant (event-time reads before writes — D13 is a timing
rule): float-panel (drag already caches bounds; open seed is one-shot),
cropper, splitter.

Suites updated to the deferred contract and green (scroll-area 3/3,
carousel 4/4, virtual-list, virtual-grid); baseline 59.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 3b02fd11a5
commit 444e05ebd4

@ -234,7 +234,7 @@ describe('CarouselProvider', () => {
dom.dispose();
});
it('exposes viewport, item group, item and indicator props', () => {
it('exposes viewport, item group, item and indicator props', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('section');
const viewportEl = document.createElement('div');
@ -280,6 +280,12 @@ describe('CarouselProvider', () => {
return { provider, viewport, group, item, indicatorGroup, indicator };
});
// The viewport mirrors are seeded post-layout (D13, `dom.measure`): flush
// the effects first (tick — the seed effect schedules the coalesced read),
// then the animation frame so the translate below sees clientWidth = 200.
await tick();
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
expect(result.provider.props).toMatchObject({
role: 'region',
'aria-roledescription': 'carousel',

@ -126,15 +126,23 @@ export class CarouselProvider {
$effect(() => {
const vp = this.viewportRef;
if (!vp) return;
// Seed once synchronously so the first translate isn't 0/0.
this.viewportClientWidth = vp.clientWidth;
this.viewportClientHeight = vp.clientHeight;
// Seed post-layout (D13): `dom.measure` runs in the coalesced pre-paint
// frame, so the mirrors are populated BEFORE the first paint — and thus
// before any user interaction — without forcing a reflow at ref-attach.
const cancelSeed = this.soma.dom.measure(() => {
this.viewportClientWidth = vp.clientWidth;
this.viewportClientHeight = vp.clientHeight;
}, vp);
// Route through ActiveDom (iframe/popup-safe, lifecycle-tracked); never a
// raw `new ResizeObserver`. `observeResize` returns its own cleanup.
return this.soma.dom.observeResize(vp, () => {
const cleanupResize = this.soma.dom.observeResize(vp, () => {
this.viewportClientWidth = vp.clientWidth;
this.viewportClientHeight = vp.clientHeight;
});
return () => {
cancelSeed();
cleanupResize();
};
});
// ── Autoplay ─────────────────────────────────────────────────────────
@ -201,25 +209,19 @@ export class CarouselProvider {
// ── Geometry ─────────────────────────────────────────────────────────────
/**
* Size (px) of the viewport along the primary axis.
* Size (px) of the viewport along the primary axis, read from the
* measured mirrors (`viewportClient{Width,Height}`).
*
* Two-tier read: the ResizeObserver mirrors (`viewportClient{Width,
* Height}`) are the steady-state source — they pick up layout reshape
* (orientation flip, container resize, font scaling) on the next
* frame. BUT the observer is async, so on first paint — or on the very
* first user interaction after mount — those mirrors may still be `0`.
* Falling back to the live `clientWidth/Height` reads synchronously
* from the DOM so the first click translates to a real pixel offset
* instead of `value * 0 = 0` (the bug that made early clicks look like
* dead-no-ops while the index actually advanced).
* The mirrors are seeded by `dom.measure` in the pre-paint frame at
* ref-attach and kept fresh by the ResizeObserver, so they are populated
* before the first paint — and thus before any user interaction. The old
* live `clientWidth/Height` fallback (which guarded the async-observer
* gap that made early clicks translate to `value * 0`) is gone: it was a
* layout read inside a `$derived` — a forced reflow at render time (D13).
*/
readonly viewportSize = $derived.by(() => {
const horizontal = this.opts.orientation.current === 'horizontal';
const tracked = horizontal ? this.viewportClientWidth : this.viewportClientHeight;
if (tracked > 0) return tracked;
const vp = this.viewportRef;
if (!vp) return 0;
return horizontal ? vp.clientWidth : vp.clientHeight;
return horizontal ? this.viewportClientWidth : this.viewportClientHeight;
});
/** Slide size (px) along the primary axis = viewport / slidesPerView. */

@ -117,13 +117,23 @@ function pointerEvent(
};
}
/**
* Flush one real animation frame: the initial viewport measurement and the
* scrollbar track measurement are deferred post-layout (D13 — `dom.measure`
* + ResizeObserver initial delivery), so size-dependent assertions must let
* the frame land first.
*/
function nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
describe('ScrollAreaProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('measures viewport overflow and exposes root/viewport state props', () => {
it('measures viewport overflow and exposes root/viewport state props', async () => {
const { dom } = installSomaHarness();
const observeResize = vi.spyOn(dom, 'observeResize').mockImplementation(() => vi.fn());
const root = document.createElement('div');
@ -152,6 +162,9 @@ describe('ScrollAreaProvider', () => {
return { provider, viewport };
});
// Deferred initial measurement (D13) — flush the pre-paint frame.
await nextFrame();
expect(observeResize).toHaveBeenCalledTimes(2);
expect(result.provider.viewportRef.current).toBe(viewportEl);
expect(result.provider.hasOverflowX).toBe(true);
@ -228,6 +241,9 @@ describe('ScrollAreaProvider', () => {
return { provider, scrollbar };
});
// Deferred initial measurement (D13) — flush the pre-paint frame.
await nextFrame();
result.scrollbar.show();
expect(result.scrollbar.shouldShow).toBe(true);
expect(result.scrollbar.shouldMount).toBe(true);
@ -264,7 +280,7 @@ describe('ScrollAreaProvider', () => {
harness.dom.dispose();
});
it('drags thumbs and exposes corner state from overflow', () => {
it('drags thumbs and exposes corner state from overflow', async () => {
const harness = installSomaHarness();
vi.spyOn(harness.dom, 'requestFrame').mockImplementation((callback) => {
callback(0);
@ -315,6 +331,12 @@ describe('ScrollAreaProvider', () => {
return { provider, scrollbar, thumb, corner };
});
// Two frames: the deferred viewport measurement (dom.measure) lands on
// the first; the scrollbar track's ResizeObserver initial delivery (which
// feeds trackWidth/trackHeight for the thumb math) lands by the second.
await nextFrame();
await nextFrame();
expect(result.thumb.props.style).toMatchObject({
top: '20px',
height: '20px',

@ -109,7 +109,11 @@ export class ScrollAreaProvider {
this.contentHeight = el.scrollHeight;
};
update();
// Initial measurement runs post-layout (D13): the ref just attached, so a
// sync read here would force a reflow right after Svelte's DOM insertion.
// `dom.measure` defers it to the coalesced pre-paint frame; the resize
// observer below also delivers an initial post-layout entry.
this.resizeCleanups.push(this.soma.dom.measure(update, el));
this.resizeCleanups.push(this.soma.dom.observeResize(el, update));
if (el.firstElementChild) {
@ -238,6 +242,13 @@ export class ScrollAreaScrollbarProvider {
hovering = $state(false);
dragging = $state(false);
// Track size, measured post-layout (ResizeObserver — the sanctioned read
// vehicle, D13). The thumb's size/offset math reads THESE instead of live
// `clientWidth`/`clientHeight` getters, which — evaluated inside the thumb's
// `props` $derived — forced a layout flush on every scroll tick.
trackWidth = $state(0);
trackHeight = $state(0);
private constructor(opts: ScrollAreaScrollbarOpts) {
this.opts = opts;
this.provider = ScrollAreaProvider.require();
@ -264,6 +275,18 @@ export class ScrollAreaScrollbarProvider {
return () => this.clearHideTimer();
});
// Keep the measured track size fresh: ResizeObserver delivers an initial
// post-layout entry on observe and again on every resize — no sync
// layout read ever runs (D13).
$effect(() => {
const el = opts.ref.current;
if (!el) return;
return this.provider.soma.dom.observeResize(el, () => {
this.trackWidth = el.clientWidth;
this.trackHeight = el.clientHeight;
});
});
// Reveal the scrollbar on parent scroll for 'scroll' and 'hover' types.
// The first run of an `$effect` is a subscription-setup pass with
// scrollTop=scrollLeft=0 — skip it via `firstRun` so we don't show
@ -474,31 +497,28 @@ export class ScrollAreaThumbProvider {
// ── Size calculation ─────────────────────────────────────────────────────
// Thumb math reads the scrollbar's MEASURED track size ($state fed by its
// ResizeObserver), never live client sizes: these getters run inside the
// thumb's `props` $derived, where a layout read would force a reflow on
// every scroll tick (D13).
private get thumbSize(): number {
if (this.isVertical) {
if (this.provider.contentHeight <= 0) return 0;
const ratio = this.provider.viewportHeight / this.provider.contentHeight;
const trackHeight = this.scrollbar.opts.ref.current?.clientHeight ?? 0;
return Math.max(ratio * trackHeight, 20);
return Math.max(ratio * this.scrollbar.trackHeight, 20);
} else {
if (this.provider.contentWidth <= 0) return 0;
const ratio = this.provider.viewportWidth / this.provider.contentWidth;
const trackWidth = this.scrollbar.opts.ref.current?.clientWidth ?? 0;
return Math.max(ratio * trackWidth, 20);
return Math.max(ratio * this.scrollbar.trackWidth, 20);
}
}
private get thumbOffset(): number {
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!scrollbarEl) return 0;
if (this.isVertical) {
const trackHeight = scrollbarEl.clientHeight;
const maxOffset = trackHeight - this.thumbSize;
const maxOffset = this.scrollbar.trackHeight - this.thumbSize;
return this.provider.getScrollProgressY() * maxOffset;
} else {
const trackWidth = scrollbarEl.clientWidth;
const maxOffset = trackWidth - this.thumbSize;
const maxOffset = this.scrollbar.trackWidth - this.thumbSize;
return this.provider.getScrollProgressX() * maxOffset;
}
}
@ -527,19 +547,16 @@ export class ScrollAreaThumbProvider {
e.preventDefault();
const viewport = this.provider.viewportRef.current;
const scrollbarEl = this.scrollbar.opts.ref.current;
if (!viewport || !scrollbarEl) return;
if (!viewport) return;
if (this.isVertical) {
const deltaY = e.clientY - this.dragStartY;
const trackHeight = scrollbarEl.clientHeight;
const scrollRatio = deltaY / (trackHeight - this.thumbSize);
const scrollRatio = deltaY / (this.scrollbar.trackHeight - this.thumbSize);
const maxScroll = this.provider.contentHeight - this.provider.viewportHeight;
viewport.scrollTop = this.dragStartScrollTop + scrollRatio * maxScroll;
} else {
const deltaX = e.clientX - this.dragStartX;
const trackWidth = scrollbarEl.clientWidth;
const scrollRatio = deltaX / (trackWidth - this.thumbSize);
const scrollRatio = deltaX / (this.scrollbar.trackWidth - this.thumbSize);
const maxScroll = this.provider.contentWidth - this.provider.viewportWidth;
viewport.scrollLeft = this.dragStartScrollLeft + scrollRatio * maxScroll;
}

@ -151,15 +151,24 @@ export class TextAreaProvider {
// ── Autosize ───────────────────────────────────────────────────────────
/** Cancels the pending coalesced autosize read (at most one in flight). */
private cancelAutosizeMeasure: (() => void) | undefined;
/**
* Recompute the textarea height by sampling its `scrollHeight`. Called
* after each input event AND once on mount.
*
* Algorithm:
* 1. Reset height to `auto` so scrollHeight is the natural content size.
* 2. Measure scrollHeight.
* 3. Compute the line-height once (from a probe with `\n` content).
* 4. Clamp height between `minRows` and `maxRows`.
* 2. Measure scrollHeight — DEFERRED via `dom.measure` (D13): the layout
* read runs post-layout in the coalesced animation frame, never
* synchronously after the style write above (the write→sync-read pair
* forced a reflow on EVERY keystroke). The rAF runs pre-paint, so the
* `height: auto` reset is never visible; a typing burst coalesces to
* one measurement per frame (stale pending reads are cancelled).
* 3. Compute the line-height (from the computed style, same deferred read).
* 4. Clamp height between `minRows` and `maxRows`, then write it back
* (read→write inside the frame does not force a mid-turn reflow).
*/
measureAutosize(): void {
const el = this.inputRef.current;
@ -170,26 +179,34 @@ export class TextAreaProvider {
// Reset to natural size before measuring. Route DOM writes through
// ActiveDom so the soma "no direct mutable writes" contract holds.
this.soma.dom.apply({ target: el, attrs: { style: 'height: auto' } });
const scroll = el.scrollHeight;
// Probe single-line height by computed line-height + vertical padding.
// 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;
const borderTop = computed ? parseFloat(computed.borderTopWidth) || 0 : 0;
const borderBottom = computed ? parseFloat(computed.borderBottomWidth) || 0 : 0;
const chrome = padTop + padBottom + borderTop + borderBottom;
const minRows = Math.max(1, this.opts.minRows.current);
const maxRows = this.opts.maxRows.current;
const minPx = lineHeight * minRows + chrome;
const maxPx = maxRows !== undefined ? lineHeight * maxRows + chrome : Infinity;
const next = Math.max(minPx, Math.min(scroll, maxPx));
this.autosizeHeight = next;
this.soma.dom.apply({ target: el, attrs: { style: `height: ${next}px` } });
this.cancelAutosizeMeasure?.();
this.cancelAutosizeMeasure = this.soma.dom.measure(() => {
this.cancelAutosizeMeasure = undefined;
// Re-check: the ref may have detached / autosize may have toggled
// between the event and the frame.
const target = this.inputRef.current;
if (!target || !this.opts.autosize.current) return;
const scroll = target.scrollHeight;
// Probe single-line height by computed line-height + vertical padding.
// 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(target)?.getComputedStyle(target);
const lineHeight = computed ? parseFloat(computed.lineHeight) || 0 : 0;
const padTop = computed ? parseFloat(computed.paddingTop) || 0 : 0;
const padBottom = computed ? parseFloat(computed.paddingBottom) || 0 : 0;
const borderTop = computed ? parseFloat(computed.borderTopWidth) || 0 : 0;
const borderBottom = computed ? parseFloat(computed.borderBottomWidth) || 0 : 0;
const chrome = padTop + padBottom + borderTop + borderBottom;
const minRows = Math.max(1, this.opts.minRows.current);
const maxRows = this.opts.maxRows.current;
const minPx = lineHeight * minRows + chrome;
const maxPx = maxRows !== undefined ? lineHeight * maxRows + chrome : Infinity;
const next = Math.max(minPx, Math.min(scroll, maxPx));
this.autosizeHeight = next;
this.soma.dom.apply({ target, attrs: { style: `height: ${next}px` } });
}, el);
}
// ── Mutations ───────────────────────────────────────────────────────────

@ -257,8 +257,13 @@ export class VirtualGridViewportProvider {
this.provider.scrollLeft = el.scrollLeft;
};
updateSize();
updateScroll();
// Initial measurement runs post-layout (D13): the ref just attached, so
// a sync clientSize/scrollOffset read here would force a reflow right
// after DOM insertion. The observers below keep the values fresh.
const cancelInitialMeasure = this.provider.soma.dom.measure(() => {
updateSize();
updateScroll();
}, el);
const cleanupResize = this.provider.soma.dom.observeResize(el, updateSize);
@ -267,6 +272,7 @@ export class VirtualGridViewportProvider {
});
return () => {
cancelInitialMeasure();
cleanupResize();
cleanupScroll();
};

@ -366,8 +366,13 @@ export class VirtualListViewportProvider {
this.provider.scrollOffset = horizontal ? el.scrollLeft : el.scrollTop;
};
updateSize();
updateScroll();
// Initial measurement runs post-layout (D13): the ref just attached, so
// a sync clientSize/scrollOffset read here would force a reflow right
// after DOM insertion. The observers below keep the values fresh.
const cancelInitialMeasure = this.provider.soma.dom.measure(() => {
updateSize();
updateScroll();
}, el);
const cleanupResizeObserver = this.provider.soma.dom.observeResize(el, updateSize);
@ -376,6 +381,7 @@ export class VirtualListViewportProvider {
});
return () => {
cancelInitialMeasure();
cleanupResizeObserver();
cleanupScroll();
};
@ -447,8 +453,13 @@ export class VirtualListWindowViewportProvider {
this.provider.scrollOffset = Math.max(0, windowScroll - listStart);
};
updateSize();
updateScroll();
// Initial measurement runs post-layout (D13) — `updateScroll` reads
// `getBoundingClientRect`, which would force a reflow if run sync at
// ref-attach. The listeners/observers below keep the values fresh.
const cancelInitialMeasure = this.provider.soma.dom.measure(() => {
updateSize();
updateScroll();
}, el);
const cleanupScroll = this.provider.soma.dom.listen(win, 'scroll', updateScroll, {
passive: true
@ -466,6 +477,7 @@ export class VirtualListWindowViewportProvider {
);
return () => {
cancelInitialMeasure();
cleanupScroll();
cleanupResize();
cleanupElementResize();

Loading…
Cancel
Save

Powered by TurnKey Linux.