* perf(composer): make the single-row autoResize fast path reachable autoResize() skips the height:'auto' -> scrollHeight -> restore round trip only when the composer is already at its one-row height, but the guard compared el.offsetHeight against the CSS min-height (44px) while the composer's natural one-row border box is line-height + vertical padding + borders (~48px at the stock font). 48 <= ceil(44)+1 is false, so the skip was unreachable in a real browser: every append keystroke wrote style.height twice, and the scrollHeight read that follows forces a synchronous layout of the whole document, transcript included. The cost therefore grows with the rendered transcript, which is the "typing gets laggy in long sessions" report. Accept the natural one-row height as the skip ceiling, computed from the same already-read getComputedStyle. The strict finite-pixel gate is kept for every value involved, so a percentage / calc / normal line-height, padding or min-height still fails closed to the full resize (the #6349 re-gate property is preserved, and test_issue5514_composer_grow_scroll_pin.py still pins it). Measured on a 758-message / 5 MB session (31k DOM nodes rendered) with the browser Event Timing API (input -> next paint) at 6x CPU throttle: a keystroke went from 168ms to ~136ms median echo latency (72ms with the transcript detached), 96ms to ~72ms in the windowed state, and the style writes per 8 keystrokes from 16 to 2. The #5514 invariant holds: an oversized composer still takes the full resize because its offsetHeight is far above the one-row ceiling. Tests: - new tests/test_long_session_composer_typing_latency.py runs the real autoResize() body from messages.js in a node sandbox whose getComputedStyle returns the composer's computed styles; it is red before this change (2 failed, 27 passed) and green after (29 passed). - tests/test_issue5514_composer_grow_scroll_pin.py source pins updated to the new guard shape. * test(docs): address #7604 review - behavior-only tests + composer sizing docs Review feedback on the PR flagged two repo-rule items: tests that pin source strings, and an undocumented runtime behavior change. Both are addressed here. - tests/test_long_session_composer_typing_latency.py: delete test_fix_is_pinned_in_source. It asserted exact JS declarations, which the repo testing guidance rejects and which the harness in this module already covers by executing the REAL autoResize() body. In its place add test_natural_row_skip_survives_every_append_of_a_typed_word, which drives the actual typing loop ('' -> 'h' -> ... -> 'hello') and asserts zero height writes on every keystroke. Red on the pre-fix tree, green with the fix. - tests/test_issue5514_composer_grow_scroll_pin.py: convert test_single_line_growth_skips_the_height_round_trip from source-string pins to a behavior fixture whose getComputedStyle returns the composer's real computed styles. It asserts the observable outcome: a one-row append at the natural 48px row takes no height write, while an oversized 176px composer still remeasures back to one row. Red before the fix (2 writes), green after. - docs/UIUX-GUIDE.md: document the composer sizing invariants in the composer section - the single-row skip is a hot path and must stay reachable, its ceiling is the natural one-row height or the CSS min-height (whichever is larger), everything else still remeasures, and non-px computed values fail closed. ./scripts/test.sh on the two touched modules: 29 passed (3 failed on the pre-fix tree). Neighbour sweep over the 51 composer/scroll/typing modules: 386 passed. ruff check clean. * fix(tests,docs): derive composer test dimensions from style.css (#7604 review) Greptile's re-review is right: the fixtures hardcoded the 18px `data-font-size=large` composer (line-height 29.7px -> a 47.7px one-row box) as if it were the stylesheet's 16px default (44.4px). At the default the pre-fix ceiling (ceil(44)+1 = 45px) DID clear the one-row box, so the regression is font-size dependent - the 16px default reaches the skip, the 18px and 20px appearance settings do not - and not a stock-configuration bug. The runtime change is unaffected and unchanged. - new `tests/_composer_metrics.py`: parses `static/style.css` (the base `textarea#msg` rule, the `data-font-size` overrides, the skin overrides) and exposes each configuration's box metrics - natural one-row height, the integer offsetHeight a browser reports, and both the pre-fix and post-fix ceilings - so a fixture cannot encode the wrong font size again. - `tests/test_long_session_composer_typing_latency.py`: parameterized over every configuration the stylesheet can produce, with `test_the_regression_was_font_size_dependent_not_stock` pinning the grounding. - `tests/test_issue5514_composer_grow_scroll_pin.py`: the converted single-row test uses the same derived metrics. - `docs/UIUX-GUIDE.md`: the sizing section now states that the natural row tracks the appearance font size (44 / 48 / 51px) while `min-height` stays 44px, which is why a min-height-only ceiling silently disables the skip. Pre-fix tree: `4 failed, 31 passed` - exactly the large-18px and xlarge-20px parameterizations plus the two tests that assert the reported configuration. Post-fix tree: `35 passed`. Neighbour sweep over the 51 composer/scroll/typing modules: `392 passed`. ruff clean.
263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""Regression coverage for composer typing lag in long chat sessions.
|
|
|
|
Symptom: in a long session, typing becomes laggy - keystrokes echo with a
|
|
visible delay. Measured on a 758-message / 5 MB session (31k DOM nodes
|
|
rendered), with the browser's own Event Timing API (input -> next paint) at 6x
|
|
CPU throttle:
|
|
|
|
transcript rendered keystroke -> echo (median)
|
|
full (36 rows) @6x 168ms -> 136ms with this fix
|
|
windowed (11 rows) @6x 96ms -> 72ms with this fix
|
|
floor (transcript detached) 72ms
|
|
|
|
Root cause: ``autoResize()`` in ``static/messages.js`` has a single-row fast path
|
|
that must skip the ``height:'auto'`` -> read ``scrollHeight`` -> restore round
|
|
trip. That round trip forces a SYNCHRONOUS layout of the whole document (the
|
|
transcript included), so its cost grows with the rendered transcript - exactly
|
|
the reported symptom. The guard that gates the skip compared ``el.offsetHeight``
|
|
against the CSS ``min-height`` (44px -> a ceiling of ``ceil(44)+1 = 45px``)
|
|
instead of the composer's natural ONE-ROW height - ``line-height + vertical
|
|
padding + borders``.
|
|
|
|
Which configurations that broke is a property of the stylesheet, so the numbers
|
|
below are DERIVED from ``static/style.css`` by ``tests/_composer_metrics.py``
|
|
rather than typed into a fixture (an earlier revision of this module hardcoded
|
|
the 18px numbers and mislabelled them as the stock font size):
|
|
|
|
composer config one-row box pre-fix ceiling skip fired?
|
|
default 16px 44px 45px yes (0.6px margin)
|
|
data-font-size=large (18px) 48px 45px NO - dead code
|
|
data-font-size=xlarge (20px) 51px 45px NO - dead code
|
|
skin graphite (14px/1.45) 44px 45px yes
|
|
|
|
So the fix is font-size independent: it derives the skip ceiling from the same
|
|
computed styles the guard already reads, and therefore also covers the smaller
|
|
configurations (which only cleared the old ceiling by 0.6px, or purely because
|
|
``min-height`` floored the box).
|
|
|
|
Fix: accept the natural one-row height (computed from line-height + padding +
|
|
borders) as the skip ceiling, failing closed to the old min-height-only
|
|
behaviour whenever those computed values are not strict px values (so a
|
|
percentage / ``normal`` line-height cannot wrongly enable the skip).
|
|
|
|
These tests exercise the REAL ``autoResize()`` body extracted from messages.js in
|
|
a node sandbox whose ``getComputedStyle`` returns the requested composer's real
|
|
computed styles. ``test_skip_is_reachable_in_every_composer_configuration`` is
|
|
red on the pre-fix tree for the 18px/20px configurations and green after it.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests._composer_metrics import (
|
|
COMPOSER_CONFIGS,
|
|
CONFIG_DEFAULT,
|
|
CONFIG_LARGE,
|
|
CONFIG_SKIN,
|
|
CONFIG_SMALL,
|
|
CONFIG_XLARGE,
|
|
)
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _autoresize_body() -> str:
|
|
start = MESSAGES_JS.find("function autoResize(")
|
|
assert start != -1
|
|
end = MESSAGES_JS.find("function scheduleComposerAutoResize(", start)
|
|
assert end > start
|
|
return MESSAGES_JS[start:end]
|
|
|
|
|
|
def _run_autoresize(config, *, value: str, previous_value: str, box_height: int,
|
|
content_height: int, computed=None):
|
|
"""Run the real autoResize() against a faithful textarea stub.
|
|
|
|
``box_height`` is the composer's current offsetHeight and ``content_height``
|
|
the height its content WANTS (one row = the config's one-row box, two rows =
|
|
``config.content_two_rows``); the stub models a real textarea, whose
|
|
scrollHeight is the box height while the box is taller than the content and
|
|
the content height once the box collapses to ``height:'auto'``.
|
|
"""
|
|
node = shutil.which("node")
|
|
assert node, "node is required for the autoResize harness"
|
|
body = _autoresize_body()
|
|
harness = textwrap.dedent(
|
|
"""
|
|
let _composerAutoResizeRaf = 0;
|
|
let _composerLastResizeValue = %(previous_value)r;
|
|
let writes = 0, height = %(box_height)s;
|
|
const NATURAL_ROW = %(natural_row)s;
|
|
const CONTENT_H = %(content_height)s;
|
|
const msg = {
|
|
value: %(value)r,
|
|
get offsetHeight() { return height; },
|
|
get scrollHeight() { return height > NATURAL_ROW ? height : CONTENT_H; },
|
|
style: {
|
|
set height(v) { writes += 1; height = v === 'auto' ? NATURAL_ROW : parseInt(v, 10); },
|
|
get height() { return height + 'px'; },
|
|
},
|
|
};
|
|
const messages = { scrollTop: 0 };
|
|
const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null;
|
|
const COMPUTED = %(computed)s;
|
|
function getComputedStyle() { return COMPUTED; }
|
|
let sendUpdates = 0;
|
|
function updateSendBtn() { sendUpdates += 1; }
|
|
function _repinMessagesAfterComposerResize() {}
|
|
%(autoresize)s
|
|
autoResize();
|
|
console.log(JSON.stringify({ writes, height, lastValue: _composerLastResizeValue, sendUpdates }));
|
|
"""
|
|
) % {
|
|
"previous_value": previous_value,
|
|
"value": value,
|
|
"box_height": box_height,
|
|
"content_height": content_height,
|
|
"natural_row": config.offset_height,
|
|
"computed": json.dumps(config.computed if computed is None else computed),
|
|
"autoresize": body,
|
|
}
|
|
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
@pytest.mark.parametrize("config", COMPOSER_CONFIGS, ids=lambda c: c.name)
|
|
def test_skip_is_reachable_in_every_composer_configuration(config):
|
|
"""THE typing-lag regression: at its natural one-row height an append
|
|
keystroke must NOT run the height round trip, in every configuration the
|
|
stylesheet can produce.
|
|
|
|
Pre-fix the one-row box was compared against the 44px CSS min-height (a
|
|
45px ceiling). That still cleared the 16px default (44px) by 0.6px, but the
|
|
18px/20px ``data-font-size`` composers sit at 48px/51px, where the skip was
|
|
dead code and every keystroke forced a synchronous full-document reflow.
|
|
"""
|
|
out = _run_autoresize(
|
|
config, value="a", previous_value="", box_height=config.offset_height,
|
|
content_height=config.offset_height,
|
|
)
|
|
assert out["writes"] == 0, f"one-row append must skip the resize round trip; got {out}"
|
|
assert out["height"] == config.offset_height, out
|
|
assert out["lastValue"] == "a", out
|
|
assert out["sendUpdates"] == 1, "the primary-button refresh must still run"
|
|
|
|
|
|
def test_the_regression_was_font_size_dependent_not_stock():
|
|
"""Grounding for this fix, straight from the stylesheet.
|
|
|
|
This is the check the first revision of this module failed: it asserted a
|
|
48px "stock" composer, which is the 18px ``data-font-size=large`` box. The
|
|
real default (16px) already reached the old ceiling, so the fix is about
|
|
making the skip font-size independent - not about a default the reporter
|
|
never ran.
|
|
"""
|
|
assert CONFIG_DEFAULT.offset_height <= CONFIG_DEFAULT.old_ceiling, (
|
|
"the stylesheet default is expected to have reached the pre-fix ceiling"
|
|
)
|
|
assert CONFIG_LARGE.offset_height > CONFIG_LARGE.old_ceiling
|
|
assert CONFIG_XLARGE.offset_height > CONFIG_XLARGE.old_ceiling
|
|
# ...and after the fix every configuration's one-row box clears the ceiling,
|
|
# including the two that were dead and the two that were already fine.
|
|
for config in COMPOSER_CONFIGS:
|
|
assert config.skip_is_reachable, f"{config.name} must reach the skip after the fix"
|
|
assert CONFIG_SMALL.offset_height == CONFIG_SMALL.min_height, (
|
|
"the small configuration's box is floored by min-height, not by its text"
|
|
)
|
|
assert CONFIG_SKIN.offset_height == CONFIG_SKIN.min_height
|
|
|
|
|
|
def test_typed_word_appends_skip_in_the_reported_configuration():
|
|
"""The reported lag configuration (18px composer) must skip on EVERY append
|
|
keystroke, not just the first: the real typing loop is '' -> 'h' -> 'he' ...
|
|
"""
|
|
previous = ""
|
|
for letter in "hello":
|
|
value = previous + letter
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value=value, previous_value=previous,
|
|
box_height=CONFIG_LARGE.offset_height,
|
|
content_height=CONFIG_LARGE.offset_height,
|
|
)
|
|
assert out["writes"] == 0, f"append {value!r} must skip; got {out}"
|
|
assert out["lastValue"] == value, out
|
|
previous = value
|
|
|
|
|
|
def test_oversized_composer_still_remeasures_to_its_natural_height():
|
|
"""The skip must not preserve an oversized composer (the #5514 invariant).
|
|
|
|
A composer several rows tall holding a one-line value remeasures down to one
|
|
row: its offsetHeight is far above the natural one-row ceiling.
|
|
"""
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value="short prefix", previous_value="short prefi",
|
|
box_height=CONFIG_LARGE.oversized_box, content_height=CONFIG_LARGE.offset_height,
|
|
)
|
|
assert out["writes"] == 2, f"an oversized composer must remeasure; got {out}"
|
|
assert out["height"] == CONFIG_LARGE.offset_height, out
|
|
|
|
|
|
def test_single_line_delete_still_runs_the_height_round_trip():
|
|
"""Shrinking values are not append-only and must remeasure."""
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value="a", previous_value="hello",
|
|
box_height=CONFIG_LARGE.oversized_box, content_height=CONFIG_LARGE.offset_height,
|
|
)
|
|
assert out["writes"] == 2, f"a delete must remeasure; got {out}"
|
|
assert out["height"] == CONFIG_LARGE.offset_height, out
|
|
|
|
|
|
def test_multi_line_append_still_runs_the_height_round_trip():
|
|
"""A newline append is not a one-row append: it must measure and grow."""
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value="a\nb", previous_value="a",
|
|
box_height=CONFIG_LARGE.offset_height,
|
|
content_height=CONFIG_LARGE.content_two_rows,
|
|
)
|
|
assert out["writes"] == 2, f"newline growth must remeasure; got {out}"
|
|
assert out["height"] == CONFIG_LARGE.content_two_rows, out
|
|
|
|
|
|
def test_non_px_computed_values_fail_closed_to_min_height_only():
|
|
"""Without strict px padding/line-height values the skip falls back to the
|
|
pre-fix min-height-only semantics, i.e. the 18px one-row box remeasures."""
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value="a", previous_value="",
|
|
box_height=CONFIG_LARGE.offset_height,
|
|
content_height=CONFIG_LARGE.offset_height,
|
|
computed={"minHeight": "44px"},
|
|
)
|
|
assert out["writes"] == 2, f"must fail closed to the full resize; got {out}"
|
|
|
|
|
|
def test_percentage_line_height_fails_closed():
|
|
"""A percentage line-height is not a strict px value and must not enable the
|
|
skip (mirrors the #6349 min-height gate)."""
|
|
out = _run_autoresize(
|
|
CONFIG_LARGE, value="a", previous_value="",
|
|
box_height=CONFIG_LARGE.offset_height,
|
|
content_height=CONFIG_LARGE.offset_height,
|
|
computed={**CONFIG_LARGE.computed, "lineHeight": "165%"},
|
|
)
|
|
assert out["writes"] == 2, f"percentage line-height must fail closed; got {out}"
|
|
|
|
|
|
def test_percentage_min_height_fails_closed():
|
|
"""A percentage min-height must not be read as a pixel number by
|
|
``parseFloat`` (``parseFloat('50%') === 50``) - the #6349 property, still in
|
|
force after this change: no strict px min-height means no reachable ceiling."""
|
|
out = _run_autoresize(
|
|
CONFIG_DEFAULT, value="a", previous_value="",
|
|
box_height=CONFIG_DEFAULT.offset_height,
|
|
content_height=CONFIG_DEFAULT.offset_height,
|
|
computed={**CONFIG_DEFAULT.computed, "minHeight": "50%"},
|
|
)
|
|
assert out["writes"] == 2, f"percentage min-height must fail closed; got {out}"
|