Merge #5392: defer mobile overflow-anchor suppression across height-churn window (#4856) [allenliang2022]
This commit is contained in:
commit
2839c16ab7
2 changed files with 378 additions and 20 deletions
131
static/ui.js
131
static/ui.js
|
|
@ -12392,18 +12392,133 @@ function _restoreMessageScrollSnapshot(snapshot){
|
|||
* Chromium cannot re-anchor to the topmost row during the innerHTML=''
|
||||
* wipe-and-rebuild gap. The rAF callback restores CSS default afterward.
|
||||
*/
|
||||
// Mobile scroll jump-back root fix. On touch devices #messages rests at
|
||||
// overflow-anchor:auto, so the browser's native scroll-anchoring engine
|
||||
// re-compensates scrollTop in the LAYOUT phase whenever content above the
|
||||
// viewport changes height — worklog live→settled collapse, tool-card inserts,
|
||||
// media/katex reflow, virtual-scroll topPad recompute, the STREAM_DONE
|
||||
// multi-render sequence. That compensation happens in the browser's layout step,
|
||||
// INDEPENDENT of which frame our JS wrote scrollTop in, so per-write suppression
|
||||
// (a single-rAF guard) could not reach it: the collapse/reflow lands a frame or
|
||||
// two later, after the guard already released. Real mobile flight-recorder data
|
||||
// (captured jumps with dTop -101/+350/+748/-400, call stack = rAF sampler only =
|
||||
// NO JS frame) confirmed the compensation is the browser engine, not our scroll
|
||||
// writes.
|
||||
//
|
||||
// Fix: DEFER the restore AND track CSS animations. Each call re-arms suppression
|
||||
// and cancels any pending release, so a burst of renders (STREAM_DONE fires
|
||||
// several back-to-back) shares ONE suppression window. The base window is two
|
||||
// animation frames + a settle timeout, which covers churn that is NOT a CSS
|
||||
// animation (virtual topPad recompute, image-decode, katex measure). But the
|
||||
// dominant churn is CSS max-height collapse/expand animations on worklog rows —
|
||||
// .activity-body (.34s), .tool-group-body (.3s), .tool-card-detail (.26s) — which
|
||||
// run LONGER than a fixed window; a fixed window lifts mid-animation and the rest
|
||||
// of the animation still jumps. So we also bind transitionrun/transitionend on
|
||||
// #messages: an animation start holds suppression (cancels the pending release);
|
||||
// an animation end schedules a short settle after the LAST one. Hard-capped so a
|
||||
// looping transition can't pin overflow-anchor:none forever. Desktop rests at
|
||||
// none (predicate false) → the whole guard is a no-op.
|
||||
const _MOBILE_ANCHOR_BASE_SETTLE_MS=400;
|
||||
const _MOBILE_ANCHOR_POST_TRANSITION_MS=90;
|
||||
const _MOBILE_ANCHOR_MAX_HOLD_MS=1200;
|
||||
let _mobileAnchorSuppressReleaseTimer=null;
|
||||
let _mobileAnchorSuppressRafId=0;
|
||||
let _mobileAnchorTransitionListenerBound=false;
|
||||
let _mobileAnchorSuppressArmedAt=0;
|
||||
// Independent hard-cap timer. Unlike the settle/rAF release (which the
|
||||
// transitionrun handler CANCELS to hold across an animation), this one is NEVER
|
||||
// cancelled by re-arm or by onRun — it is only ever cleared when suppression is
|
||||
// actually lifted, and re-armed to a fresh deadline on each _fixMobileScrollJank
|
||||
// call. This guarantees overflow-anchor returns to the mobile resting 'auto'
|
||||
// even if EVERY transitionend/transitioncancel is missed (animation interrupted,
|
||||
// element detached mid-transition, etc.) — the #5338 contract that mobile rests
|
||||
// at 'auto' must hold no matter what. (Gate-cert defect: the previous
|
||||
// _MOBILE_ANCHOR_MAX_HOLD_MS was only a guard clause inside onRun, so a missed
|
||||
// transitionend pinned 'none' forever.)
|
||||
let _mobileAnchorMaxHoldTimer=null;
|
||||
function _liftMobileAnchorSuppression(el){
|
||||
if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; }
|
||||
if(_mobileAnchorMaxHoldTimer){ clearTimeout(_mobileAnchorMaxHoldTimer); _mobileAnchorMaxHoldTimer=null; }
|
||||
if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); }
|
||||
_mobileAnchorSuppressRafId=0;
|
||||
// Only clear the inline value we set; a concurrent path may have legitimately
|
||||
// re-armed it (checked via the 'none' guard).
|
||||
if(el&&el.style&&el.style.overflowAnchor==='none') el.style.overflowAnchor='';
|
||||
}
|
||||
function _bindMobileAnchorTransitionExtender(el){
|
||||
if(_mobileAnchorTransitionListenerBound||!el||!el.addEventListener) return;
|
||||
_mobileAnchorTransitionListenerBound=true;
|
||||
// Only act while suppression is actually armed (inline 'none') and within the
|
||||
// hard cap, so we never pin overflow-anchor:none indefinitely.
|
||||
const onRun=(e)=>{
|
||||
if(!e||e.propertyName!=='max-height') return;
|
||||
if(el.style.overflowAnchor!=='none') return;
|
||||
if(_mobileAnchorSuppressArmedAt && (performance.now()-_mobileAnchorSuppressArmedAt)>_MOBILE_ANCHOR_MAX_HOLD_MS) return;
|
||||
// An animation is running — cancel the pending SETTLE release so we stay
|
||||
// suppressed until it ends (transitionend re-schedules the settle). The
|
||||
// independent max-hold timer is deliberately NOT cancelled here.
|
||||
if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; }
|
||||
if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); }
|
||||
_mobileAnchorSuppressRafId=0;
|
||||
};
|
||||
const onEnd=(e)=>{
|
||||
if(!e||e.propertyName!=='max-height') return;
|
||||
if(el.style.overflowAnchor!=='none') return;
|
||||
// This animation ended; settle shortly after (another may still be running,
|
||||
// in which case its own transitionrun already cancelled this timer).
|
||||
if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); }
|
||||
_mobileAnchorSuppressReleaseTimer=setTimeout(()=>{
|
||||
_mobileAnchorSuppressReleaseTimer=null;
|
||||
_liftMobileAnchorSuppression(el);
|
||||
},_MOBILE_ANCHOR_POST_TRANSITION_MS);
|
||||
};
|
||||
el.addEventListener('transitionrun',onRun,{passive:true});
|
||||
el.addEventListener('transitionstart',onRun,{passive:true});
|
||||
el.addEventListener('transitionend',onEnd,{passive:true});
|
||||
el.addEventListener('transitioncancel',onEnd,{passive:true});
|
||||
}
|
||||
window._fixMobileScrollJank=function _fixMobileScrollJank(){
|
||||
const el=document.getElementById('messages');
|
||||
if(!el) return;
|
||||
// Route through the same "is the browser scroll-anchor layer active?" predicate
|
||||
// as _suppressBrowserOverflowAnchor so the two guards can't drift (#5338 review).
|
||||
// Desktop rests at overflow-anchor:none (predicate false) → nothing to suppress.
|
||||
// Mobile touch devices rest at auto (predicate true) → temporarily suppress
|
||||
// anchor re-selection during the wipe-and-rebuild gap.
|
||||
if(!_browserOverflowAnchorActive(el)) return;
|
||||
// Engage when the browser scroll-anchor layer is active (mobile auto), OR when
|
||||
// WE are already holding an inline suppression from a prior call in the same
|
||||
// burst. The predicate reads the COMPUTED value, which our own inline
|
||||
// overflow-anchor:none flips to 'none' — so on the 2nd..Nth call of a
|
||||
// STREAM_DONE burst the predicate would say false and short-circuit the re-arm
|
||||
// below, collapsing the whole "consecutive renders extend the window" behavior
|
||||
// to a single first-call window. Treat an inline 'none' WE set as still-armed
|
||||
// so re-arm actually runs. Desktop rests at computed 'none' with EMPTY inline,
|
||||
// so `alreadySuppressed` is false there and this stays a no-op. (Gate-cert
|
||||
// defect: re-arm was dead code without this.)
|
||||
const alreadySuppressed=el.style.overflowAnchor==='none';
|
||||
if(!alreadySuppressed && !_browserOverflowAnchorActive(el)) return;
|
||||
el.style.overflowAnchor='none';
|
||||
requestAnimationFrame(()=>{
|
||||
if(el.style.overflowAnchor==='none') el.style.overflowAnchor='';
|
||||
_bindMobileAnchorTransitionExtender(el);
|
||||
_mobileAnchorSuppressArmedAt=performance.now();
|
||||
// Re-arm: cancel any pending release so consecutive renders EXTEND, not shorten,
|
||||
// the suppression window (the STREAM_DONE settle fires renderMessages several
|
||||
// times back-to-back, plus a deferred postProcess reflow).
|
||||
if(_mobileAnchorSuppressReleaseTimer){ clearTimeout(_mobileAnchorSuppressReleaseTimer); _mobileAnchorSuppressReleaseTimer=null; }
|
||||
if(_mobileAnchorSuppressRafId&&typeof cancelAnimationFrame==='function'){ cancelAnimationFrame(_mobileAnchorSuppressRafId); }
|
||||
_mobileAnchorSuppressRafId=0;
|
||||
// Independent hard cap: (re)arm a release that NOTHING cancels except an actual
|
||||
// lift, so a missed transitionend can never pin 'none' past the cap.
|
||||
if(_mobileAnchorMaxHoldTimer){ clearTimeout(_mobileAnchorMaxHoldTimer); }
|
||||
_mobileAnchorMaxHoldTimer=setTimeout(()=>{
|
||||
_mobileAnchorMaxHoldTimer=null;
|
||||
_liftMobileAnchorSuppression(el);
|
||||
},_MOBILE_ANCHOR_MAX_HOLD_MS);
|
||||
const rafHop=(cb)=>{ if(typeof requestAnimationFrame==='function') return requestAnimationFrame(cb); return setTimeout(cb,16); };
|
||||
// Base window: two animation frames (paint + post-render reflow settle) THEN a
|
||||
// settle timeout. CSS max-height animations are covered by the transitionrun/
|
||||
// transitionend extender above; this floor covers non-animated churn.
|
||||
_mobileAnchorSuppressRafId=rafHop(()=>{
|
||||
_mobileAnchorSuppressRafId=rafHop(()=>{
|
||||
_mobileAnchorSuppressReleaseTimer=setTimeout(()=>{
|
||||
_mobileAnchorSuppressReleaseTimer=null;
|
||||
_liftMobileAnchorSuppression(el);
|
||||
},_MOBILE_ANCHOR_BASE_SETTLE_MS);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
_FUNC_MARKER = "window._fixMobileScrollJank=function _fixMobileScrollJank(){"
|
||||
_RAF_MARKER = "requestAnimationFrame(()=>{"
|
||||
_RAF_MARKER = "setTimeout(()=>{"
|
||||
|
||||
|
||||
def _extract_fix_mobile_scroll_jank(src: str) -> str:
|
||||
|
|
@ -25,8 +25,11 @@ def _extract_fix_mobile_scroll_jank(src: str) -> str:
|
|||
|
||||
|
||||
def _extract_raf_body(fn_src: str) -> str:
|
||||
# The deferred-release cleanup now lives inside the final setTimeout callback
|
||||
# (see the two-rAF-hop + settle-timeout structure), not a bare rAF. Extract
|
||||
# that callback body so the cleanup-check assertions below still apply.
|
||||
idx = fn_src.find(_RAF_MARKER)
|
||||
assert idx != -1, "requestAnimationFrame callback not found in function"
|
||||
assert idx != -1, "deferred-release setTimeout callback not found in function"
|
||||
depth = 0
|
||||
for i, ch in enumerate(fn_src[idx:], idx):
|
||||
if ch == "{":
|
||||
|
|
@ -35,7 +38,7 @@ def _extract_raf_body(fn_src: str) -> str:
|
|||
depth -= 1
|
||||
if depth == 0:
|
||||
return fn_src[idx : i + 1]
|
||||
raise AssertionError("Could not extract rAF body")
|
||||
raise AssertionError("Could not extract deferred-release cleanup body")
|
||||
|
||||
|
||||
def test_fix_sets_none_not_auto():
|
||||
|
|
@ -54,16 +57,23 @@ def test_fix_sets_none_not_auto():
|
|||
|
||||
|
||||
def test_raf_cleanup_checks_none():
|
||||
# The rAF guard must check for 'none' so it only clears the inline style
|
||||
# it set; checking 'auto' was always false and left the inline style on.
|
||||
fn = _extract_fix_mobile_scroll_jank(UI_JS)
|
||||
raf = _extract_raf_body(fn)
|
||||
assert "overflowAnchor==='none'" in raf, (
|
||||
"The rAF cleanup in _fixMobileScrollJank() must check for 'none' so it "
|
||||
"clears the inline style after the synchronous scrollTop write lands."
|
||||
# The release cleanup must check for 'none' so it only clears the inline
|
||||
# style it set; checking 'auto' was always false and left the inline style
|
||||
# on. The cleanup now lives in the shared _liftMobileAnchorSuppression()
|
||||
# helper (called by every release path: base settle, transition settle, and
|
||||
# the independent hard cap), so assert the invariant there.
|
||||
lift_idx = UI_JS.find("function _liftMobileAnchorSuppression(")
|
||||
assert lift_idx != -1, (
|
||||
"_liftMobileAnchorSuppression() must exist as the single cleanup path "
|
||||
"shared by the base-settle, transition-settle, and hard-cap releases."
|
||||
)
|
||||
assert "overflowAnchor==='auto'" not in raf, (
|
||||
"The rAF cleanup must not check for 'auto'; that check was always false."
|
||||
lift = _balanced_block(UI_JS, UI_JS.index("{", lift_idx))
|
||||
assert "overflowAnchor==='none'" in lift, (
|
||||
"The cleanup in _liftMobileAnchorSuppression() must check for 'none' so "
|
||||
"it clears only the inline style it set, not a legitimately re-armed one."
|
||||
)
|
||||
assert "overflowAnchor==='auto'" not in lift, (
|
||||
"The cleanup must not check for 'auto'; that check was always false."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -570,3 +580,236 @@ def test_post_process_runs_under_overflow_anchor_suppression():
|
|||
"wrapper; found fewer than 3."
|
||||
)
|
||||
|
||||
|
||||
def test_fix_mobile_scroll_jank_defers_release_across_height_churn():
|
||||
"""Root-cause fix: the anchor suppression must span the WHOLE height-churn
|
||||
window, not just one frame.
|
||||
|
||||
Real mobile flight-recorder data proved the jump-back is the browser's own
|
||||
overflow-anchor engine re-compensating scrollTop in the LAYOUT phase when
|
||||
above-viewport content changes height (virtual-scroll topPad recompute,
|
||||
worklog live->settled collapse, STREAM_DONE multi-render, media/katex
|
||||
reflow). That compensation is independent of which frame our JS wrote
|
||||
scrollTop, so the previous single-rAF restore released too early -- the
|
||||
collapse/reflow lands a frame or two later, after suppression lifted.
|
||||
|
||||
The fix DEFERS release: each call re-arms and cancels any pending release so
|
||||
a burst of renders shares one suppression window that only lifts after the
|
||||
layout has been quiet for two animation frames + a settle timeout. These
|
||||
source invariants encode that behavior (base-fails on the old single-rAF
|
||||
body / head-passes on the deferred-release body).
|
||||
"""
|
||||
fn = _extract_fix_mobile_scroll_jank(UI_JS)
|
||||
# Re-arm must cancel a pending release so consecutive renders EXTEND, not
|
||||
# restart-and-shorten, the window.
|
||||
assert "clearTimeout(" in fn, (
|
||||
"_fixMobileScrollJank() must clearTimeout() any pending release on "
|
||||
"re-arm so a burst of renders shares one suppression window."
|
||||
)
|
||||
assert "cancelAnimationFrame(" in fn, (
|
||||
"_fixMobileScrollJank() must cancelAnimationFrame() any pending release "
|
||||
"hop on re-arm so consecutive renders extend the suppression window."
|
||||
)
|
||||
# Release must be deferred past the render frame: a settle timeout gated
|
||||
# behind animation-frame hops (not a bare single rAF).
|
||||
assert "setTimeout(" in fn, (
|
||||
"_fixMobileScrollJank() must defer the anchor restore behind a settle "
|
||||
"timeout so late layout reflow after the render cannot re-anchor."
|
||||
)
|
||||
# The module-level re-arm state the deferred release needs.
|
||||
assert "_mobileAnchorSuppressReleaseTimer" in UI_JS, (
|
||||
"The deferred-release timer handle must be tracked module-level so "
|
||||
"successive _fixMobileScrollJank() calls can cancel and re-arm it."
|
||||
)
|
||||
# Guard against regressing to the old immediate single-rAF restore: the
|
||||
# function body must NOT restore overflowAnchor inside a bare
|
||||
# requestAnimationFrame(()=>{...}) with no intervening defer.
|
||||
assert "requestAnimationFrame(()=>{\n if(el.style.overflowAnchor==='none')" not in fn, (
|
||||
"_fixMobileScrollJank() must not restore overflow-anchor in a single "
|
||||
"immediate rAF; that released before the height-churn window closed "
|
||||
"(the residual mobile scroll jump-back this fix addresses)."
|
||||
)
|
||||
|
||||
|
||||
def test_fix_mobile_scroll_jank_tracks_css_max_height_animations():
|
||||
"""The deferred window must EXTEND across CSS max-height animations.
|
||||
|
||||
A fixed rAF+timeout window (~150ms) under-covers the real churn: the
|
||||
dominant above-viewport height change during streaming is CSS max-height
|
||||
collapse/expand animations on worklog rows -- `.activity-body`
|
||||
(transition: max-height .34s), `.tool-group-body` (.3s),
|
||||
`.tool-card-detail` (.26s). Those run 260-340ms, LONGER than a fixed window,
|
||||
so overflow-anchor:none lifts mid-animation and the remaining frames of the
|
||||
animation still jump (the residual on-device report captured mid-stream).
|
||||
|
||||
Fix: bind transitionrun/transitionend for `max-height` on #messages so an
|
||||
animation start HOLDS suppression (cancels the pending release) and an
|
||||
animation end schedules a short settle after the last one, bounded by a hard
|
||||
cap. This test locks in that the guard listens for the animation lifecycle.
|
||||
"""
|
||||
assert "_bindMobileAnchorTransitionExtender" in UI_JS, (
|
||||
"A transition extender must exist so the suppression window tracks CSS "
|
||||
"max-height animations that outlast the fixed deferred window."
|
||||
)
|
||||
# It must listen for the START of an animation (hold) and the END (settle).
|
||||
assert "'transitionrun'" in UI_JS or '"transitionrun"' in UI_JS, (
|
||||
"The extender must listen for transitionrun so an animation beginning "
|
||||
"AFTER the base window started counting down still holds suppression."
|
||||
)
|
||||
assert "'transitionend'" in UI_JS or '"transitionend"' in UI_JS, (
|
||||
"The extender must listen for transitionend so suppression settles only "
|
||||
"after the animation actually finishes."
|
||||
)
|
||||
# It must gate on the height property, not every transition (opacity etc.).
|
||||
assert "propertyName!=='max-height'" in UI_JS or 'propertyName!=="max-height"' in UI_JS, (
|
||||
"The extender must act only on max-height transitions -- those are the "
|
||||
"ones that change above-viewport height and drive the anchor jump."
|
||||
)
|
||||
# A hard cap must exist so a looping/pathological transition can't pin
|
||||
# overflow-anchor:none forever.
|
||||
assert "_MOBILE_ANCHOR_MAX_HOLD_MS" in UI_JS, (
|
||||
"A max-hold cap must bound the transition-extended suppression so a "
|
||||
"looping transition cannot pin overflow-anchor:none indefinitely."
|
||||
)
|
||||
|
||||
|
||||
# ── Behavioral harness: execute the REAL _fixMobileScrollJank against a mock
|
||||
# #messages element + fake timers, so we test BEHAVIOR (does re-arm push the
|
||||
# release out? does the hard cap fire when transitionend is missed?) instead of
|
||||
# source strings. These two behaviors were the gate-cert defects on #5392:
|
||||
# 1. re-arm was DEAD CODE — the computed-value predicate short-circuited the
|
||||
# 2nd..Nth call of a burst because our own inline overflow-anchor:none had
|
||||
# already flipped computed to 'none', so consecutive renders never extended
|
||||
# the window (collapsed to a single first-call window).
|
||||
# 2. the "hard cap" was only a guard clause inside onRun, not an independent
|
||||
# release timer — so a MISSED transitionend (interrupted animation, detached
|
||||
# element) pinned overflow-anchor:none forever, violating the #5338 contract
|
||||
# that mobile rests at 'auto'.
|
||||
# Both assertions are mutation-verified: reverting either fix flips the matching
|
||||
# test to FAIL (proven at authoring time by reverting each hunk).
|
||||
_ANCHOR_BLOCK_START = "const _MOBILE_ANCHOR_BASE_SETTLE_MS"
|
||||
|
||||
|
||||
def _extract_anchor_block(src: str) -> str:
|
||||
start = src.find(_ANCHOR_BLOCK_START)
|
||||
assert start != -1, "anchor suppression block not found in ui.js"
|
||||
fn_mark = src.find("window._fixMobileScrollJank=function", start)
|
||||
assert fn_mark != -1, "_fixMobileScrollJank not found"
|
||||
brace_open = src.index("{", src.index("(){", fn_mark))
|
||||
depth = 0
|
||||
end = -1
|
||||
for i in range(brace_open, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i
|
||||
break
|
||||
assert end != -1, "could not balance _fixMobileScrollJank braces"
|
||||
return src[start : src.index(";", end) + 1]
|
||||
|
||||
|
||||
_ANCHOR_HARNESS = r"""
|
||||
const block = %s;
|
||||
function makeSandbox() {
|
||||
let now = 0; const timers = []; let nextId = 1;
|
||||
const schedule = (cb, delay) => { const id = nextId++; timers.push({ id, at: now + (delay||0), cb }); return id; };
|
||||
const cancel = (id) => { const i = timers.findIndex(t => t.id === id); if (i >= 0) timers.splice(i, 1); };
|
||||
const advance = (ms) => { const target = now + ms;
|
||||
for (;;) { const due = timers.filter(t => t.at <= target).sort((a,b)=>a.at-b.at);
|
||||
if (!due.length) break; const t = due[0]; timers.splice(timers.indexOf(t),1); now = t.at; t.cb(); }
|
||||
now = target; };
|
||||
const setTimeout = (cb,d)=>schedule(cb,d); const clearTimeout = (id)=>cancel(id);
|
||||
const requestAnimationFrame = (cb)=>schedule(cb,16); const cancelAnimationFrame = (id)=>cancel(id);
|
||||
const performance = { now: () => now };
|
||||
const listeners = {};
|
||||
const el = { style:{overflowAnchor:'auto'},
|
||||
addEventListener:(t,f)=>{(listeners[t]=listeners[t]||[]).push(f);},
|
||||
_fire:(t,e)=>{(listeners[t]||[]).forEach(f=>f(e));} };
|
||||
const _browserOverflowAnchorActive = (e)=>e.style.overflowAnchor==='auto';
|
||||
const document = { getElementById:(id)=>id==='messages'?el:null }; const win = {};
|
||||
new Function('setTimeout','clearTimeout','requestAnimationFrame','cancelAnimationFrame',
|
||||
'performance','document','window','_browserOverflowAnchorActive', block
|
||||
)(setTimeout,clearTimeout,requestAnimationFrame,cancelAnimationFrame,
|
||||
performance,document,win,_browserOverflowAnchorActive);
|
||||
return { fix: win._fixMobileScrollJank, el, advance };
|
||||
}
|
||||
const out = {};
|
||||
// TEST 1: re-arm extends the window across consecutive calls.
|
||||
{ const s = makeSandbox();
|
||||
s.fix(); const armed1 = s.el.style.overflowAnchor;
|
||||
s.advance(200); const midStill = s.el.style.overflowAnchor;
|
||||
s.fix(); s.advance(300); const afterFirstWouldRelease = s.el.style.overflowAnchor;
|
||||
s.advance(400); const afterSecond = s.el.style.overflowAnchor;
|
||||
out.rearm = { armed1, midStill, afterFirstWouldRelease, afterSecond }; }
|
||||
// TEST 2: hard cap releases when transitionend is missed.
|
||||
{ const s = makeSandbox();
|
||||
s.fix(); s.el._fire('transitionrun', { propertyName:'max-height' });
|
||||
s.advance(500); const heldMid = s.el.style.overflowAnchor;
|
||||
s.advance(1000); const afterCap = s.el.style.overflowAnchor;
|
||||
out.hardcap = { heldMid, afterCap }; }
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run_anchor_harness() -> dict:
|
||||
block = _extract_anchor_block(UI_JS)
|
||||
script = _ANCHOR_HARNESS % json.dumps(block)
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script], check=True, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return json.loads(result.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_behavioral_rearm_extends_suppression_window():
|
||||
"""Consecutive _fixMobileScrollJank() calls must EXTEND the window.
|
||||
|
||||
Gate-cert defect: the computed-value predicate short-circuited the 2nd call
|
||||
(our inline 'none' made computed 'none' -> predicate false -> early return),
|
||||
so re-arm never ran. Behaviorally: with the bug, the release stays anchored
|
||||
to call #1 and fires ~432ms in, so at t=500 (after a 2nd call at t=200) the
|
||||
anchor is already restored. With the fix, the 2nd call re-arms and the anchor
|
||||
is STILL 'none' at t=500. Mutation-verified: reverting the `alreadySuppressed`
|
||||
guard flips `afterFirstWouldRelease` from 'none' to ''.
|
||||
"""
|
||||
r = _run_anchor_harness()["rearm"]
|
||||
assert r["armed1"] == "none", "first call must engage suppression"
|
||||
assert r["midStill"] == "none", "suppression must hold within the first window"
|
||||
assert r["afterFirstWouldRelease"] == "none", (
|
||||
"the 2nd _fixMobileScrollJank() call must RE-ARM and extend the window; "
|
||||
"if suppression released here, re-arm is dead code (the computed-value "
|
||||
"predicate short-circuited the 2nd call)."
|
||||
)
|
||||
assert r["afterSecond"] == "", (
|
||||
"suppression must eventually release to the mobile resting 'auto' after "
|
||||
"the (extended) window elapses."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_behavioral_hard_cap_releases_when_transitionend_missed():
|
||||
"""A missed transitionend must NOT pin overflow-anchor:none forever.
|
||||
|
||||
Gate-cert defect: the 'hard cap' was only a guard clause inside onRun, not an
|
||||
independent release timer. If a max-height animation STARTS (transitionrun
|
||||
holds suppression) but its transitionend never fires (interrupted / element
|
||||
detached), nothing restored 'auto' -> stuck 'none', violating the #5338
|
||||
resting-value contract. With the fix, an independent max-hold timer restores
|
||||
'auto' by _MOBILE_ANCHOR_MAX_HOLD_MS. Mutation-verified: removing the
|
||||
independent timer leaves `afterCap` stuck at 'none'.
|
||||
"""
|
||||
r = _run_anchor_harness()["hardcap"]
|
||||
assert r["heldMid"] == "none", (
|
||||
"transitionrun must HOLD suppression across the animation (the settle "
|
||||
"release is cancelled while animating)."
|
||||
)
|
||||
assert r["afterCap"] == "", (
|
||||
"when transitionend is missed, the independent hard-cap timer MUST still "
|
||||
"restore the mobile resting 'auto'; a guard-clause-only cap pins 'none' "
|
||||
"forever."
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue