fix(webui): reserve real user-row height in non-virtualized transcripts to stop scroll jump-back
When transcript virtualization is disabled (the #4325 opt-out, _virtualizeTranscript===false), renderMessages() renders every row with no windowing and never runs the virtualized measure pass (_updateMessageVirtualMeasurements early-returns when !virtualized). Under @media (pointer: coarse), .msg-row[data-role="user"] carries content-visibility: auto; contain-intrinsic-size: auto 96px. Every rebuild does inner.innerHTML='' then recreates rows as fresh elements, so a fresh off-screen tall user row (a long paste measuring thousands of px) reserves only the flat estimate instead of its real height. scrollHeight shrinks by (realHeight - estimate), the browser force-clamps scrollTop, and the viewport jumps backward (a browser clamp, JS=none, so scrollTop-write compensation cannot catch it). #5638 fixed this for the virtualized wipe-and-rebuild path but left the non-virtualized full-rebuild path uncovered. Fix, three coordinated parts: - _estimateUserRowIntrinsicHeight weights CJK / full-width characters as ~2 columns (they wrap at ~24 chars/line, not 48), so a CJK paste reserves close to its real height even before it is ever measured. - _applyUserRowIntrinsicHeight reserves max(remembered, estimate): a remembered height can be a partial paint (a row taller than the viewport only paints its intersecting slice under content-visibility:auto), so the estimate floors it. - _rememberRenderedUserRowIntrinsicHeights, called pre-wipe inside renderMessages, reads the still-laid-out rows' real heights and persists them keyed by session-relative index, only for rows within the viewport (a fully off-screen never-painted row reports its collapsed reserve and must not poison the map), floored at the estimate. Desktop rests at content-visibility:visible so intrinsic-size is inert there; verified no behavior change with pointer:fine. Adds tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py (7 mutation-checked node-harness tests). Existing #5637/#5638 suites and the render/virtualization suites pass.
This commit is contained in:
1 parent
923cadcb80
commit
e8a3412939
2 files changed
+456
-6
No files matched your search
+102
-6
@@ -1291,19 +1291,42 @@ function _rememberUserRowIntrinsicHeight(sessionMsgIdx, height){
|
|||||||
function _estimateUserRowIntrinsicHeight(rawText){
|
function _estimateUserRowIntrinsicHeight(rawText){
|
||||||
const t=String(rawText||'');
|
const t=String(rawText||'');
|
||||||
if(!t) return 96;
|
if(!t) return 96;
|
||||||
// ~48 chars/line at the mobile user-bubble width (≈90% of a phone viewport), ~22px per
|
// ~48 half-width chars/line at the mobile user-bubble width (≈90% of a phone viewport),
|
||||||
// line + ~24px row chrome; floored at the stylesheet's 96px so a short row never reserves
|
// ~22px per line + ~24px row chrome; floored at the stylesheet's 96px so a short row never
|
||||||
// LESS than today (estimate can only add reserved height for tall rows, never regress).
|
// reserves LESS than today (estimate can only add reserved height for tall rows, never
|
||||||
|
// regress). CJK / full-width characters occupy ~2 columns each, so a Chinese/Japanese/
|
||||||
|
// Korean paste wraps at ~24 chars/line — counting them as 1 badly UNDER-estimates the
|
||||||
|
// height (a 3k-char CJK paste is ~2x taller than the naive length/48 guess). Weight wide
|
||||||
|
// characters as 2 columns so the fresh-row reserve is close to reality even for a row the
|
||||||
|
// reader has never scrolled into view (content-visibility:auto reports only the reserve
|
||||||
|
// for a never-painted row, so a good estimate is the only backstop there). Uses a Unicode
|
||||||
|
// range test (no \p{} — keep the RegExp engine-portable across the supported browsers).
|
||||||
const explicitLines=(t.match(/\n/g)||[]).length+1;
|
const explicitLines=(t.match(/\n/g)||[]).length+1;
|
||||||
const wrapLines=Math.ceil(t.length/48);
|
let columns=0;
|
||||||
|
for(let i=0;i<t.length;i++){
|
||||||
|
const c=t.charCodeAt(i);
|
||||||
|
// CJK Unified + Ext-A, Hiragana/Katakana, Hangul, CJK symbols/punctuation, full-width forms.
|
||||||
|
const wide=(c>=0x1100&&c<=0x115F)||(c>=0x2E80&&c<=0xA4CF)||(c>=0xAC00&&c<=0xD7A3)||
|
||||||
|
(c>=0xF900&&c<=0xFAFF)||(c>=0xFE30&&c<=0xFE4F)||(c>=0xFF00&&c<=0xFF60)||(c>=0xFFE0&&c<=0xFFE6);
|
||||||
|
columns+=wide?2:1;
|
||||||
|
}
|
||||||
|
const wrapLines=Math.ceil(columns/48);
|
||||||
const lines=Math.max(explicitLines, wrapLines);
|
const lines=Math.max(explicitLines, wrapLines);
|
||||||
return Math.max(96, Math.round(lines*22+24));
|
return Math.max(96, Math.round(lines*22+24));
|
||||||
}
|
}
|
||||||
function _applyUserRowIntrinsicHeight(row, rawText){
|
function _applyUserRowIntrinsicHeight(row, rawText){
|
||||||
if(!row||!row.style||!row.dataset) return;
|
if(!row||!row.style||!row.dataset) return;
|
||||||
const key=Number(row.dataset.sessionMsgIdx);
|
const key=Number(row.dataset.sessionMsgIdx);
|
||||||
let h=Number.isFinite(key)?_userRowIntrinsicHeightBySessionIdx[key]:0;
|
const remembered=Number.isFinite(key)?Number(_userRowIntrinsicHeightBySessionIdx[key])||0:0;
|
||||||
if(!(h>0)) h=_estimateUserRowIntrinsicHeight(rawText!=null?rawText:row.dataset.rawText);
|
const estimate=_estimateUserRowIntrinsicHeight(rawText!=null?rawText:row.dataset.rawText);
|
||||||
|
// Reserve the LARGER of the remembered measurement and the content estimate. A remembered
|
||||||
|
// height can be a PARTIAL paint: a user row taller than the viewport that only ever had its
|
||||||
|
// top slice scrolled through content-visibility:auto reports just the painted portion, not
|
||||||
|
// its full height — persisting that would under-reserve and let scrollHeight collapse on the
|
||||||
|
// next rebuild (the jump-back). Taking the max means a good estimate floors the reserve even
|
||||||
|
// when the measurement under-read, while a full measurement (row shorter than the viewport,
|
||||||
|
// fully painted) still wins when it exceeds the estimate.
|
||||||
|
const h=Math.max(remembered, estimate);
|
||||||
if(h>0) row.style.containIntrinsicSize='auto '+Math.round(h)+'px';
|
if(h>0) row.style.containIntrinsicSize='auto '+Math.round(h)+'px';
|
||||||
}
|
}
|
||||||
function _measureMessageVirtualRow(inner, entry){
|
function _measureMessageVirtualRow(inner, entry){
|
||||||
@@ -1361,6 +1384,70 @@ function _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs,
|
|||||||
_markMessageVirtualMeasurementsSettled(virtualWindow);
|
_markMessageVirtualMeasurementsSettled(virtualWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// #5638 follow-up — the non-virtualized transcript path (the #4325 opt-out, where
|
||||||
|
// _virtualizeTranscript===false renders every row with no windowing) never runs the
|
||||||
|
// virtualized measure pass above, so a user row's real height is never remembered.
|
||||||
|
// content-visibility:auto on user rows then collapses a freshly-rebuilt off-screen tall
|
||||||
|
// user row to its flat contain-intrinsic-size estimate on every renderMessages() rebuild
|
||||||
|
// (each streaming frame does inner.innerHTML='' then rebuilds all rows as FRESH elements
|
||||||
|
// that have never painted at full size). scrollHeight shrinks by (realHeight-estimate),
|
||||||
|
// the browser force-clamps scrollTop, and the viewport jumps backward — the desktop/mobile
|
||||||
|
// jump-back, with JS=none because the clamp is the browser's own.
|
||||||
|
//
|
||||||
|
// The reliable moment to read a user row's REAL height is JUST BEFORE the wipe: the old
|
||||||
|
// rows are still in the DOM, laid out at full height (content-visibility:auto reports the
|
||||||
|
// true rect height once an element has painted, at any scroll position — verified: a tall
|
||||||
|
// off-screen user row still measures its real height pre-wipe). A POST-render read is
|
||||||
|
// unreliable because a freshly-rebuilt off-screen row reports its collapsed reserve, not
|
||||||
|
// its real size, so it would persist the wrong (small) value. Capture pre-wipe, keyed by
|
||||||
|
// the stable session-relative index, so the rebuild's _applyUserRowIntrinsicHeight reserves
|
||||||
|
// the real off-screen height and scrollHeight stays stable across the rebuild.
|
||||||
|
// Desktop rests at content-visibility:visible (intrinsic-size ignored) → inert there.
|
||||||
|
function _rememberRenderedUserRowIntrinsicHeights(){
|
||||||
|
const container=$('messages');
|
||||||
|
const inner=$('msgInner');
|
||||||
|
if(!container||!inner) return;
|
||||||
|
const rows=inner.querySelectorAll('.msg-row[data-role="user"][data-msg-idx]');
|
||||||
|
if(!rows.length) return;
|
||||||
|
const cRect=container.getBoundingClientRect();
|
||||||
|
// Only trust a row that is currently WITHIN (or straddling) the viewport: such a row has
|
||||||
|
// been painted at full size, so getBoundingClientRect().height is its REAL height. A row
|
||||||
|
// that content-visibility:auto is skipping (fully off-screen and never painted this
|
||||||
|
// session) reports only its contain-intrinsic-size reserve — persisting THAT would poison
|
||||||
|
// the remembered height with the collapsed value and defeat the estimate backstop for a
|
||||||
|
// never-seen row. The viewport intersection test is the reliable "has this row painted?"
|
||||||
|
// signal (an off-screen row that WAS painted earlier keeps its real height too, but we
|
||||||
|
// don't need it here — it either was captured on a prior in-view pass or the estimate
|
||||||
|
// covers it). Small margin so a row just above/below the fold still counts as painted.
|
||||||
|
const margin=Math.max(0, cRect.height||0);
|
||||||
|
for(let i=0;i<rows.length;i++){
|
||||||
|
const row=rows[i];
|
||||||
|
if(!row||!row.dataset||!row.style) continue;
|
||||||
|
const r=row.getBoundingClientRect();
|
||||||
|
const measured=Math.max(0, r.height||0);
|
||||||
|
if(!(measured>0)) continue;
|
||||||
|
// In-viewport (with a one-screen margin) ⇒ painted ⇒ height is trustworthy — but only
|
||||||
|
// for a row that FITS the viewport. A row taller than the viewport only ever paints the
|
||||||
|
// intersecting slice under content-visibility:auto, so its measured height is a PARTIAL
|
||||||
|
// value, not the full row. Floor every persisted height at the content estimate so a
|
||||||
|
// partial paint can never lower the reserve below a reasonable full-row guess; a full
|
||||||
|
// paint (short row) still wins when it exceeds the estimate.
|
||||||
|
const inView=(r.bottom>=cRect.top-margin)&&(r.top<=cRect.bottom+margin);
|
||||||
|
if(!inView) continue;
|
||||||
|
const estimate=(typeof _estimateUserRowIntrinsicHeight==='function')
|
||||||
|
? _estimateUserRowIntrinsicHeight(row.dataset.rawText) : 0;
|
||||||
|
const h=Math.max(measured, estimate);
|
||||||
|
if(!(h>0)) continue;
|
||||||
|
const key=Number(row.dataset.sessionMsgIdx);
|
||||||
|
const remembered=Number.isFinite(key)?Number(_userRowIntrinsicHeightBySessionIdx[key])||0:0;
|
||||||
|
// Keep the tallest reserve seen — a row mid-collapse (rebuild transient) can report a
|
||||||
|
// shrunken size; never let that overwrite a good taller remembered value.
|
||||||
|
if(h>=remembered && typeof _rememberUserRowIntrinsicHeight==='function'){
|
||||||
|
_rememberUserRowIntrinsicHeight(row.dataset.sessionMsgIdx, h);
|
||||||
|
row.style.containIntrinsicSize='auto '+Math.round(h)+'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
function _scheduleMessageVirtualizedRender(force){
|
function _scheduleMessageVirtualizedRender(force){
|
||||||
const container=$('messages');
|
const container=$('messages');
|
||||||
const inner=$('msgInner');
|
const inner=$('msgInner');
|
||||||
@@ -14049,6 +14136,15 @@ function renderMessages(options){
|
|||||||
if(!_m) return false;
|
if(!_m) return false;
|
||||||
return (_m.scrollHeight-_m.scrollTop-_m.clientHeight)<=8;
|
return (_m.scrollHeight-_m.scrollTop-_m.clientHeight)<=8;
|
||||||
})();
|
})();
|
||||||
|
// Pre-wipe capture: read the still-laid-out user rows' REAL heights before the wipe below
|
||||||
|
// destroys them, and persist so the rebuild reserves the real off-screen height. This is
|
||||||
|
// the non-virtualized analog of #5638's virtualized measure pass (which never runs when
|
||||||
|
// _virtualizeTranscript===false). Without it, a fresh off-screen tall user row reserves
|
||||||
|
// only the flat contain-intrinsic-size estimate, scrollHeight shrinks, and the browser
|
||||||
|
// clamps scrollTop → the jump-back. Reading pre-wipe (not post-render) is what makes the
|
||||||
|
// measurement reliable — the old elements have painted, so their rect height is real even
|
||||||
|
// off-screen; a post-render read of a fresh off-screen row returns its collapsed reserve.
|
||||||
|
if(typeof _rememberRenderedUserRowIntrinsicHeights==='function') _rememberRenderedUserRowIntrinsicHeights();
|
||||||
// The DOM wipe can briefly collapse #msgInner to zero height, causing the
|
// The DOM wipe can briefly collapse #msgInner to zero height, causing the
|
||||||
// browser to clamp #messages.scrollTop to 0 and emit a scroll event. That
|
// browser to clamp #messages.scrollTop to 0 and emit a scroll event. That
|
||||||
// event is a render artifact, not user intent; if the scroll listener sees it
|
// event is a render artifact, not user intent; if the scroll listener sees it
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""Regression tests for the non-virtualized user-row content-visibility collapse
|
||||||
|
jump-back (follow-up to #5637 / #5638).
|
||||||
|
|
||||||
|
Root cause (proven in a mobile-emulated Playwright repro on an isolated debug
|
||||||
|
instance): when a reader has transcript virtualization disabled
|
||||||
|
(`_virtualizeTranscript === false`, the #4325 opt-out), renderMessages() renders
|
||||||
|
every row with no windowing and NEVER runs the virtualized measure pass
|
||||||
|
(`_updateMessageVirtualMeasurements` early-returns when
|
||||||
|
`!virtualWindow.virtualized`). Under `@media (pointer: coarse)`,
|
||||||
|
`.msg-row[data-role="user"]` carries `content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 96px`. Every renderMessages() rebuild does
|
||||||
|
`inner.innerHTML=''` then recreates all rows as FRESH elements; a fresh,
|
||||||
|
off-screen tall user row (e.g. a long paste measuring thousands of px) reserves
|
||||||
|
only the flat estimate instead of its real height, so scrollHeight shrinks by
|
||||||
|
(realHeight - estimate) and the browser force-clamps scrollTop -> the viewport
|
||||||
|
jumps backward (`JS=none`, a browser clamp, which is why no JS scrollTop-write
|
||||||
|
compensation catches it). Desktop rests at content-visibility:visible so
|
||||||
|
intrinsic-size is inert there.
|
||||||
|
|
||||||
|
Three coordinated pieces fix it, each covered below:
|
||||||
|
1. `_estimateUserRowIntrinsicHeight` weights CJK / full-width characters as ~2
|
||||||
|
columns, so a Chinese/Japanese/Korean paste (which wraps at ~24 chars/line,
|
||||||
|
not 48) reserves close to its real height even before it is ever measured.
|
||||||
|
2. `_applyUserRowIntrinsicHeight` reserves `max(remembered, estimate)`, so a
|
||||||
|
PARTIAL-paint remembered height (a row taller than the viewport only ever
|
||||||
|
paints its intersecting slice under content-visibility:auto) can never
|
||||||
|
under-reserve below the content estimate.
|
||||||
|
3. `_rememberRenderedUserRowIntrinsicHeights` (called pre-wipe inside
|
||||||
|
renderMessages, and the non-virtualized analog of #5638's measure pass) only
|
||||||
|
persists a height from a row currently within the viewport (painted =>
|
||||||
|
trustworthy) and floors it at the estimate.
|
||||||
|
|
||||||
|
Each behavioral test is written to FAIL on the known-buggy version and PASS only
|
||||||
|
on the fixed version (mutation notes inline).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).parent.parent
|
||||||
|
UI_JS_PATH = ROOT / "static" / "ui.js"
|
||||||
|
NODE = shutil.which("node")
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(source: str) -> str:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False
|
||||||
|
) as script:
|
||||||
|
script.write(source)
|
||||||
|
script_path = pathlib.Path(script.name)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[NODE, str(script_path)],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
script_path.unlink(missing_ok=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_func_script(js: str) -> str:
|
||||||
|
# Brace-matches a function body while skipping braces inside string / template
|
||||||
|
# / regex literals and comments (same hardened extractor as the sibling #5638
|
||||||
|
# suite).
|
||||||
|
prelude = "const src = " + json.dumps(js) + ";\n"
|
||||||
|
body = r"""
|
||||||
|
function extractFunc(name) {
|
||||||
|
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||||
|
const start = src.search(re);
|
||||||
|
if (start < 0) throw new Error(name + ' not found');
|
||||||
|
let i = src.indexOf('{', start);
|
||||||
|
let depth = 1; i++;
|
||||||
|
let str = null;
|
||||||
|
let inLine = false;
|
||||||
|
let inBlock = false;
|
||||||
|
let inRegex = false;
|
||||||
|
let prev = '';
|
||||||
|
while (depth > 0 && i < src.length) {
|
||||||
|
const c = src[i];
|
||||||
|
const n = src[i + 1];
|
||||||
|
if (inLine) { if (c === '\n') inLine = false; i++; continue; }
|
||||||
|
if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; } i++; continue; }
|
||||||
|
if (str) {
|
||||||
|
if (c === '\\') { i += 2; continue; }
|
||||||
|
if (c === str) str = null;
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
if (inRegex) {
|
||||||
|
if (c === '\\') { i += 2; continue; }
|
||||||
|
if (c === '/') inRegex = false;
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
if (c === '/' && n === '/') { inLine = true; i += 2; continue; }
|
||||||
|
if (c === '/' && n === '*') { inBlock = true; i += 2; continue; }
|
||||||
|
if (c === '"' || c === "'" || c === '`') { str = c; i++; continue; }
|
||||||
|
if (c === '/' && !'})]0123456789'.includes(prev) && !/[A-Za-z_$]/.test(prev)) {
|
||||||
|
inRegex = true; i++; continue;
|
||||||
|
}
|
||||||
|
if (c === '{') depth++;
|
||||||
|
else if (c === '}') depth--;
|
||||||
|
if (c.trim()) prev = c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return src.slice(start, i);
|
||||||
|
}"""
|
||||||
|
return prelude + body
|
||||||
|
|
||||||
|
|
||||||
|
def _dom_prelude() -> str:
|
||||||
|
"""Fake DOM: a $('id') helper returning a fake #messages container and
|
||||||
|
#msgInner, plus user rows with getBoundingClientRect + dataset + style, so
|
||||||
|
the extracted _rememberRenderedUserRowIntrinsicHeights can run headless.
|
||||||
|
|
||||||
|
The container is 600px tall (viewport). A row's `top` is where it sits
|
||||||
|
relative to the container top; a row is "in view" when it straddles the
|
||||||
|
[0, 600] band widened by a one-screen margin.
|
||||||
|
"""
|
||||||
|
return r"""
|
||||||
|
var _userRowIntrinsicHeightBySessionIdx = Object.create(null);
|
||||||
|
function makeRow(role, sessionMsgIdx, rectTop, rectHeight, rawText){
|
||||||
|
return {
|
||||||
|
_top: rectTop, _height: rectHeight,
|
||||||
|
style: { containIntrinsicSize: '' },
|
||||||
|
dataset: { role: role, sessionMsgIdx: String(sessionMsgIdx),
|
||||||
|
rawText: rawText || '', msgIdx: String(sessionMsgIdx) },
|
||||||
|
classList: { contains(){ return false; } },
|
||||||
|
getAttribute(){ return null; },
|
||||||
|
getBoundingClientRect(){ return { top: this._top, bottom: this._top + this._height, height: this._height }; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var __rows = [];
|
||||||
|
var __container = { getBoundingClientRect(){ return { top: 0, bottom: 600, height: 600 }; } };
|
||||||
|
var __inner = { querySelectorAll(){ return __rows.slice(); } };
|
||||||
|
function $(id){ if(id==='messages') return __container; if(id==='msgInner') return __inner; return null; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_weights_cjk_as_double_width():
|
||||||
|
"""A CJK (full-width) paste must reserve ~2x the height a same-length latin
|
||||||
|
paste would, because CJK glyphs occupy ~2 columns and wrap at ~24 chars/line.
|
||||||
|
The pre-fix estimate counted every char as 1 column, badly under-reserving a
|
||||||
|
Chinese/Japanese/Korean row -> off-screen collapse -> jump-back.
|
||||||
|
|
||||||
|
Mutation: revert the per-char width weighting (count `columns += 1` for all)
|
||||||
|
and the CJK estimate drops to the latin value, failing the >1.7x assertion.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + r"""
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
// Equal CHARACTER counts; the CJK one has ~2x the visual columns.
|
||||||
|
const latin = 'a'.repeat(1200);
|
||||||
|
const cjk = '\u4e2d'.repeat(1200); // 1200 CJK ideographs
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
latin: _estimateUserRowIntrinsicHeight(latin),
|
||||||
|
cjk: _estimateUserRowIntrinsicHeight(cjk),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["cjk"] > m["latin"] * 1.7, (
|
||||||
|
"a CJK paste must reserve ~2x a same-length latin paste (full-width "
|
||||||
|
f"columns); got cjk={m['cjk']} latin={m['latin']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_short_row_still_floors_at_96():
|
||||||
|
"""No regression: a short row must never reserve less than today's 96px."""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + r"""
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
empty: _estimateUserRowIntrinsicHeight(''),
|
||||||
|
hi: _estimateUserRowIntrinsicHeight('hi'),
|
||||||
|
cjkShort: _estimateUserRowIntrinsicHeight('\u4f60\u597d'),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["empty"] == 96 and m["hi"] == 96 and m["cjkShort"] == 96, (
|
||||||
|
f"short rows must floor at 96px; got {m!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_reserves_max_of_remembered_and_estimate():
|
||||||
|
"""_applyUserRowIntrinsicHeight must reserve max(remembered, estimate). A
|
||||||
|
remembered height can be a PARTIAL paint (a row taller than the viewport only
|
||||||
|
paints its intersecting slice), so when the content estimate is LARGER it must
|
||||||
|
win -- otherwise a partial 1500px remembered value under-reserves a really
|
||||||
|
3000px row and scrollHeight collapses on the next rebuild.
|
||||||
|
|
||||||
|
Mutation: change `Math.max(remembered, estimate)` back to
|
||||||
|
"remembered if >0 else estimate" and this fails (the small remembered value is
|
||||||
|
used even though the estimate is larger).
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + _dom_prelude() + r"""
|
||||||
|
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_applyUserRowIntrinsicHeight'));
|
||||||
|
// A big CJK paste whose real height >> a stale partial-paint remembered value.
|
||||||
|
const longCjk = '\u4e2d'.repeat(1500);
|
||||||
|
const estimate = _estimateUserRowIntrinsicHeight(longCjk);
|
||||||
|
// Remember a PARTIAL paint far below the estimate (the viewport-slice trap).
|
||||||
|
_rememberUserRowIntrinsicHeight(4, 900);
|
||||||
|
const row = makeRow('user', 4, 0, 0, longCjk);
|
||||||
|
_applyUserRowIntrinsicHeight(row, longCjk);
|
||||||
|
console.log(JSON.stringify({ reserved: row.style.containIntrinsicSize, estimate: estimate }));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
reserved_px = int("".join(ch for ch in m["reserved"] if ch.isdigit()))
|
||||||
|
assert reserved_px == m["estimate"], (
|
||||||
|
"apply must reserve the larger content estimate when the remembered value "
|
||||||
|
f"is a smaller partial paint; got {m['reserved']!r}, estimate {m['estimate']}"
|
||||||
|
)
|
||||||
|
assert reserved_px > 900, "the small partial-paint remembered value must not win"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_still_prefers_a_taller_remembered_full_measurement():
|
||||||
|
"""The max() must not regress the #5638 case: when a full measurement (taller
|
||||||
|
than the estimate) was remembered, the rebuild still reserves that real height.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + _dom_prelude() + r"""
|
||||||
|
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_applyUserRowIntrinsicHeight'));
|
||||||
|
_rememberUserRowIntrinsicHeight(7, 4901);
|
||||||
|
const row = makeRow('user', 7, 0, 0, 'short at build time');
|
||||||
|
_applyUserRowIntrinsicHeight(row, 'short at build time');
|
||||||
|
console.log(JSON.stringify({ reserved: row.style.containIntrinsicSize }));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["reserved"] == "auto 4901px", (
|
||||||
|
f"a taller remembered full measurement must still win; got {m['reserved']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remember_persists_only_in_viewport_rows():
|
||||||
|
"""_rememberRenderedUserRowIntrinsicHeights must persist a height ONLY for a
|
||||||
|
row currently within (or straddling) the viewport band. A fully off-screen row
|
||||||
|
reports its collapsed content-visibility reserve, not its real height;
|
||||||
|
persisting THAT would poison the remembered map and defeat the estimate
|
||||||
|
backstop for a never-seen row.
|
||||||
|
|
||||||
|
Setup: one in-view user row (top 100, height 400 -> within [0,600]) whose
|
||||||
|
measurement 400 exceeds its tiny estimate, and one far-off-screen user row
|
||||||
|
(top 9000) whose reported height is a bogus small "reserve". Only the in-view
|
||||||
|
row's height may be remembered.
|
||||||
|
|
||||||
|
Mutation: drop the `if(!inView) continue;` guard and the off-screen row's bogus
|
||||||
|
height gets persisted, failing the assertion that its key stays unset.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + _dom_prelude() + r"""
|
||||||
|
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_rememberRenderedUserRowIntrinsicHeights'));
|
||||||
|
// In-view row at session idx 1: straddles the [0,600] viewport, measured 400px.
|
||||||
|
// Give it empty rawText so its estimate is the 96 floor and the 400 measurement wins.
|
||||||
|
const inView = makeRow('user', 1, 100, 400, '');
|
||||||
|
// Far off-screen row at session idx 2: top 9000 (way past the one-screen margin),
|
||||||
|
// reports a bogus small collapsed 96px reserve.
|
||||||
|
const offScreen = makeRow('user', 2, 9000, 96, '');
|
||||||
|
__rows = [inView, offScreen];
|
||||||
|
_rememberRenderedUserRowIntrinsicHeights();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
inViewRemembered: _userRowIntrinsicHeightBySessionIdx[1] || 0,
|
||||||
|
offScreenRemembered: _userRowIntrinsicHeightBySessionIdx[2] || 0,
|
||||||
|
offScreenHasKey: (2 in _userRowIntrinsicHeightBySessionIdx),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["inViewRemembered"] == 400, (
|
||||||
|
f"the in-view painted row's real 400px must be remembered; got {m['inViewRemembered']}"
|
||||||
|
)
|
||||||
|
assert not m["offScreenHasKey"], (
|
||||||
|
"a fully off-screen row's collapsed reserve must NOT be persisted "
|
||||||
|
f"(poisons the map); got remembered={m['offScreenRemembered']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remember_floors_persisted_height_at_estimate():
|
||||||
|
"""When an in-view row is TALLER than the viewport it only paints its slice, so
|
||||||
|
its measured height is a partial value. The persisted height must be floored at
|
||||||
|
the content estimate so a partial paint can never store a value below a
|
||||||
|
reasonable full-row guess.
|
||||||
|
|
||||||
|
Setup: an in-view CJK row whose measured height (500, a partial slice) is far
|
||||||
|
below its content estimate. The remembered value must be the estimate, not 500.
|
||||||
|
|
||||||
|
Mutation: remove the `Math.max(measured, estimate)` floor in the capture and
|
||||||
|
the small 500 partial paint gets stored, failing the assertion.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + _dom_prelude() + r"""
|
||||||
|
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
|
||||||
|
eval(extractFunc('_rememberRenderedUserRowIntrinsicHeights'));
|
||||||
|
const longCjk = '\u4e2d'.repeat(1500);
|
||||||
|
const estimate = _estimateUserRowIntrinsicHeight(longCjk);
|
||||||
|
// In-view but only a 500px slice painted (row is taller than the 600px viewport).
|
||||||
|
const partial = makeRow('user', 3, 0, 500, longCjk);
|
||||||
|
__rows = [partial];
|
||||||
|
_rememberRenderedUserRowIntrinsicHeights();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
remembered: _userRowIntrinsicHeightBySessionIdx[3] || 0,
|
||||||
|
estimate: estimate,
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["remembered"] == m["estimate"], (
|
||||||
|
"a partial in-view paint must be floored at the content estimate; "
|
||||||
|
f"got remembered={m['remembered']}, estimate={m['estimate']}"
|
||||||
|
)
|
||||||
|
assert m["remembered"] > 500, "the small partial slice must not be stored as-is"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rendermessages_calls_prewipe_capture_before_wipe():
|
||||||
|
"""The pre-wipe capture must be invoked BEFORE `inner.innerHTML=''` in
|
||||||
|
renderMessages, so it reads the still-laid-out old rows. A post-wipe (or
|
||||||
|
post-render) read would see fresh, never-painted rows reporting their collapsed
|
||||||
|
reserve.
|
||||||
|
|
||||||
|
Structural guard: in the renderMessages source the
|
||||||
|
`_rememberRenderedUserRowIntrinsicHeights()` call must appear before the first
|
||||||
|
`inner.innerHTML=''`. Mutation: move the call after the wipe and this fails.
|
||||||
|
"""
|
||||||
|
js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
source = _extract_func_script(js) + r"""
|
||||||
|
eval(extractFunc('renderMessages'));
|
||||||
|
const body = renderMessages.toString();
|
||||||
|
const capIdx = body.indexOf('_rememberRenderedUserRowIntrinsicHeights(');
|
||||||
|
// Match the ACTUAL wipe statement (with trailing semicolon), not the prose
|
||||||
|
// mentions of `inner.innerHTML=''` in comments above it. Use the LAST occurrence
|
||||||
|
// so a comment reference earlier in the body cannot shadow the real wipe.
|
||||||
|
const wipeIdx = body.lastIndexOf("inner.innerHTML='';");
|
||||||
|
console.log(JSON.stringify({ capIdx: capIdx, wipeIdx: wipeIdx }));
|
||||||
|
"""
|
||||||
|
m = json.loads(_run_node(source))
|
||||||
|
assert m["capIdx"] > 0, "renderMessages must call _rememberRenderedUserRowIntrinsicHeights"
|
||||||
|
assert m["wipeIdx"] > 0, "renderMessages must wipe inner.innerHTML"
|
||||||
|
assert m["capIdx"] < m["wipeIdx"], (
|
||||||
|
"the pre-wipe capture must run BEFORE inner.innerHTML='' so it reads the "
|
||||||
|
f"still-laid-out rows; capIdx={m['capIdx']} wipeIdx={m['wipeIdx']}"
|
||||||
|
)
|
||||||
Reference in new issue
Block a user