Skip to content

fix(js): lay the page out again before reading geometry after a mutation - #293

Merged
tannevaled merged 1 commit into
mainfrom
js-forced-layout
Oct 18, 2026
Merged

tannevaled merged 1 commit into
mainfrom
js-forced-layout

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Problem

With JS enabled, a script turns <h1>Short</h1> into <h1><span>Short</span></h1> by creating a span and moving the h1's children into it. Reading span.getBoundingClientRect().width straight away gave 0; the same span written in the markup reads 85.7. Style and text mutations behaved the same way: after d.style.width='150px', offsetWidth still read 100. Marp's fitting headings do exactly this.

Spec

CSSOM View: the geometry getters (getBoundingClientRect, offset*, client*, scroll*) answer from the current layout. Engines get there by flushing style and layout before answering, the "forced synchronous layout".

Root cause

The binder answered every read from the Metrics snapshot handed to the session with SetMetrics (dynamic.go newLayoutMetrics). That snapshot is replaced only by the settle loop between script passes, never inside one.

Change

  • js.Refresher: an optional Metrics extension. The binder calls it before every geometry read: rectOf (bounding rect, offset*, client*, scroll*) and getComputedStyle.
  • The engine's liveMetrics records a signature of the DOM it was laid out from. This is a structural hash over tags, attributes (class/style included) and text, built without serialising, so a read with no mutation costs one walk and no re-layout. When the signature differs, the metrics re-cascade and re-lay-out (containers included) before answering.
  • The re-layout stays private to the metrics; the settle loop still owns the render's pass and its wipe guard.

Tests

  • TestForcedSynchronousLayout (engine, RenderHTML with JS): the script-created span measures the same as the static one. Style-property, setAttribute('style') and textContent mutations are seen too. Failed on main (0, and 100,100,100,false).
  • TestTreeSignature: stable for an unchanged tree (attribute map order), detects attribute, text and wrap changes, and re-lays out once per change.
  • TestSessionRefreshBeforeGeometryReads (js): Refresh is called once per read.

The second defect (scrollWidth/clientWidth = border box) has a different root cause and comes in its own PR.

🤖 Generated with Claude Code

getBoundingClientRect, offset*, client*, scroll* and getComputedStyle
answered from the layout pass the session was handed, so geometry read
right after a DOM mutation described the DOM before it: a <span> created
by script and given the heading's text measured 0 wide, where the same
span written in the markup measures 85.7. CSSOM View's geometry getters
imply an up-to-date layout - the forced synchronous layout every engine
performs.

js.Refresher is an optional Metrics extension the binder calls before
every geometry read. The engine's metrics keep a signature of the DOM
they were laid out from (a structural hash of tags, attributes - class
and style included - and text, no serialisation) and, when it differs,
re-cascade and lay the document out again before answering. The
re-layout stays private to the metrics: the settle loop still owns the
render's own pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 1546fa9 into main Oct 18, 2026
7 checks passed
@tannevaled
tannevaled deleted the js-forced-layout branch October 18, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant