Skip to content

Stop flicker and layout shift when loading tables and switching sidebar tabs - #109

Merged
broisnees merged 25 commits into
masterfrom
fix/smooth-grid-and-sidebar
Oct 30, 2026
Merged

broisnees merged 25 commits into
masterfrom
fix/smooth-grid-and-sidebar

Conversation

@broisnees

@broisnees broisnees commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stroke didn't feel smooth: horizontal scrolling dropped frames on text-heavy tables, and opening tables, switching tabs, changing schema or refreshing flickered and shifted the layout. This branch fixes the flicker, makes long text columns cheaper to draw, and fixes a few data-trust problems I found on the way. It's 24 commits, one change each, so the history reads change by change.

Grid

  • Long cells measure only their visible head. Truncating a 2,000-character Devanagari or markdown cell used to shape the whole string to show its first 60 characters.
  • The previous table stays up while the next one loads. It's dimmed under the loading overlay, and the overlay no longer fades in. That removes the "old table, about 70ms of empty grid, new table" strobe.

Data trust

  • Failed edits on Apply produce one toast that says how many failed and whether they're still staged or were discarded, instead of one toast per edit followed by "Changes applied".
  • A search or filter that matches nothing says "No rows match these filters" and offers Clear filters.
  • NULL and empty markers are on by default, so a NULL no longer looks like the text 'NULL'.
  • Close All and Close Others ask before discarding staged edits, like Close Tabs to Left/Right already did.
  • Close All Tabs is no longer styled as destructive. Every closed tab goes on the reopen stack.

Toolbar, status bar and loading

  • When a table opens, the pager keeps its shape: total starts at "counting", the readout has a fixed width, and the page picker is always present outside page size All.
  • The status bar's column jumps and fetch time keep their place.
  • The filter count is a corner badge instead of widening the button.
  • A sidebar refresh keeps the list and its known counts on screen.
  • The row count no longer drops back to "…" on a refresh or page change.
  • Changing schema keeps the open table up until the new list lands, then swaps in one step.
  • Switching connections shows the connect screen straight away instead of about 330ms of black window.
  • A large tab evicted from the row cache keeps its columns and count, so coming back reloads only the rows.

Sidebar

  • Each tab's panel mounts once and then stays, each with its own scroll position. Returning to Tables went from about 921 DOM mutations to 15.
  • Arrow keys on the tab strip work. They had always thrown, because of a missing semicolon.
  • Databases no longer says "No databases" while its list is loading.
  • Find & replace keeps what was typed across tab switches.
  • The top bar keeps one width on every tab.
  • Tabs switch without a colour fade.
  • Objects has its own icon instead of the Extensions puzzle piece.

Measurements

I measured flicker and layout shift on a production build in headless Chromium (the engine behind WebView2). The backend was stubbed with realistic latency: rows 300-800ms, counts 600ms, the table list 150ms.

Scenario Before After
Cold table open, layout shifts 2-3 0-1
Blank grid frames between two tables 258-372ms, 274-324ms, 445-492ms none
Schema switch, layout-shift score 0.0358 0.0001
Mod+R refresh, layout shifts 2 0
Typing a filter value, layout shifts 3 2
Returning to Tables in the sidebar, DOM mutations about 921 15

Measuring only the head of long cells cut the JS time of a vertical scroll over text columns from 1,217ms to 964ms. The 72ms long task in the before run was also gone. That's one run each, so treat it as indicative.

Verification

  • The branch tip passes svelte-check (0 errors) and vitest (1131 tests).
  • Every one of the 24 commits passes svelte-check (0 errors) and vitest (1131 tests) on its own, each checked out in a separate worktree.
  • The flicker numbers above come from the agent harness's scenarios: sidebar clicks and keys, table opens, page size All, refresh, schema switch, connection switch, and loads that land just past the 220ms grace window. No run logged a console error.

Not in this PR

  • A horizontal version of the scroll copy. I built one, then measured it. In Chromium on a CPU canvas, copying through the scratch surface cost more than the text it saved: with the CPU throttled 4x, janky frames on horizontal scroll went from 27-46% to 66%. The existing vertical copy pays the same price, about 4.8ms a frame for its two drawImage passes. A single-copy version (two canvases that swap) benchmarks 24-53% cheaper than the scratch copy. I want to A/B that on WebKitGTK before shipping either direction.

  • Two costs during a sidebar drag: the grid's canvas backing store is reallocated on every frame (syncCanvasSurface), and ctx.font set per cell forces style recalcs.

  • Sidebar scrolling with about 400 tables spends 44-49% of the main thread on per-frame hover hit-testing.

  • Switching back to some tabs refetches all their rows, and five windows are prefetched eagerly right after the first paint.

  • bits-ui's body scroll lock forces a layout on every dialog and menu open (about 12-15ms), and the command palette mounts every item up front (one 105ms task).

  • On a connection switch the schema picker still appears a moment after the connection lands.

  • The first visit to Objects still pops its groups in. A background prefetch after the table list loads would fix it.

  • Background row counts still fail against a closed pool when switching connections. This was already happening before this branch.

Release

There's no release:* label on this PR. The release is held until I give the go-ahead.

With the Empty & NULL Markers extension off, a real NULL and the text 'NULL'
drew the same four letters, grey against white, and empty and whitespace-only
strings both drew blank. Whether to write IS NULL or = 'NULL' depended on
reading that tint. Anyone who switched the extension off keeps it off.
Apply raised one "Could not save" per failed edit, then a green "Changes
applied". When the same Apply also deleted rows, the failed edits were dropped
with nothing said. Now one error names how many edits failed, whether they are
still staged or were discarded, and up to three column: error lines in the
code block. The success toast reads "Some changes applied" when anything
failed. The grid's own "Could not delete" toast is gone, since the delete
handler already raises one.
An empty result under a search or filter read "No rows in this table", which
says the data is gone, and the overlay had no way out. The grid now gets a
`filtered` flag and a Clear filters button that drops the search and every
filter in one reload. An unfiltered empty table still reads as before.
Close Tabs to Left/Right asked before throwing away unsaved changes, Close All
and Close Others did not. The three now share one confirm, which also resets
the visible grid's live edits when its tab is among those closed, as closing a
single tab does.
It was red with a trash icon, though every closed tab goes on the reopen stack.
Red should mean something can't be undone, so it is now a plain item with the
x icon in both the tab menu and the File menu.
Truncation started with measureText on the whole value, so a 2,000-character
Devanagari or markdown chunk was shaped end to end to show its first 60
characters, once per cell scrolled into view. It now measures a head of four
times the monospace estimate, which covers narrow fallback glyphs and
zero-width marks, and falls back to the whole string only when that head fits.
The canvas kept the old table's pixels only for the 220ms grace window, then
repainted blank under a spinner that faded in over 300ms. A load just past the
window strobed: old table, about 70ms of empty grid, new table. The held frame
now stays until the new rows land, dimmed by the overlay, and the fade is gone.
A fresh or resized canvas still paints, as before.
Objects needs an icon of its own (next commit), so the registry gains
'shapes': Lucide Shapes, Hugeicons Shapes01 and Phosphor Shapes. 'replace' had
only a Lucide glyph, so in the default Hugeicons set the Find & replace tab drew
in a different stroke and proportion from the five tabs beside it. It now maps
to SearchReplace.
Objects used 'blocks', which draws a puzzle piece in Hugeicons and is the
Extensions icon everywhere else, so the two read as the same thing. Objects
and its empty states now use shapes.
Left and Right never switched tabs. The line after
`sidebarTab = SIDEBAR_TABS[next].id` started with `(`, so automatic semicolon
insertion joined the two into a call and every press threw
"SIDEBAR_TABS[next].id is not a function". The button is read into a variable
first now, then the tab is set, then focus moves.
The accent rail snapped to the new tab while the pill and the icon faded over
150ms, so every switch showed two tabs half lit. The switch is one frame now.
Switching tabs destroyed the panel being left and rebuilt the one being
opened: its DOM, its scroll position, and for Objects a fresh read of the
catalog. Queries, Objects, Tables, Databases and Recent now mount on first
visit and then stay, hidden with display:none. Each list tab has its own
scroller, and its position is saved on the way out and put back on return
(display:none drops it). Objects stops loading while it is hidden. Returning
to Tables went from about 921 DOM mutations to 15.

Find & replace still mounts per visit: its preview reads the open table's
rows, which a hidden copy would keep recomputing.
The Databases tab counted as empty from the moment it opened until its list
arrived, so a first visit read "No databases. Nothing else on this server."
for about 200ms, with the loading dots unreachable behind it. A pending or
failed load now shows the section's dots or its error instead.
Objects dropped the display options button, and Queries and Find & replace
showed table actions that did nothing for their panels, so the button group
changed width from tab to tab. A spacer holds the slot on Objects, and the
table actions keep their space but are hidden on Queries and Find & replace.
The panel remounts on every visit and kept what was typed in its own state,
so going to Tables and back cleared the find and replace fields. The text and
the regex, whole-value and case options are bindable now, held by the sidebar.
Opening a table set total to 0, which unmounted the range readout, the page
picker and the arrows until the rows landed, then brought them back and moved
them again when the count arrived. The page-size select jumped up to 179px
each way. Now:

- openTableTab seeds total = -1, the "counting" shape, and keeps the last
  fetch time instead of zeroing it.
- The readout sits in a fixed-width box with its text held left, so "…"
  becoming a number grows into the box.
- Outside page size All the page picker is always there, disabled on a single
  page. Under All it never shows, and while counting the range reads the rows
  loaded, not "1-1".
- The Mod+F hint no longer waits for columns.
The go-to-first and go-to-last column buttons were removed whenever the grid
fit, and the fetch time only rendered once a time was known, so both left and
came back around every table open and shifted what sat beside them. The
buttons are disabled instead of removed, and on table tabs the timing has a
fixed slot.
The filter button grew from 28 to 42px to fit the count inline, which moved
the five buttons after it 14px every time a filter value was typed or cleared.
The count is a badge on the corner now, and the button's accessible name says
how many filters are active.
A refresh swapped the table list for loading dots, which reset its scroll, and
the reply's unknown counts blanked counts the sidebar already had until the
recount waves came back. loadTables now remembers which connection and schema
the list on screen belongs to: re-reading that same list stays quiet and keeps
known counts, while tables the reply left unknown are still recounted. A new
connection or schema shows the dots as before.
The count taken when a table opens never set _countedView, so the first page
change or refresh afterwards dropped total back to -1 and the readout flashed
"…" through another COUNT(*). The open path records the counted view now, and
a recount of the same table under the same predicate keeps the last number
until the new one lands.
Changing schema cleared the active table before the new table list loaded, so
the main area showed "Select a table" for a moment, then the old toolbar over
an empty grid, and the filter bar remounted and pushed the grid down 82px. The
old table now stays until the list lands, then the tab moves to the first
table in one step under the grid's held frame. The previous table's search,
filters and sort are cleared on the way, the tab title follows, and the filter
bar stays mounted through a reload.
A switch unmounts the shell before the new connection exists, and the overlay
held its ring and label back 350ms, so a fast switch was about a third of a
second of plain black window. The hold-back now applies only to the startup
reconnect it was added for.
Under page size All, a large tab that fell out of the three-tab row cache lost
its columns along with its rows, so coming back was a full cold load: the
loading overlay, the pager collapsing and the count blinking. Eviction now
drops only the rows and marks the tab, which refetches its rows on return
with the header and toolbar already in place, and a refetch keeps the tab's
known count until the background count lands.
@github-actions

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@broisnees
broisnees force-pushed the fix/smooth-grid-and-sidebar branch from 11135bd to cc0edf6 Compare October 29, 2026 07:36
@broisnees broisnees added the release:patch Bump patch version (0.0.x) label Oct 30, 2026
@broisnees
broisnees merged commit f72743e into master Oct 30, 2026
1 check failed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release:patch Bump patch version (0.0.x)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant