Termix-SSH / Termix-SSH/Support

[BUG] Hosts sidebar: virtualizer renders from index 18 at scrollTop 0 after editing a host, hiding the first 18 rows

Open
#1,221 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug performance platform-desktop platform-proxmox platform-web platform-windows ui
Dominant language
No language data
Stars
28
Forks
4
PR merge metrics
No merged PRs in 30d

Description

Platform

Desktop App - Windows

Server Installation Method

Proxmox (Community Scripts)

Version

2.7.1

CLI Installation Method

None

CLI Version

No response

Troubleshooting
  • I have examined logs and tried to find the issue
  • I have reviewed opened and closed issues
  • I have tried restarting the application
  • I have checked open issues and ensured this is not a duplicate
The Problem

After editing a host and returning to the list, the Hosts sidebar renders with a large
blank gap at the top and the host list bunched below it. The rows that do render are
correctly sized and spaced — it is the whole block that is offset, and the hosts above
the offset are not rendered at all.

(Edited 2026-08-27: originally reported as intermittent with no known trigger, and the
original text claimed the host editor round-trip does not cause it. Both were wrong — the
editor round-trip is the trigger, deterministically. See How to Reproduce.)

Once it enters this state it survives all normal use of the panel: scrolling, window
resizes, minimize/restore, and further edit round-trips do not clear it. Two things do
clear it: switching the sidebar to another panel (e.g. Credentials) and back — the tree
remounts with all rows at firstIndex 0, though with a leftover scrollTop of ~249px —
and restarting the app.

I captured the DOM state from the Hosts tree while it was happening (2.7.1, Windows
desktop, connected to a remote server):

{
  scrollTop: 0,
  firstIndex: "18",
  firstTranslateY: "translateY(278.75px)",
  gapAboveFirstRow: 279,
  scrollerClientHeight: 1258,
  scrollerScrollHeight: 1258,
  spacerHeight: "689.844px",
  renderedRows: 13,
  firstSixRowHeights: [34.84, 34.84, 34.84, 34.84, 34.84, 22],
  anyZeroHeightRows: 0,
  devicePixelRatio: 1,
  innerSize: [2885, 1369]
}

The scroll container is not scrollable, and the virtualizer starts at index 18 anyway.
scrollHeight === clientHeight === 1258 and scrollTop === 0, so there is no scroll
position that could justify skipping any rows — yet getVirtualItems() begins at index
18, positioned at translateY(278.75px). Rows 0–17 are never rendered. That 278.75px is
the gap. I have 29 hosts; only 13 rows exist in the DOM, so this is hiding most of the
list, not just adding whitespace.

The cached sizes for the skipped rows are far too small. Rows 0–17 account for
278.75px of offset — an average of 15.5px each. Every row shape in the table is larger
than that; the smallest whole row is the compact host at 27.5. Likewise getTotalSize()
is 689.844px, while the 13 rendered rows alone measure ~453px, and 31-ish rows at the
observed 34.84 would come to ~1080px.

Every recurrence lands on the same corrupted range. Instrumented captures (DevTools
protocol, 300ms sampling) of every later occurrence on this install show firstIndex 18
with exactly the last 13 of 31 rows rendered, at translateY(522.656px) /
spacerHeight: 933.75px — the same tail-pinned range as the capture above with a
different offset — identical at window widths 1184 and 3440 and scroller heights 650 and
1258. Immediately after the trip the scroller can still be scrollable (scrollHeight 934
vs clientHeight 650); the scrollHeight === clientHeight state captured above is how
it settles later.

The hardcoded row heights do not match what actually renders here. With the app's UI
font (JetBrains Mono Variable, 14px / 21px line-height), host rows with tags measure
34.84px, host rows without tags 22px (originally misread as a folder header), and
folder rows 31.5px — which matches FOLDER_ROW_HEIGHT exactly. SidebarTree.tsx:726
declares:

HOST_ROW_HEIGHT   = 27.5 (compact) / 45 (comfortable)  + 4.5 in click mode
FOLDER_ROW_HEIGHT = 31.5

So the folder constant is right, and the host constants don't model the tag line: 27.5
sits between the real 22 (no tags) and 34.84 (tags). devicePixelRatio is 1, so this
is not display scaling.

How to Reproduce

Deterministic on this install (2.7.1 Windows desktop, 29 hosts + folders = 31 rows):

  1. Hosts sidebar open, healthy list.
  2. Hover a host row and click Edit Host in the action tray (tray trigger: "Show on hover").
  3. Click Back to Hosts.

The gap is present every time, within 250ms of the tree remounting (sampled over the
DevTools protocol at 300ms intervals). The exact click sequence was also scripted over
CDP against two fresh app launches: it tripped on the first edit/back cycle both times,
plus twice by hand.

What does not trigger it: cold start alone (two instrumented launches sampled for two
minutes each stayed healthy until the first edit), minimize/restore (>2.5 min minimized),
switching sidebar panels, hovering, or window resizes across the sizes above. No console
errors or warnings accompany the trip.

Settings during all repros: density compact, tray trigger "Show on hover", host tags
shown, open-on-double-click on. Environment: Electron 43.2.0 / Chrome 150.0.7871.129,
--force-device-scale-factor 1.

Additional Context

Reading src/ui/sidebar/tree/SidebarTree.tsx at release-2.7.1-tag, two things look
relevant:

1. measureElement treats a zero measurement as real (:802):

measureElement: (element, entry) => {
  const box = entry?.borderBoxSize?.[0];
  return box ? box.blockSize : element.getBoundingClientRect().height;
}

ResizeObserver fires with blockSize: 0 when an element becomes display:none. box is
an object, so it is truthy, and 0 is cached as the row's size. This matters because
SidebarTree is hidden rather than unmounted — HostsPanel.tsx:1030 wraps it in
className={`... ${managerEditing ? "hidden" : ""}`}, and HostsPanel itself takes an
active prop defaulting to true (:190), which suggests panels are kept mounted when
switched away from too. Rows measured in that window would cache 0 and drag getTotalSize()
down, which is consistent with the undersized offsets above.

Guarding it would be cheap:

const size = box ? box.blockSize : element.getBoundingClientRect().height;
return size > 0 ? size : undefined; // ignore measurements taken while hidden

Update from instrumentation (2026-08-27): while the editor is open there are zero
div[data-index] rows anywhere in the DOM — the virtual rows fully unmount rather than
sitting display:none (likewise while the panel is switched away: the panel component
stays mounted, its virtual rows do not). The corrupted range materializes on remount,
within 250ms of clicking "Back to Hosts". So if measurement-while-hidden is involved, the
window for it is the hide/remount transition itself, not a steady hidden state.

2. virtualizer.measure() wipes the entire measurement cache (:843) on any change to
density, trayTrigger, showTags or showResourceBars, throwing every row back to a
constant that — per the 34.84 measurement above — is wrong on this install to begin with.

Healthy list (baseline), same session:

Image

Seconds later, after Edit Host → Back to Hosts:
Image

I have not confirmed either as the cause; the captured state and the deterministic
repro are the hard evidence, and these are the two paths in the code that seem able to
produce it. Happy to run anything specific or test a :beta desktop build.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/ui/sidebar/tree/SidebarTree.tsx, focusing on measureElement and the virtualizer.measure path, then trace the HostsPanel.tsx editor remount around the managerEditing state. Reproduce Edit Host followed by Back to Hosts and inspect firstIndex, row measurements, and total size. Done means the Hosts list remains positioned at index 0 and shows all rows after the round-trip.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, typescript
Domain
desktop-dev, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
64/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.