fix: preserve live scroll ownership on snapshot mismatch
This commit is contained in:
1 parent
a55096b81f
commit
7a320bea3c
2 files changed
+81
-17
No files matched your search
+28
-11
@@ -15002,17 +15002,34 @@ function _captureMessageScrollSnapshot(){
|
||||
};
|
||||
}
|
||||
function _messageScrollSnapshotInputChanged(snapshot){
|
||||
if(!snapshot||snapshot.pinned===true) return false;
|
||||
if(!snapshot) return false;
|
||||
const captured=Number(snapshot.inputGeneration);
|
||||
const current=typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : captured;
|
||||
return Number.isFinite(captured)&&Number.isFinite(current)&¤t!==captured;
|
||||
}
|
||||
function _abandonMessageScrollSnapshot(){
|
||||
_lastScrollTop=$('messages')?.scrollTop||0;
|
||||
_lastMessageClientHeight=$('messages')?.clientHeight||0;
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
_nearBottomCount=0;
|
||||
const el=$('messages');
|
||||
if(!el){
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
_nearBottomCount=0;
|
||||
return;
|
||||
}
|
||||
_lastScrollTop=el.scrollTop||0;
|
||||
_lastMessageClientHeight=el.clientHeight||0;
|
||||
// A generation mismatch abandons only the stale snapshot write. Reconcile
|
||||
// ownership from the live viewport so a reader who moved down to the true
|
||||
// bottom is immediately re-pinned instead of being stranded sticky-unpinned.
|
||||
const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight;
|
||||
if(bottomDistance<=80){
|
||||
_messageUserUnpinned=false;
|
||||
_scrollPinned=true;
|
||||
_nearBottomCount=2;
|
||||
}else{
|
||||
_messageUserUnpinned=true;
|
||||
_scrollPinned=false;
|
||||
_nearBottomCount=0;
|
||||
}
|
||||
}
|
||||
function _restorePinnedMessageScrollSnapshot(snapshot){
|
||||
const el=$('messages');
|
||||
@@ -15040,11 +15057,11 @@ function _restoreMessageScrollSnapshot(snapshot){
|
||||
// activity rebuilds can remount an older top-of-viewport anchor and yank a
|
||||
// pinned streaming transcript upward. Semantic anchors remain for manual
|
||||
// unpinned reading positions below.
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){
|
||||
if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot();
|
||||
return;
|
||||
}
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
|
||||
? _restoreMessageViewportAnchor(snapshot.anchor,0)
|
||||
: false;
|
||||
@@ -15262,14 +15279,14 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
// Same-frame live DOM updates (tool/worklog/activity rows) are the hot path for
|
||||
// streaming. Pinned followers must stay tail-relative here too; restoring the
|
||||
// semantic viewport anchor is only safe for explicitly unpinned readers.
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
// A delayed rAF restore must not overwrite a position the reader changed
|
||||
// after capture. Recent-intent timestamps are lossy; the generation is
|
||||
// monotonic and therefore preserves snapshot ownership exactly.
|
||||
if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){
|
||||
if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot();
|
||||
return;
|
||||
}
|
||||
if(_restorePinnedMessageScrollSnapshot(snapshot)) return;
|
||||
// A delayed rAF restore must not overwrite a position the reader changed
|
||||
// after capture. Recent-intent timestamps are lossy; the generation is
|
||||
// monotonic and therefore preserves snapshot ownership exactly.
|
||||
let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function')
|
||||
? _restoreMessageViewportAnchor(snapshot.anchor,0)
|
||||
: false;
|
||||
|
||||
@@ -176,9 +176,11 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top
|
||||
active_intent: bool = False, touch_like: bool = True,
|
||||
init_scroll_top: int = 90030,
|
||||
snapshot_user_unpinned: bool = False,
|
||||
snapshot_pinned: bool = False,
|
||||
anchor=None, anchor_row_content_pos=None, container_top: int = 0,
|
||||
top_pad_now=None, input_generation: int = 0,
|
||||
delayed_input_generation=None) -> str:
|
||||
delayed_input_generation=None, delayed_scroll_top: int = 89000,
|
||||
restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str:
|
||||
"""Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame.
|
||||
|
||||
SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's
|
||||
@@ -233,7 +235,12 @@ function _recentMessageScrollIntent(){{ return {intent_js}; }}
|
||||
function _recentMessageTouchScrollIntent(){{ return {intent_js}; }}
|
||||
function _isTouchLikeMessageViewport(){{ return {touch_js}; }}
|
||||
// realign path fails (no anchor restore) so execution reaches the absolute fallback
|
||||
function _restorePinnedMessageScrollSnapshot(){{ return false; }}
|
||||
function _restorePinnedMessageScrollSnapshot(snapshot){{
|
||||
if(snapshot.pinned!==true) return false;
|
||||
const pinnedTarget=el.scrollHeight-el.clientHeight-(Number(snapshot.bottom)||0);
|
||||
el.scrollTop=pinnedTarget;
|
||||
return true;
|
||||
}}
|
||||
function _restoreMessageViewportAnchor(){{ return false; }}
|
||||
function _remountMessageViewportAnchor(){{ return false; }}
|
||||
let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 5;
|
||||
@@ -244,13 +251,13 @@ function _deferClearProgrammaticScroll(){{}}
|
||||
function requestAnimationFrame(cb){{ cb(); }}
|
||||
function setTimeout(cb){{ cb(); return 1; }}
|
||||
const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40,
|
||||
scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
|
||||
scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: {str(snapshot_pinned).lower()}, userUnpinned: {str(snapshot_user_unpinned).lower()} }};
|
||||
eval(extractFunc('_desktopAnchorRealignDelta'));
|
||||
eval(extractFunc('_messageScrollSnapshotInputChanged'));
|
||||
eval(extractFunc('_abandonMessageScrollSnapshot'));
|
||||
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
|
||||
_restoreMessageScrollSnapshotSameFrame(snapshot);
|
||||
{f"stTop = 89000; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""}
|
||||
eval(extractFunc({json.dumps(restore_fn)}));
|
||||
{restore_fn}(snapshot);
|
||||
{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""}
|
||||
console.log(JSON.stringify({{ wrote: writes.length, writes,
|
||||
messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }}));
|
||||
"""
|
||||
@@ -347,6 +354,46 @@ def test_delayed_restore_cannot_overwrite_new_desktop_input():
|
||||
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_fn", [
|
||||
"_restoreMessageScrollSnapshot",
|
||||
"_restoreMessageScrollSnapshotSameFrame",
|
||||
])
|
||||
def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn):
|
||||
"""A pinned-at-capture snapshot must lose ownership before its tail write.
|
||||
|
||||
The reader moves upward before the delayed restore; the stale pinned snapshot
|
||||
must not put them back at its captured tail-relative position.
|
||||
"""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_pinned=True,
|
||||
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
|
||||
delayed_scroll_top=89000,
|
||||
restore_fn=restore_fn,
|
||||
)))
|
||||
assert m["writes"] == [89986]
|
||||
assert m["finalScrollTop"] == 89000
|
||||
assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_fn", [
|
||||
"_restoreMessageScrollSnapshot",
|
||||
"_restoreMessageScrollSnapshotSameFrame",
|
||||
])
|
||||
def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn):
|
||||
"""Abandoning a stale unpinned snapshot must reconcile a live bottom position."""
|
||||
m = json.loads(_run_node(_fallback_harness(
|
||||
snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577,
|
||||
active_intent=False, touch_like=False, snapshot_user_unpinned=True,
|
||||
init_scroll_top=89577, input_generation=0, delayed_input_generation=1,
|
||||
delayed_scroll_top=90026,
|
||||
restore_fn=restore_fn,
|
||||
)))
|
||||
assert m["writes"] == [89577]
|
||||
assert m["finalScrollTop"] == 90026
|
||||
assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True
|
||||
|
||||
|
||||
# ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom -----
|
||||
# These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the
|
||||
# certified hold numbers are physically realizable in a real browser — the round-2
|
||||
|
||||
Reference in new issue
Block a user