/virtual-list-debug — instruments and workflow

SkillDev tools

Instruments and workflow for debugging the VirtualList — the chat transcript (`InfiniteList`) and the sidebar (`FiniteList`). Covers the built-in consistency checker, the on-screen overlay, the five `?vl*` URL flags, the touch-gesture rig, the frame recorder, attaching to a real Android device over CDP, and how to read a trace without being fooled by a measurement artifact. Use when the list jumps while scrolling, blanks, sticks on skeletons, or the overscroll/bounce misbehaves. The component's specification is `docs/ui/virtual-list.md`; this skill is only how to *measure* it. Pairs with /debug-ui (browser side) and /server-loop (server side).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the /virtual-list-debug — instruments and workflow skill

What this skill tells your AI

The instructions your AI receives, as published by actual-chat/actual-chat in .claude/skills/virtual-list-debug/SKILL.md and read by ahel’s review.

Ground rules before you measure anything

The spec is docs/ui/virtual-list.md (132KB). Look invariants up there; do not trust a summary, including this one. The section map you will actually use:

NeedSection
Vocabulary — chainStart, scrollOffset, pinning vs. anchoring§1
The eleven enforced invariants§2
The model and the two position terms§3.1
Render direction (Natural | Reverse — there is no Auto)§3.2
States, transitions, loops, which term may move when§3.3–§3.6
Overscroll: the band, resistance, bounce, floor — what the rig judges§3.7
Spacers, end anchor, short conversations§3.8
Re-anchoring§3.9
Item heights and stability§3.10
Rules that came from painful debugging§3.12
Browser/OS/device quirks — read §4.11 before injecting any gesture§4
Measuring, and the traps that gave confidently wrong answers§5
FiniteList§6

Two facts that decide whether a measurement means anything:

  • scrollTop alone is meaningless. What the user sees is the sum of three terms (§3.1): scrollTop, the container's position in the wrapper, and the composed transform. Each moves content without the others moving. The only honest measure is the on-screen position of a real item, followed by key, sampled every frame.
  • The wrapper is a fixed 4,000,000px (InfiniteSize, mirrored in InfiniteList.razor.cs and infinite-list.ts — the two must agree). It never resizes for the life of the list. Nothing in the current design shrinks a spacer to re-origin the coordinate system.

1. The instruments

1.1 The consistency checker

debugUI.virtualListDebug(true)                   // preferred; warns if InfiniteList isn't loaded yet
globalThis.InfiniteList.setDebugEnabled(true)    // same thing, one level down

Sets the static InfiniteList.isDebugEnabled flag, so it covers every live InfiniteList and every one created afterwards. State is per page — a reload drops it. FiniteList is not instrumented.

It runs checkModelDrift(reason) from exactly three places in infinite-list.ts:

  • render — after a render has been applied,
  • settled — when the list comes to rest,
  • setDebugEnabled — once, immediately, on every live list when you turn it on.

checkModelDrift bails out early when there are fewer than two items or stability.isAnimating (height animations in flight), so a silent checker during an animation is not a pass. It picks the first non-position: sticky item as the baseline — conversation headers report where they are stuck, not where they sit in the flow — and compares every other non-sticky item's real getBoundingClientRect().top against offsets[i] - offsets[base]. Only the worst offender is reported, past DriftWarnThresholdPx (8px).

The same pass always calls checkContentOverflow(reason), which walks every item that is not c-height-unsettled and compares its box against the height its content actually requires (content box + content margins + item padding + borders). Two warnings come out of it: an item whose content needs more than ContentOverflowThresholdPx (2px) more than its box — that surplus paints straight over the item below — and an item that renders more than one child element, since only the first is measured.

Output is warnLog only. There is no violation buffer, no ring, nothing to drain, no return value — you read the browser console. The InfiniteList scope defaults to Warn, so these appear without any setup. To also see the flow (repinEdge, jump, applyLayout: re-centred):

logLevels.override('InfiniteList', 1)   // 1 = Debug; persisted in localStorage + IndexedDB
logLevels.reset()                       // back to package defaults

ScrollController's own backstop bypasses the log scopes entirely and calls console.warn directly: ScrollController: phase '<phase>' stopped advancing means a phase ran LockWatchdogMs (1500ms) without finishing and the scroller was handed back. That message is always a defect.

What the checker does not cover: blank viewports, gaps between items, scroll clamps, jumps during an animation, anything in FiniteList, and anything about the band. For those, record a trace (§3).

1.2 The on-screen overlay

debugUI.showVirtualListOverlay(true)    // or false

A one-line readout pinned to the top-right of every virtual list, infinite and finite. It persists in UserAppSettings.IsVirtualListOverlayEnabled and is re-applied at startup by DebugUI.RestoreVirtualListOverlaydebugUI.applyVirtualListOverlay (which does not write back). There is no Settings-UI toggle; the console is the only entry point. It refreshes every 200ms and reads only inline styles, so it costs no layout. It lives in document.body, because .virtual-list sets contain: strict, which clips even fixed-position descendants.

A chat scrolled to the newest with older messages still to load:

↑  ⇊⟳  ~(5/48]~  h=115.7

The layout is fixed — glyphs never appear or disappear, only their colour changes, so the bar never reflows. Dark gray (off) means "not this / not happening".

FieldMeaning
/ render direction — = reverse (cyan, the notable state), = natural (light gray). The chat view is always Reverse
a data request is in flight (amber)
a render/animation is in progress (cyan)
~~start / end spacer shown, amber when lit. They sit outside the brackets because that is where a spacer physically is
[ ] vs ( )square = that end of the data is loaded (hasVeryFirstItem / hasVeryLastItem), round = there is more that way
bracket colourgreen = that end is the pinned edge. Glyph and colour are independent because loaded-ness and pinnedness are
5/48infinite: visible / rendered. total is null for infinite lists, so nothing follows the slash before the closing bracket
6(0-6)/6finite: visible(first-last rendered index) / total
h=115.7mean height of the items rendered right now

Two things make it watchable: and stay lit for HoldMs (500ms) after they end, driven by lastDataRequestAt / lastRenderAt, so a request that begins and ends between two ticks is still drawn; and a changed value flashes white for the same window, keyed on text plus tone, because with a fixed layout most state changes are colour-only. Counters do not flash and have reserved widths, so the bar does not strobe or resize as numbers cross a power of ten.

1.3 URL flags

Five, all read from location.search. Put them in the URL and reload — ?vlloaddelay and ?vlfriction are read once at module load, ?vllock, ?vltakeover and ?vlwheel are memoized on first use.

FlagWhereWhat it does
?vlloaddelay=<ms>virtual-list.ts readLoadDelayHolds every data load for that long. A fast fling into history is only interesting while the loads cannot keep up with it, and that state is otherwise a race to catch. This is how you reproduce continuous "load more" on demand
?vllock=0 / =1scroll-controller.ts canLockOverflowForces the two-frame overflow: hidden fling kill off / on. Default is on everywhere except WebKit. 0 reproduces the "nothing stops a fling" half of the iOS problem on desktop
?vltakeover=0 / =1scroll-controller.ts canTakeOverMomentumForces the WebKit release takeover off / on. Default is iOS+WebKit only. 1 exercises the FLIP handoff and the spring on desktop Chrome
?vlfriction=<max>x<ramp>scroll-controller.ts readFrictionOverrides the resistance curve (default 0.667x444) for feel tuning on a device. max must be in (0, 1)
?vlwheel=0scroll-controller.ts canOwnWheelGesturesStops the controller driving precise-device (trackpad / precision touchpad) wheel gestures near a limit, handing them back to the browser. Reproduces the edge jitter the takeover exists to remove — useful for A/B on a device

Also useful: /test/virtual-list (admin only) takes RangeSeed, ContentSeed, DefaultEdge, RenderDirection (0 = Natural, 1 = Reverse), AnimateItemHeight, HeightTransition, HeightDelay. Pin RangeSeed and ContentSeed unless the churn is the subject — otherwise the page re-seeds its range every 3s and item content every 10s and moves under your measurement. Note §4.10: at phone width the chat-list panel is painted over that page and hit-tests every point of it, so injected gestures never reach the list there; real-gesture tests have to run against a chat.

1.4 The rig — tools/virtual-list-rig/

rig.mjs drives real touch gestures into a Chrome debug port over CDP, records the controller frame by frame with recorder.js, and judges the result mechanically against the rules in §3.7. This is how the overscroll model is verified; the phones are for feel.

Needs Chrome started with remote debugging (ai chrome, port 9222; ai chrome*2 adds 9223), a Voxt chat open in it, and the server running. Give the page a mobile viewport first — at desktop width the chat view is not the touch-scrolling element (chrome-devtools MCP: emulate with 412x915x2.6,mobile,touch).

node tools/virtual-list-rig/rig.mjs all 9222              # every scenario, lock on
node tools/virtual-list-rig/rig.mjs all 9222 nolock       # ordinary path without the two-frame overflow kill
node tools/virtual-list-rig/rig.mjs all 9222 takeover     # force the iOS takeover on Chrome
node tools/virtual-list-rig/rig.mjs swing-back 9222       # one scenario
node tools/virtual-list-rig/soak.mjs 60 9223              # 60 random gestures, judged as a whole
node tools/virtual-list-rig/soak.mjs 60 9223 takeover
node tools/virtual-list-rig/follow.mjs 9223               # the follow's write path, scroll vs transform

Scenarios: pull-release, throw-out, throw-top, swing-back, catch, catch-drag, updown, brake, repeat-catch, repeat-updown, control-fling, fling-edge, native-resume (takeover only), cross-and-back. Traces land in tmp/traces/rig-<scenario>.json; the soak's in tmp/traces/soak.json. soak.mjs uses a fixed seed, so a failing run re-runs identically.

Run the matrix on two chats: one longer than the viewport (a real band) and one shorter (a band collapsed to a point, min == max). Both must pass on the ordinary lock/nolock paths and with takeover forced.

The judge checks rules, not feel: the band never inverts, no gesture starts inside a band, every excursion ends with the band transform at zero and the position legal, the finger is followed through the curve's slope, the band never moves by more than the rules allow, and the transform is the band's alone - what is left of it once the band's share is taken out has to be zero on every frame, which is what makes "the list writes no transform" a checked property. coast after release on swing-back and updown should match control-fling — a throw from overscroll is a throw. On fling-edge the excursion should go out to roughly MaxBouncePx (150px) past where it was noticed before coming home; that is the bounce.

Three things the rig cannot do. Synthetic CDP touch drops flings intermittently unless moves are ~12ms apart and sent without awaiting each ack — so a single zero coast proves nothing, repeat it. Desktop Chrome does not scroll off the main thread the way WebKit does, so the iOS-specific jitter does not reproduce here; nolock reproduces the "nothing stops a fling" half only. And it cannot reproduce iOS choosing an unscrollable target before touchstart: catch-drag proves that the controller releases its lock and preserves geometry, not that the same caught gesture can resume native scrolling on iOS.

Both scripts turn the checker on and collect its console warnings over CDP themselves (Runtime.consoleAPICalled), so the violations column is the checker's real verdict for the run; the traces under tmp/traces/ carry the same list as violations.

1.5 The recorder — tools/virtual-list-rig/recorder.js

A self-contained IIFE you evaluate in the page. It finds .virtual-list.infinite-list, waits for its scrollController (the controller sets that expando on its element in its constructor), and installs itself, re-arming every 500ms so it survives the list being re-created by navigation. It exposes:

window.__vlt.rows      // per-frame samples (capped at 12000)
window.__vlt.events    // touchstart/move/end/cancel (capped at 6000)
window.__vlt.stop()    // restores onTransform, removes the document listeners

Why it does not sample only on rAF — read this before writing your own tracer. A recorder that samples on its own requestAnimationFrame can run before the controller's frame callback and so reads the new scrollTop against last frame's transform. That produces a phantom step of exactly the scroll delta on every moving frame — a perfectly convincing, entirely fictional jump on every frame of every fling. recorder.js therefore hooks sc.onTransform (chaining the previous handler) and takes an authoritative sample in a microtask right after the controller writes; its rAF loop is only a fallback for frames where nothing was written, and an authoritative sample replaces a rAF sample from the same frame. Samples are throttled to 4ms apart.

Row fields:

FieldMeaning
tms since the recorder armed
toplist.scrollTop, native
tfthe composed translate3d y on .c-virtual-container
basetf - band — whatever is on the transform that is not the band's. The list writes no transform, so this is 0 on every frame, and the judge fails a run where it is not
phasein-band | following (finger down past an edge) | engaged (past an edge, nobody holding it)
decisionmomentumPhase: none | arming | transform (the WebKit takeover)
viswhat the band puts on screen — signedOverscroll(over), i.e. after resistance. 0 when in-band
bandscrollController.bandOffset — the band's own share of the transform
driftthe raw pull over behind vis. 0 when in-band
sprsigned springVisible during a takeover
spscrollSpeed in px/s (the return phase's measurement; 0 unless engaged)
lock1 while overflow-y: hidden is forced on the scroller
min, maxgetEffectiveScrollLimits(), in the same scrollTop coordinate as top
cythe container's top relative to the list's top
ch, clcontainer height, list clientHeight

Event fields: t, type, n (touch count), y (mean clientY), top, phase, decision.


2. Attaching to a real device over CDP

Android Chrome

adb devices                                             # confirm the phone is authorized
adb forward tcp:9333 localabstract:chrome_devtools_remote
curl -s http://localhost:9333/json/list | jq -r '.[] | select(.type=="page") | "\(.url)\t\(.webSocketDebuggerUrl)"'

Forward to a free port. Host Chrome usually owns 9222 and 9223 (ai chrome, ai chrome*2), and adb forward tcp:9222 … will either fail or shadow the desktop browser the rig expects. 9333 is free in practice.

Then attach with ws (already a dependency, ^8.18.0, installed) and follow the pattern in rig.mjs: open the socket from webSocketDebuggerUrl, Runtime.enable, and evaluate with Runtime.evaluate + returnByValue: true (add awaitPromise: true for async expressions, and always check exceptionDetails — a page-side throw comes back as a successful CDP response).

import { createRequire } from 'node:module';
import fs from 'node:fs';
// Resolved against this script's own location — adjust the hops if it doesn't sit in tools/<dir>/.
const require = createRequire(new URL('../../package.json', import.meta.url));
const WebSocket = require('ws');

const pages = await (await fetch('http://localhost:9333/json/list')).json();
const target = pages.find(x => x.type === 'page' && (x.url || '').includes('voxt.ai'));
const ws = new WebSocket(target.webSocketDebuggerUrl, { perMessageDeflate: false });
// ... id/pending plumbing exactly as in rig.mjs ...
await send('Runtime.enable');
await ev(fs.readFileSync('tools/virtual-list-rig/recorder.js', 'utf8'));
// ... gesture ...
const trace = JSON.parse(await ev('JSON.stringify({ rows: window.__vlt.rows, events: window.__vlt.events })'));

The recorder is device-agnostic — it is exactly what the phones used. Screen on and the tab in the foreground, or you get no animation frames and an empty trace.

What actually moves the list, and what does not

  • Input.synthesizeScrollGesture with gestureSourceType: 'touch' does not move the list. Verified: delta 0. On this page it delivers touchstart: 1, touchmove: 0, touchend: 1 (§4.11). Do not build anything on it.
  • Input.dispatchTouchEvent sequences do, once Emulation.setTouchEmulationEnabled is on — but only if the moves arrive close together. Moves more than ~25ms apart get the fling dropped intermittently. rig.mjs's fling() is the working pattern: fire moves 12ms apart without awaiting each ack, collect the promises, Promise.all them, then touchEnd.
  • A mouse or wheel gesture is useless for anything past an edge. ScrollController.onScroll snaps scrollTop back to the boundary whenever the motion is not a finger (!(isTouching || isTouchMotion)) — by design, and that includes middle-button autoscroll. You will never see a band from one.
  • Over adb, Input.dispatchTouchEvent arrives too unevenly for Chrome's velocity tracker to read as a throw — it scrolls but never flings (§4.10). On a phone use adb shell input swipe, and map page → screen coordinates as chrome + cssY * devicePixelRatio where chrome = screenHeight - innerHeight * devicePixelRatio (~532px on a 3120px device; ignoring it puts every gesture in the URL bar).
  • Bring the tab to the front (Page.bringToFront) and check that the point you aim at hit-tests into the list.

3. Reading a trace

Load a trace and go through these in order. Each one found a real defect; none of them is scrollTop on its own.

1. Distance past min/max while phase === 'in-band'. In-band means the controller believes the position is legal. If top < min or top > max while in-band, the crossing was never noticed and no band was drawn — the list is simply somewhere it must not be.

const escapes = rows.filter(r => r.phase === 'in-band' && (r.top < r.min - 1 || r.top > r.max + 1));
const worst = Math.max(0, ...escapes.map(r => Math.max(r.min - r.top, r.top - r.max)));

A frame or two just after a load is not automatically a bug — the limits are recomputed from the model and a page of history arriving moves them. A sustained run, or an excursion of hundreds of px, is.

2. Unbroken following runs. following means a finger is down past an edge. A long run of it with no touchend in events is a release that never registered — the list stays parked and the band never returns. There are three backstops (TouchStaleMs 3000, TouchSilenceMs 400, LockWatchdogMs 1500), so a run longer than ~1.5–3s means one of them fired; find the matching console.warn.

3. lock frames. lock === 1 is overflow-y: hidden forced on the scroller — the user cannot scroll at all during those frames. Two-frame runs are the intended fling kill. Long runs, or a run that ends the trace, mean the scroller was never handed back.

4. Peak |vis|, and how far the position travelled. vis is what the band actually puts on screen after resistance, so it is bounded by the curve (0.667 max resistance over a 444px ramp) and by MaxBouncePx (150) for a release. A large |vis| is a band that was seeded wrong. Keep it separate from ordinary travel: the limits themselves already grant up to three screens (MaxOverscrollScreens) past loaded content whenever that end of the data is not yet known (§3.7) — travelling into that space is legal and is how reading further back begins. It is not overscroll, and "fixing" it re-breaks catch-up.

5. Then, and only then, rendered motion.

The container moving is not a jump. cy (and the container's top) move on every prepend by construction: loading history changes chainStart and the container's position by the same amount, so the items inside it do not move at all (§3.1). A check on cy, or on container geometry alone, will report a screenful of motion that never happened.

The only honest rendered-motion check follows a specific item by key across frames:

const el = document.querySelector('.virtual-list.infinite-list .item[data-key="<key>"]');
const y = el.getBoundingClientRect().top;   // sample this every frame, compare consecutive samples

Items carry data-key on the li.item (groups are li.group and carry none), which is the same handle infinite-list.ts itself indexes by.

When something does move, decompose it into the three terms that can cause it (§3.1): the container's position in wrapper coordinates, the item's offset within the chain, and the scroll position.

Artifacts that look exactly like bugs

  • A rAF-only recorder reports a phantom step of exactly the scroll delta on every moving frame. See §1.5. This is the single most expensive trap here.
  • rAF sampling lags a composited scroll: during a fling the main thread can read 0 for one frame and double for the next. It cancels over three frames — discontinuity tests must run on a smoothed series, never on a single-frame delta.
  • A hidden tab gets no rAF at all. A Chrome window behind another window reports visibilityState === 'hidden' and stops firing animation frames; every probe hangs and every recording comes back empty. Page.bringToFront does not fix it — use a visible window or a dedicated --headless=new instance.
  • A teleported scroll position reads as a huge velocity. Reaching an edge with scrollTop += 3000 in a loop hands the bounce thousands of px/s it did not earn (or, if the writes land in the same millisecond, none at all). Walk out over several frames, or drive it through scrollController.scrollTo, which zeroes the estimate for its 300ms suppression window.
  • Test data that changes under the measurement — pin RangeSeed and ContentSeed on /test/virtual-list.
  • A stale WASM bundle. After a rebuild, a plain reload in WebAssembly mode keeps serving the cached hashed bundle from the service worker. Hard-reload with caches cleared, or you are measuring the old code (§4.12).

4. Symptom → first instrument

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
55
Forks
10
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
virtual-list-debug
Source
github.com/actual-chat/actual-chat