mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-21 07:00:23 +00:00
fix(webui): reserve real user-row height in non-virtualized transcripts to stop scroll jump-back
Clean rebase of allenliang2022's #5751 (rebase-first). Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
This commit is contained in:
+102
-6
@@ -1291,19 +1291,42 @@ function _rememberUserRowIntrinsicHeight(sessionMsgIdx, height){
|
||||
function _estimateUserRowIntrinsicHeight(rawText){
|
||||
const t=String(rawText||'');
|
||||
if(!t) return 96;
|
||||
// ~48 chars/line at the mobile user-bubble width (≈90% of a phone viewport), ~22px per
|
||||
// line + ~24px row chrome; floored at the stylesheet's 96px so a short row never reserves
|
||||
// LESS than today (estimate can only add reserved height for tall rows, never regress).
|
||||
// ~48 half-width chars/line at the mobile user-bubble width (≈90% of a phone viewport),
|
||||
// ~22px per line + ~24px row chrome; floored at the stylesheet's 96px so a short row never
|
||||
// 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 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);
|
||||
return Math.max(96, Math.round(lines*22+24));
|
||||
}
|
||||
function _applyUserRowIntrinsicHeight(row, rawText){
|
||||
if(!row||!row.style||!row.dataset) return;
|
||||
const key=Number(row.dataset.sessionMsgIdx);
|
||||
let h=Number.isFinite(key)?_userRowIntrinsicHeightBySessionIdx[key]:0;
|
||||
if(!(h>0)) h=_estimateUserRowIntrinsicHeight(rawText!=null?rawText:row.dataset.rawText);
|
||||
const remembered=Number.isFinite(key)?Number(_userRowIntrinsicHeightBySessionIdx[key])||0:0;
|
||||
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';
|
||||
}
|
||||
function _measureMessageVirtualRow(inner, entry){
|
||||
@@ -1361,6 +1384,70 @@ function _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs,
|
||||
_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){
|
||||
const container=$('messages');
|
||||
const inner=$('msgInner');
|
||||
@@ -14049,6 +14136,15 @@ function renderMessages(options){
|
||||
if(!_m) return false;
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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