@@ -135,11 +135,6 @@ type DisambiguationState = {
135135 best : SnapshotNode | null ;
136136 bestVisible : boolean ;
137137 tie : boolean ;
138- // ADR 0012 decision 2: the criterion that most recently distinguished the
139- // current best from a challenger, win or lose — a side channel only. It
140- // never feeds back into `best`/`tie`, so the winner selection this state
141- // machine already made is unchanged.
142- tiebreak : DisambiguationTiebreak | null ;
143138} ;
144139
145140function analyzeSelectorMatches (
@@ -159,7 +154,7 @@ function analyzeSelectorMatches(
159154 let count = 0 ;
160155 let firstNode : SnapshotNode | null = null ;
161156 const candidates : SnapshotNode [ ] = [ ] ;
162- const state : DisambiguationState = { best : null , bestVisible : false , tie : false , tiebreak : null } ;
157+ const state : DisambiguationState = { best : null , bestVisible : false , tie : false } ;
163158 // Lazily built: only ambiguous matches pay for viewport inference.
164159 let byIndex : Map < number , SnapshotNode > | undefined ;
165160 const isVisible = ( node : SnapshotNode ) : boolean => {
@@ -178,7 +173,7 @@ function analyzeSelectorMatches(
178173 count,
179174 firstNode,
180175 disambiguated : state . tie ? null : state . best ,
181- tiebreak : state . tie ? null : state . tiebreak ,
176+ tiebreak : state . tie ? null : findDecidingTiebreak ( candidates , state . best , isVisible ) ,
182177 candidates,
183178 } ;
184179}
@@ -204,27 +199,53 @@ function accumulateDisambiguationCandidate(
204199 state . best = node ;
205200 state . bestVisible = true ;
206201 state . tie = false ;
207- state . tiebreak = 'visible' ;
208- } else {
209- // The current best already wins on visibility; record that fact only if
210- // no criterion has decided yet — a later, closer comparison (equal
211- // visibility, decided by depth/area) is the more specific explanation.
212- state . tiebreak ??= 'visible' ;
213202 }
214203 return ;
215204 }
216205 const comparison = compareDisambiguationCandidates ( node , state . best ) ;
217206 if ( comparison . result > 0 ) {
218207 state . best = node ;
219208 state . tie = false ;
220- state . tiebreak = comparison . criterion ;
221209 } else if ( comparison . result === 0 ) {
222210 state . tie = true ;
223- } else {
224- state . tiebreak ??= comparison . criterion ;
225211 }
226212}
227213
214+ /**
215+ * The resolution winner is deliberately still picked by the existing running
216+ * comparator above. For disclosure, compare that winner with its best losing
217+ * challenger rather than leaking the first document-order comparison that
218+ * happened to set state. This is a side channel only.
219+ */
220+ function findDecidingTiebreak (
221+ candidates : readonly SnapshotNode [ ] ,
222+ winner : SnapshotNode | null ,
223+ isVisible : ( node : SnapshotNode ) => boolean ,
224+ ) : DisambiguationTiebreak | null {
225+ if ( ! winner ) return null ;
226+ let runnerUp : SnapshotNode | null = null ;
227+ for ( const candidate of candidates ) {
228+ if ( candidate === winner ) continue ;
229+ if ( ! runnerUp || compareCandidatesWithVisibility ( candidate , runnerUp , isVisible ) . result > 0 ) {
230+ runnerUp = candidate ;
231+ }
232+ }
233+ return runnerUp ? compareCandidatesWithVisibility ( winner , runnerUp , isVisible ) . criterion : null ;
234+ }
235+
236+ function compareCandidatesWithVisibility (
237+ a : SnapshotNode ,
238+ b : SnapshotNode ,
239+ isVisible : ( node : SnapshotNode ) => boolean ,
240+ ) : DisambiguationComparison {
241+ const visibleA = isVisible ( a ) ;
242+ const visibleB = isVisible ( b ) ;
243+ if ( visibleA !== visibleB ) {
244+ return { result : visibleA ? 1 : - 1 , criterion : 'visible' } ;
245+ }
246+ return compareDisambiguationCandidates ( a , b ) ;
247+ }
248+
228249function countSelectorMatchesOnly (
229250 nodes : SnapshotState [ 'nodes' ] ,
230251 selector : Selector ,
0 commit comments