Repository navigation
fix(js): lay the page out again before reading geometry after a mutation - #293
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Readingspan.getBoundingClientRect().widthstraight away gave 0; the same span written in the markup reads 85.7. Style and text mutations behaved the same way: afterd.style.width='150px',offsetWidthstill 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
Metricssnapshot handed to the session withSetMetrics(dynamic.gonewLayoutMetrics). 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*) andgetComputedStyle.liveMetricsrecords 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.Tests
TestForcedSynchronousLayout(engine, RenderHTML with JS): the script-created span measures the same as the static one. Style-property,setAttribute('style')andtextContentmutations are seen too. Failed on main (0, and100,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