diff --git a/static/ui.js b/static/ui.js index 19ee2f27..adff96c9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -789,10 +789,14 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ _resetMessageVirtualMeasurementBurst(); return; } - if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){ - // Repeated key: the window is oscillating, not converging. End the burst - // (no further internal re-render is scheduled), so the next externally - // initiated cycle starts fresh instead of being starved of retries. + const lastKey = _messageVirtualMeasurementSeenKeys.length ? _messageVirtualMeasurementSeenKeys[_messageVirtualMeasurementSeenKeys.length - 1] : null; + if(cycleKey !== lastKey && _messageVirtualMeasurementSeenKeys.includes(cycleKey)){ + // Non-consecutive repeated key (A->B->A): the window is oscillating, not + // converging. End the burst (no further internal re-render is scheduled), + // so the next externally initiated cycle starts fresh instead of being + // starved of retries. A consecutive same-key pass (A->A) proceeds because + // heights changed while the window bounds did not (e.g. row shrink across + // two passes), still bounded by the absolute per-burst cap (#6717 re-gate). _resetMessageVirtualMeasurementBurst(); return; } diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 8bb93b85..4b6e2af8 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1284,6 +1284,42 @@ console.log(JSON.stringify({ ) +def test_consecutive_same_key_row_shrink_converges_to_settled(): + """#6717 re-gate: consecutive same-key passes (e.g. A->A->B->settled from + a row shrinking across two measurement passes with unchanged window bounds) + must NOT be cut short as oscillation. Only non-consecutive repeats (A->B->A) + represent cyclic oscillation; consecutive same-key passes continue toward + convergence while remaining bounded by the absolute cap.""" + source = _measurement_burst_harness(""" +measurePlan = ['A', 'A', 'B']; // R0 measures A, R1 internal measures A (consecutive), R2 internal measures B, then settled +renderMessages({}); // external trigger: measures A -> burst starts +flushRaf(); flushRaf(); // L1 -> L2: internal render measures A again (consecutive same key) -> must proceed! +flushRaf(); flushRaf(); // L1 -> L2: internal render measures B -> proceeds! +flushRaf(); flushRaf(); // L1 -> L2: internal render measures settled +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["internal"] == 3, ( + "consecutive same-key pass (row shrink) must NOT be cut short as oscillation: " + f"got {metrics['internal']} internal renders (expected 3)" + ) + assert metrics["renders"] == 4, ( + f"burst must converge to settled: {metrics['renders']} renders (expected 4 = 1 external + 3 internal)" + ) + assert metrics["burstActive"] is False, ( + f"burst must settle: burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + f"settlement must clear seen-key memory: seenKeys {metrics['seenKeys']}" + ) + + def test_genuine_convergence_exceeding_two_renders_completes(): """#6717 re-gate (b): a legitimate forward convergence A->B->C->settled (content reflow, fonts/images loading late, dynamic height) must be allowed