Skip to content

feat(log-viewer): add lv-grid, a virtual table to replace Tabulator - #1166

Draft
lukecotter wants to merge 49 commits into
mainfrom
feat-grid-spike
Draft

lukecotter wants to merge 49 commits into
mainfrom
feat-grid-spike

Conversation

@lukecotter

@lukecotter lukecotter commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

📝 PR Overview

Adds lv-grid, our own table, to replace Tabulator. This PR adds the grid only. No view uses it yet. The next PR in this stack moves the Call Tree to it.

Why: Tabulator is too slow for large logs. At 580,091 rows, a first search takes 17.6s in one task. A tree CSV export takes time that grows with the square of the row count (148s at 21k rows). A column resize drops every frame.

lv-grid has the Time Order and Bottom-Up features of today's Call Tree. It does all its long work in slices, so no step blocks the UI.

🛠️ Changes made

  • log-viewer/src/grid/: the grid, built as if it were an outside library. 3 layers, which depend one way only:
    • core: rows, tree, sort, filter, groups, totals, find, export and keyboard navigation. Pure TypeScript. A second type check runs it with no DOM library.
    • render: DOM, virtual rows (@tanstack/virtual-core), find highlights (CSS Custom Highlight API) and scroll anchoring.
    • ui: the <lv-grid> Lit element, header, footer, keyboard and styles.
  • lana/grid-boundary oxlint rule: app code imports only grid/index.ts, and each layer imports only the layers below it.
  • features/call-tree/grid/: the Call Tree columns, cells, totals and theme tokens for lv-grid.
  • ColumnSettingsController can drive an lv-grid as well as a Tabulator table.
  • Bench: pnpm bench:grid <log> <out> call-tree [--tree bottom-up] runs the grid in headless Chromium. scripts/grid-bench/compare.mjs compares a run with the saved Tabulator baseline. pnpm measure grid times the core in Node.

Bench, 580,091 rows (94.8MB log)

Each cell is median ms · longest task ms. A ! marks a task over the 50ms budget.

Time Order Tabulator lv-grid
First render 293 · !324 209 · !186
Expand all 254 · !655 76 · 0
Sort 473 · !525 176 · 0
Filter on 196 · !224 89 · 0
Go to row 151 · !144 21 · 0
Find 641 · !17639 516 · 0
CSV export not run (148s at 21k rows) 769 · 0
Hide column 289 · !287 20 · 0
Resize column, frames dropped 60 of 60 0 of 60
Heap added on mount 78MB 8MB
Heap added after all actions 477MB 31MB
Bottom-Up Tabulator lv-grid
First render 779 · !537 571 · !184
Group by type 2784 · !2835 312 · 0
Group by namespace 1576 · !1776 255 · 0
Sort 412 · !426 20 · 0
Expand all 419 · !433 19 · 0
CSV export 565 · !565 21 · 0

The first render still has 1 long task. Most of it is a garbage collection pause, not grid work.

🧩 Type of change (check all applicable)

  • 🐛 Bug fix - something not working as expected
  • ✨ New feature – adds new functionality
  • ♻️ Refactor - internal changes with no user impact
  • ⚡ Performance Improvement
  • 📝 Documentation - README or documentation site changes
  • 🔧 Chore - dev tooling, CI, config
  • 💥 Breaking change

📷 Screenshots / gifs / video [optional]

No user-visible change in this PR. Screenshots are on the next PR in the stack.

🔗 Related Issues

related #1165

✅ Tests added?

  • 👍 yes
  • 🙅 no, not needed
  • 🙋 no, I need help

📚 Docs updated?

  • 🔖 README.md
  • 🔖 CHANGELOG.md
  • 📖 help site
  • 🧪 Marked any pre-release-only features (README 🧪 badge — see RELEASING.md)
  • 🙅 not needed

Anything else we need to know? [optional]

  • Review by layer: grid/AGENTS.md has the layer map. Start at core, then render, then ui.
  • No CHANGELOG entry, because nothing changes for the user yet. The next PR adds it.
  • Next in the stack: the Call Tree (Time Order, Aggregated and Bottom-Up) moves to lv-grid, and its Tabulator code is deleted. The other 8 tables move later, each in its own PR.

… rows

The bench times the Call Tree's Time Order grid in headless Chromium.
It covers first render, expand and collapse all, sort, filter, go to
row, find, export, column show and hide, scroll, column resize and
heap. Each later grid step is compared against the saved Tabulator
results.

- pnpm bench:grid <log> <out.json> builds the page and runs it.
- scripts/grid-bench/make-log.mjs 5 <log> writes the 580,091-row log
  that the baseline used.
- scripts/grid-bench/compare.mjs marks each figure against Tabulator
  and against the 50ms task budget.
- scripts/grid-bench/size.mjs gives minified and gzip sizes.

Tabulator's tree CSV export is not run at 580k rows. Its time grows
with the square of the row count: 9.1s at 4,986 rows, 38.9s at
10,878, 148.2s at 21,342. The baseline keeps those three points.

@tanstack/virtual-core is added for the grid's renderer.
The grid is built as if it were an outside library, in three layers:
core (table logic), render (DOM and virtual rows) and ui (Lit). Its
layers depend one way: ui to render to core.

- lana/grid-boundary resolves each import and reports one that
  crosses the boundary: app code that imports a grid layer, grid code
  that imports app code, a layer that imports against the order, or a
  package the layer may not use.
- core/tsconfig.json checks core again without the DOM library, as
  part of `pnpm typecheck`.
- grid/AGENTS.md records the layer map and how to measure a change.

Probe files showed each case: 8 bad imports reported, the allowed
imports passed, and an HTMLElement in core failed the core check.
The probes are not committed.
…lter

GridStore holds immutable snapshots of the shown rows and tells
subscribers about each new one. It gives:

- expand and collapse of one row, which splices the typed arrays
- expand all and collapse all
- sort and filter per sibling list, cached until they change
- a filter that keeps the ancestors of a match
- reveal of a deep row by its path

Long steps run in 8ms slices through an injected scheduler. A newer
change restarts a running step.

`pnpm measure grid` times each step. Results on a 95MB log
(580,091 rows), Node:

  step                         time      longest slice
  expand all                   90.7ms    8.2ms
  sort by self time            171.9ms   9.9ms
  clear sort                   35.4ms    9.0ms
  detail filter off            39.8ms    9.6ms
  detail filter on             45.0ms    8.1ms
  SOQL filter, keep ancestors  91.4ms    9.8ms
  SOQL filter off              47.3ms    8.5ms
  collapse all                 0.1ms     0.1ms
  reveal deep row (depth 23)   1.0ms     1.0ms
  toggle one root              0.2ms     0.2ms

No slice is longer than 10ms, against the 50ms budget. Heap grows by
21MB at most, while all rows are expanded.
GridStore can group the top-level rows by a key:

- Groups start closed. A group drops out when the filters leave it
  empty.
- Groups go most rows first, then by key. setSort takes an optional
  second compare that orders groups by their totals, and ties keep
  the default order.
- Open a group with toggle(group). reveal opens the group of the
  row it reaches. Expand all and collapse all leave groups as they
  are, as Tabulator does today.

Calcs give the footer (Snapshot.totals) and each group's totals.
A calc runs on the top-level rows that pass the filters, or with
scope 'all' on every row that passes them at any depth. A calc can
return a generator, so a slow total runs in slices. Totals are kept
across a sort or a toggle.

The row walk is now a plain loop that moves a batch of rows per
call, not a generator that resumes once per row. With no forced GC
between steps, expand all at 580k rows goes from 49-51ms to 33-43ms.

`pnpm measure grid` adds a Bottom-Up section. Its total-time calc
is a sliced copy of sumDurationTotalForRootEvents, which step 8
moves into the Bottom-Up adapter. Run in one go, that sum holds the
thread for up to 330ms.

The grid measure no longer forces a GC before each step. The forced
GC dropped V8's optimised code, so the next step timed a cold walk
and made some steps look up to 2x slower. Heap is now read once per
section, with all rows shown.

Results on a 95MB log (580,091 rows), Node, two runs:

  Time Order                   time         longest slice
  expand all                   94-98ms      8.5-13.2ms
  sort by self time            113-119ms    9.0-10.5ms
  clear sort                   25-27ms      8.1-8.5ms
  detail filter off            35ms         11.6-11.9ms
  detail filter on             31-32ms      9.3-10.4ms
  SOQL filter, keep ancestors  36-38ms      8.1-8.9ms
  SOQL filter off              35-40ms      8.2-11.0ms
  heap held, all rows shown    +21MB

  Bottom-Up (167 roots)        time         longest slice
  first build, with totals     333-348ms    19.6-27.5ms
  group by type                412-449ms    10.8-15.5ms
  group by namespace           377-414ms    19.6-23.8ms
  sort rows and groups         0.1ms        0.1ms
  open largest group           0.2ms        0.2ms
  heap held, all rows shown    +1MB

No slice is longer than 28ms, against the 50ms budget.
…d core

GridStore.find counts a query in the cells it is given. It searches
every row that passes the filters, expanded or not, in display order:

- The text is matched literally. Case is ignored unless asked for.
- The result gives the row of each match, the first match in a row,
  and the key path to a match, which reveal opens.
- findPattern gives the pattern find counts with, so the highlights
  in render can use the same one.
- A newer find, or a change to the rows, cancels a running find.

GridStore.exportText writes the same rows as CSV (every value quoted)
or tab-separated text, under a header line. A group writes its key on
a line before its rows. With tree off, only top-level rows are
written, as Bottom-Up copy does today.

navigate turns an arrow, Home or End key on the selected row into a
select, expand or collapse. A group row is the parent of its rows.

Two changes from Tabulator, for the adapters in step 8 to confirm:

- Export writes the rows that pass the filters. Tabulator's 'all'
  range ignored them.
- Find in a grouped grid also searches the tree under each group's
  rows. Tabulator searched only the top-level rows.

Results on a 95MB log (580,091 rows), Node, two runs, on the Time
Order rows with 563,090 shown and five searched cells:

  step                         time         longest slice
  find "account" (19,842)      239-245ms    8.3ms
  find "e" (4,474,918)         409-421ms    15.2-21.1ms
  export CSV, every row        444-450ms    18.6-23.1ms
  export CSV, Bottom-Up        3.7-4.9ms    3.7-4.9ms

No slice is longer than 24ms, against the 50ms budget.
GridView shows a window of the core's rows on @tanstack/virtual-core:

- Row elements come from a pool and are reused as the window moves.
  A caller's RowPainter fills them; render places them and sets
  aria-rowindex, aria-level and aria-expanded.
- Heights are measured as rows paint, so wrapped names get their
  own height.
- After a sort, filter or toggle, the user keeps their place: an
  edge they were at, else the toggled row where it was, else the
  middle row or its nearest shown ancestor (anchor.ts, ported from
  AnchoringPolicy).
- Find matches are marked with the CSS Highlight API, from the same
  pattern and numbering that core counts with. A browser without the
  API shows no marks; the count still works.
- browserScheduler slices core's long steps with scheduler.yield, or
  a MessageChannel task where the browser lacks it.
- textWidth measures text with a canvas, for columns sized to their
  content.

Two fixes the bench found:

- virtual-core reports each resized row on its own, and each report
  repositions every row after it. Drawing on each report dropped 53
  of 120 frames in a fast scroll. Draws are now batched into one
  microtask per burst.
- Heights are kept by index and dropped on each change to the rows.
  Keying them by row cost a key lookup per row on every reposition.

The bench gets a `grid` contender: core and render with plain text
in the Time Order grid's fifteen columns. It does not yet paint bars,
links or SOQL as the app does, so its paint costs less than
Tabulator's. scripts/grid-bench/profile.mjs CPU-profiles a fast
scroll or a mount for one contender.

Chromium, 580,091 rows (563,090 shown), median of 5 runs, against
the Tabulator baseline:

  action                Tabulator   grid    longest task
  first render          293ms       185ms   382ms (Tabulator 324ms)
  expand all            254ms       50ms    0ms
  collapse all          232ms       17ms    0ms
  sort self desc        473ms       313ms   0ms
  clear sort            207ms       33ms    0ms
  filter off            189ms       33ms    0ms
  filter on             196ms       29ms    0ms
  go to deep row        151ms       22ms    0ms
  find                  641ms       338ms   0ms (Tabulator 17,639ms)
  export CSV            -           564ms   0ms
  hide column           289ms       20ms    0ms
  show column           278ms       19ms    0ms
  scroll jump to end    29ms        22ms    0ms
  scroll jump to top    16ms        22ms    0ms
  fast scroll           0/120 dropped frames, both
  resize Name column    60/60 dropped (Tabulator), 0/60 (grid)
  heap added on mount   78MB, both
  heap after the runs   +477MB (Tabulator), +97MB (grid)

Two lines miss the gate:

- First render's long task is the app's toTimeOrderTree (caller
  namespace and governor cost), which Tabulator's first render runs
  in one task too. The grid's own part of mount profiles at about
  10ms.
- Jump to top: a timeline trace puts both grids at one frame per
  jump (grid 15-18ms, Tabulator 14-19ms), with about 2.5ms of main-
  thread work in the grid. The bench's figure depends on where the
  jump lands in the frame.
The ui layer puts the core store and the render view into one Lit
element, <lv-grid>. The app does not use it yet.

- Columns: a cell template, plus text for find, copy and export, a
  tooltip, a sort, a calc for the footer and group totals, and a width.
- Header: a click sorts a column. The order is the first direction,
  then the other direction, then no sort. aria-sort shows the state.
  The sort is also a property. It sorts again only when the column's
  sort, calc or direction changes, so hiding a column does not sort
  580k rows again.
- Rows: a click selects a row or clears it. The twisty and a group row
  toggle. Keys move, open and close rows, and select group rows. With
  no selection, a move selects the first or last row.
- Ctrl/Cmd+C copies the rows that pass the filters, closed rows too.
- Methods: goTo, find, setCurrentMatch, clearFind, expandAll,
  collapseAll, exportText, copy and settled.
- Events bubble, but are not composed: lv-grid-select, lv-grid-locate,
  lv-grid-context, lv-grid-reshape and lv-grid-find-results.
- Styles: --grid-* tokens with CSS system colours as defaults, in
  @layer grid. The busy bar shows only after 100ms.

The bench gets an lv-grid contender with the same 15 text columns as
the grid contender. 580,091 rows, Chromium, median ms (longest task):

  action          tabulator    grid        lv-grid
  first render    293 (324)    180 (372)   168 (344)
  expand all      254 (655)     31 (0)      34 (0)
  sort            473 (525)    135 (0)     123 (0)
  find            641 (17639)  286 (0)     287 (0)
  export CSV        -          545 (0)     555 (0)
  hide column     289 (287)     19 (0)      19 (0)
  jump to top      16 (0)       23 (0)      23 (0)
  fling           0/120 dropped frames for all three
  resize Name     60/60        0/60        0/60 dropped frames
  heap after runs +477MB       +98MB       +110MB

Jump to top and the heap use a second run with 9 reps. The first run
gave 30ms and +122MB for lv-grid, so the difference to grid is in the
noise. The first-render long task is the app's toTimeOrderTree, as in
the last step.
- Resize: drag the end edge of a header to set the width of a column.
  A double-click on the edge fits the column to its header and the rows
  on screen. The width is never less than minWidth. On release, the
  grid sends lv-grid-column-resize. setColumnWidth sets a width from
  code. A user width stays when the columns change, for example when a
  column view hides other columns. A column with resizable: false has
  no handle.
- A width change sets only the column tracks. No row paints again, and
  the resize bench drops 0 of 60 frames.
- The column tracks are now on the scroller in the shadow root, not on
  the host. Before, a parent that set the style of the host removed
  them.
- freeze-first keeps the first column in view on a horizontal scroll.
  Its cells use the row colour (hover, marked, selected) over the grid
  background, so the scrolled cells do not show through. Checked in
  Chromium in a dark and a light scheme.
- A right-click on a header sends lv-grid-header-context with the
  column id, for the column menu. Each header has its title as a
  tooltip.
- ARIA: the header row is row 1, so the data rows start at 2 and
  aria-rowcount includes the header and footer rows.

Column views and their settings stay in the app. The grid shows and
hides columns through the hidden flag of each column.

580,091 rows, Chromium, 9 reps, median ms (longest task):

  action          tabulator    grid        lv-grid
  first render    293 (324)    174 (362)   176 (370)
  expand all      254 (655)     41 (0)      35 (0)
  sort            473 (525)    135 (0)     121 (0)
  find            641 (17639)  287 (0)     275 (0)
  export CSV        -          556 (0)     546 (71)
  hide column     289 (287)     19 (0)      23 (0)
  resize Name     60/60        0/60        0/60 dropped frames
  heap after runs +477MB       +110MB      +104MB

The 71ms export task came in 2 of 3 lv-grid runs. This step did not
change the export code. The probable cause is the single join of the
57MB text in the store. A separate commit will look at it.
The store joined every export line in one step after the sliced walk.
At 563k rows that is one join of 57MB of text. In the bench it caused
a 64ms and a 71ms long task in 2 of 3 lv-grid runs, over the 50ms
budget.

The walk now joins each 4096 lines while it runs, inside its slices.
The last step joins only these chunks. The text does not change, and a
new test crosses two chunk edges to show it.

pnpm measure grid, 563,090 rows: export CSV 527ms, longest slice 15ms.
Bench, 580,091 rows, 9 reps, two runs: lv-grid export 541ms and 539ms,
grid 532ms and 526ms. No long task in either run.
- The first cell is now a flex row, with the twisty and then the
  content. A long name wraps beside the twisty. Before, the twisty
  stayed alone on the first line and the name started on the next one.
- The twisty has no rotate transition. Rows are recycled, so a reused
  row that changed from closed to open animated during a scroll.
- A column can have a description, which is its header tooltip. The
  default is the title. The call-tree heap columns need it.

Checked in Chromium with the Name column at 260px.

Bench, 580,091 rows, 9 reps: fling and resize drop 0 frames. The
plain grid contender, which this commit does not change, also got
slower in this run and had long tasks for the first time. So the
other times of this run are not used as a result.
…p trees

The call-tree adapter for lv-grid, in features/call-tree/grid. It stays
outside grid/ and the app does not use it yet.

- timeOrderColumns and bottomUpColumns give the columns of the Tabulator
  tables, field for field: the same ids, titles, widths, visibility and
  order, so the column views apply unchanged. A test compares the
  governor block with the Tabulator definitions.
- Cells are Lit templates with the same markup as today: time and
  governor bars, heap integers, utilisation with a dash where the log
  has no limits, and a Name cell that links to the type or shows the
  query highlighted.
- Tooltips are plain text in the native title. The governor breakdown
  is on separate lines.
- Footers and group totals: sums of the top-level rows, self time and
  heap self of every row that passes the filters in Time Order, and
  Bottom-Up totals that count each call once. That last calc runs in
  slices. pnpm measure now uses it, not its own copy.
- lv-call-tree-grid is lv-grid with the cell styles and a map from the
  --lana-* tokens to --grid-* in its shadow root. For this subclass,
  LvGrid.styles is typed CSSResultGroup.
- MIN_VISIBLE_PERCENT is exported, so the bar template uses the same
  minimum width as progressComponent.

Find, copy and export use the text that a cell shows, for example
milliseconds with 2 decimals.

pnpm measure grid, Bottom-Up steps on the 580,091-row log: no slice is
over 24ms.
The Tabulator call-tree tables colour each row by the category of its
event, through a rowFormatter. lv-grid now has the same feature.

- lv-grid: rowClass(row) gives extra classes for a data row. The
  painter sets them on each paint, so a recycled row element does not
  keep the classes of the last row. A change to rowClass repaints.
- lv-call-tree-grid sets rowClass to the category class of each row
  (for example cat-soql). Its styles map each class to the
  --ct-color-* theme property, which the host sets as today. The Name
  cell gets the colour strip. With category-colorize set, it also gets
  the tint, as the callTree.categoryColorize setting asks.
sum() and max() ran each total in one task. With the all scope, a
total reads every row of the tree, so a filter on the 580k-row Time
Order tree had a 66-75ms task.

Both are now generators that yield every 1,024 rows. A yield for each
row made the filter 408ms, so the rows go in batches.

Time Order, 580k rows, against the Tabulator baseline:
- filter: 126-128ms (Tabulator 189-196ms), longest task 0ms
- first render: 276ms (Tabulator 293ms)
- outermostSum: each total of one row list now reads one shared list
  of outermost events, kept in a WeakMap. The ancestor walk keeps its
  answer for each event it passes, so no part of the tree is walked
  twice. The sum over the list yields every 1,024 events: one type
  group has 345k events, and its three totals took 58ms in one task.
- Integer cells use one cached Intl.NumberFormat. formatInteger makes
  a new formatter on each call, which made find take 65ms.

Bottom-Up, 580k-row log, against the Tabulator baseline:
- first render: 568-638ms, was 1,149ms (Tabulator 779ms)
- find: 18ms, was 65ms (Tabulator 18ms)
- group by type: 336ms, longest task 0ms, was 57-63ms
  (Tabulator 2,784ms)
- --tree bottom-up runs the Bottom-Up actions: sort, expand, scroll,
  find, export and collapse, then group by type and by namespace, then
  ungroup.
- Contenders: Tabulator, with the options CalltreeView gives it, and
  lv-call-tree-grid with the Bottom-Up columns.
- call-tree is a new Time Order contender: lv-call-tree-grid with the
  app's Time Order columns.
- compare.mjs picks the baseline for the tree. The Tabulator Bottom-Up
  baseline is saved as baseline/tabulator-bottom-up-580k.json.

Grouping runs last, and Tabulator skips ungroup. Its Bottom-Up table
overflows the stack on an ungroup: GroupSort calls setGroupValues([])
again on each data-refreshed. The app does the same call for "None".
toTimeOrderTree copies about 20 fields of each event into a new row:
580k objects and 147MB on a 580k-row log, before the grid shows
anything. lv-grid now takes the events as its rows, with their own
children and eventIndex as the key.

- columns: the shared column helpers read the event, name, caller
  namespace, type and governor costs through RowFields. Bottom-Up reads
  the fields its rows hold; Time Order works them out from the event
  when a cell asks.
- timeOrderSource and TIME_ORDER_DETAILS: the tree and the Show
  Details filter. The filter keeps the parents of a detail, as
  _hasDetailsDeep did.
- isDetailEvent and governorCostOf: the per-event rules, now shared
  with the Tabulator build, which does not change.
- eventCategoryClass: the category colour for a row that is the event.

Time Order, 580k rows, against the gate run on built rows:
- heap added on mount: 9MB, was 78MB (Tabulator 78MB)
- heap added after all runs: 63MB, was 132MB (Tabulator 477MB)
- first render longest task: 189-357ms over six loads, was
  339-357ms. It is garbage collection after the parse, not grid work.
- sort 172-245ms, was 119-178ms; clear sort 84-113ms, was 28ms. Both
  stay under Tabulator (473ms and 207ms), with no long task.
- filter off and on: 242ms and 289ms, was 133ms and 127ms (Tabulator
  189ms and 196ms). The next commit keeps the footer per filter set.
A filter change summed the footer again. With the all scope, that is a
walk and a sum over every row of the tree: about 77% of a filter
change on the 580k-row Time Order tree.

The store now keeps the footer for each set of filters it has shown,
so a filter turned off and on again sums nothing. A change of source or
calcs clears them all. Group totals still clear on a filter change.

Time Order, 580k rows, against the Tabulator baseline:
- filter off: 61-66ms after the first time (Tabulator 189ms); the
  first time is 250ms, longest task 0ms
- filter on: 85-94ms (Tabulator 196ms)
toBottomUpTree ran in one task: 474ms on the 580k-row log. The walk
and the finalize step are now one generator, which yields every
CHECK_EVERY events or rows.

- toBottomUpTree runs the generator to the end, so Tabulator and its
  callers do not change.
- buildBottomUpTree runs it through frameBudget and returns null once
  the signal aborts. The bench lv-grid Bottom-Up page uses it.
- The finalize step is no longer recursive: it walks the rows in
  reverse pre-order, so each row comes after its children.

Bottom-Up, 580k rows:
- Node build: 360ms in 42 slices, longest slice 8.5ms.
- First render longest task: 179-207ms over 9 loads, was 540-578ms
  (Tabulator 537ms). The trace shows the task that is left is a major
  GC pause of 181-213ms, nearly all of it ephemeron marking.
- Group by type: longest task 0ms in 3 of 5 runs, but 123ms and 56ms
  in the other 2. The sync build gave 0ms in 2 runs. The cause is not
  known.
- Every other action: no change.
The bench timed a jump to the first paint after it. On a jump of more
than one viewport, Tabulator's VirtualVerticalRenderer skips the render
until the scroll stops, so that paint shows no rows: 0 rows in view at
the first paint, 18-22 rows 300ms later. lv-grid shows its rows in the
first frame. Tabulator's 16-17ms jump to top was the time to an empty
screen.

timed takes an optional check, and a jump is now timed to the first
painted frame with a body row in view. lv-grid's header and footer rows
do not count.

Only scrollJumpEnd and scrollJumpTop change in the two Tabulator
baselines. The other fields stay from the earlier runs, because the
Time Order baseline holds fields that the bench does not write.

580k rows, one run each, jump to top / jump to end:
- Time Order: Tabulator 42 / 33ms (was 16 / 29ms), lv-grid 29 / 24ms
- Bottom-Up: Tabulator 41 / 23ms (was 17 / 28ms), lv-grid 25 / 28ms
The walk that gathers every row for an all-scope footer yielded once
per row. On the 580k-row Time Order tree, the generator resumes cost
more than the walk itself. It now yields every 64 rows, so drive reads
the clock about every 16k rows.

Time Order, 580k rows, first detail filter off (the first sum of the
unfiltered footer), one run:
- 200ms, was 250ms; longest task 0ms. Tabulator is 189ms.
- In a profile, the walk's own time fell from 56ms to 25ms. The three
  all-scope sums take the remaining 98ms.
- Later filter toggles do not change: 59-62ms off, 77-90ms on.
lv-grid summed the calc of every column, hidden ones too. On Time
Order, two of the three all-scope sums are for hidden columns (heap
self, net and gross), and each reads all 580k events.

- lv-grid gives the store the calcs of the shown columns, and of the
  sorted column, which orders the groups.
- The store keeps each total by the calc that made it, for each set
  of filters and each group. A change of calcs sums only the calcs it
  has not summed before.
- With no grouping, a change of calcs sums the footer and keeps the
  rows on screen, so showing or hiding a column walks no rows.

Time Order, 580k rows, against the last run:
- first detail filter off: 179ms, was 200ms (Tabulator 189ms)
- first render: 211-215ms, was 265ms; its longest task is still the
  garbage collection after the parse (180-185ms)
- hide and show column: 26 and 27ms, no change

Bottom-Up: no change outside run-to-run spread. Group by type had a
56ms long task again, as in 2 of 5 runs before this change.
After a tab switch, the grid could show two sets of rows, one on top
of the other. There were two causes:

- A destroyed view left its row elements in the body. The view made
  on reconnect then painted its own rows over them. Destroy now
  removes its rows, so a new view on the same body paints the only
  rows.
- The row pool hides a spare row with the `hidden` attribute. The
  `display: grid` on the row beat the browser's own `[hidden]` rule,
  so spare rows stayed visible where they were last painted. A
  `[hidden]` rule on the row now hides them.

The bench counted only rows that are not hidden, so it did not show
the second cause.
A tab switch removed the grid from the page or hid it. On disconnect
the grid destroyed its view, and on reconnect it made a new one and
painted every row again.

- lv-grid keeps its view across disconnect and reconnect. The view
  watches only its own scroller, so a grid that is dropped is still
  collected.
- The view has a ResizeObserver on the scroller. While the scroller
  has no height, the view draws nothing. It holds the newest rows, a
  repaint and a go to row, and applies them once on show.
- On show the view puts back the scroll position. The browser sets
  scrollTop to 0 when the box goes.
- The view finds the rows in view from the scroller and the measured
  heights, not from the virtualizer's window. On show the virtualizer
  has not yet seen the restored position or the new height.
- The store controller takes the snapshot it missed while the host
  was detached.

Checked in Chromium: hide, detach, collapse all and expand all, then
attach and show. The same 43 row elements stay in the pool, no rows
overlap, and the scroll position comes back.

Bench at 580k rows, against the last run:
- Time Order: first render 197ms (was 210ms), jumps 23 and 31ms
  (were 24 and 32ms). Every action is under Tabulator.
- Bottom-Up: no change outside run-to-run spread. Jump to end is
  30ms against 23ms for Tabulator, as before this change (27-33ms
  over runs).
From the dev host check against the Tabulator tables:

- The sort icon was a flex item after the title. A title that wraps
  fills the width, so the icon went to the far edge of the column.
  It is now inline after the last word of the title.
- A sortable column with no sort showed no icon. It now shows both
  arrows, faded, as Tabulator does. The sorted direction shows its
  arrow at full strength. The click cycle already had three states.
- Header titles sat at the bottom of the header. They now sit at the
  top, as in Tabulator.
When the rows did not fill the grid, the footer sat under the last
row. Tabulator puts it at the bottom edge of the table.

- The scroller is a flex column, and the body grows to fill it, so
  the footer sits at the bottom edge.
- Nothing in the scroller shrinks. The body holds only absolute rows,
  so as a flex item it would shrink to no height.
- A new `footer-position` attribute keeps the old place for a grid
  that wants it: `bottom` (the default) or `rows`.

Checked in Chromium on Bottom-Up, filtered to one row:
- bottom: the footer bottom is at the scroller bottom (540px).
- rows: the footer top is at the bottom of the last row (94px).
A click on a row focuses the scroller, and keys then move through
the rows. The scroller had an inset focus ring. The header, footer
and Name colour strip covered three sides of it, so in the dev host
only a blue line on the right edge showed. Tabulator shows no ring.

The ring is gone. The selected row colour shows where the keyboard
is, as in Tabulator.
The Tabulator trees open and close a row with the codicon chevrons.
lv-grid drew a filled triangle. The call-tree grid now draws the same
chevron-right and chevron-down glyphs, in the same 16px glyph box.

The glyphs are set in the call-tree adapter, not in lv-grid, because
the codicon font is the app's, not the grid's. lv-grid keeps its own
triangle for other hosts.

Checked in Chromium: a closed twisty draws U+EAB6, an open one U+EAB4,
both in the codicon font.
When a row above the fold changes height, virtual-core moves
scrollTop by the change, to keep the rows in view where they were.
It adds the change to the offset of the last scroll event. A
scrollTop write since that event, from a script in a frame or from
the view's own anchoring, was undone.

In a fling of 40 frames of 1,500px on Bottom-Up at 580k rows, 18
frames went back by about one frame's scroll. One jump to the end was
undone completely. It also made the bench's lv-grid flings cover less
distance than Tabulator's.

Before it measures a row, virtual-core now gets the live scrollTop.
This happens only when the scroller moved since the last check and
virtual-core does not know of the move. Each sync places every row
after the change again, so its own adjustments must not sync.

Bench at 580k rows:
- Bottom-Up jump to end: 17ms, was 30ms (Tabulator 23ms). Every
  Bottom-Up action is now under Tabulator.
- Time Order jump to end: 23ms (Tabulator 33ms).
- Time Order with the sync off and on, back to back: no change
  outside run-to-run spread. Fling and column resize drop no frames.
A STATIC_VARIABLE_LIST event holds one variable per line. The
Tabulator Name cell keeps the line breaks (white-space: pre-wrap).
lv-grid joined the lines into one run of text.

The call-tree grid now sets pre-wrap on the Name text. The rule is in
the call-tree adapter, because it is about log text: lv-grid keeps
its own default.

Checked in Chromium on sample-log.log, Bottom-Up, all rows open: the
STATIC_VARIABLE_LIST rows now show their lines. They are about 11%
taller than in Tabulator (3,130px against 2,810px), because lv-grid
keeps wrapped lines beside the twisty, not under it.
ColumnSettingsController keeps each grid's column view and saves it
in settings. It worked only on Tabulator tables. A grid on lv-grid
needs the same column views and the same saved state.

- A ColumnTarget is a grid that lists its columns (field, title,
  shown) and shows a set of fields.
- gridColumnTarget(grid) makes an lv-grid a ColumnTarget. A view sets
  each column's `hidden`.
- The controller takes a Tabulator table or a ColumnTarget, so one
  host can hold both while it moves to lv-grid. No caller changes.
- The column menu is built from a list of columns, so both kinds of
  grid get the same menu.

When the last Tabulator grid goes, the Tabulator side goes with it.
…lours

In the dev host, the current find match in the call-tree grid looked
the same as the other matches.

- lv-grid registered its marks as `find-match` and
  `current-find-match`, the names the Tabulator Find module uses for
  its own marks. The CSS highlight registry is global to the page,
  so each one replaced the other's registration. lv-grid now uses
  `lv-grid-find-match` and `lv-grid-current-find-match`.
- The call-tree grid did not map the find colours, so it used the
  grid's fallbacks. It now takes the VS Code find colours that the
  Tabulator grids use: editor.findMatchHighlight for a match and
  editor.findMatch for the current one, foreground and background.
- lv-grid gets `--grid-find-fg` and `--grid-find-current-fg` for the
  match text colour. They default to the text colour.
In the dev host, call-tree grid rows drew on top of each other after
the inspector opened or closed, and left gaps after a window resize.

- virtual-core keeps one element for each row and unobserves the old
  element when a row gets a new one. The lv-grid pool gives elements
  to other rows, so an element on screen lost its observer and a
  change to its height was not measured. lv-grid now observes its
  pool with its own ResizeObserver.
- A spare row that was hidden still reported its height, as 0, for
  the row it showed last. The rows below then drew over it. A size of
  0, or an element that does not paint that row now, is ignored.
- A reused element that kept its height reported no change for its
  new row, which kept the estimated height. Each painted element is
  observed again, which reports its size once, in the frame's own
  layout.

Chromium, sample log, after 5 jumps and at 5 window widths: 20 to 26
rows overlapped before, 0 after. Time Order bench at 580k rows: fling
drops 0 of 120 frames, and all actions stay under the Tabulator
baseline.
In some themes the current find match in the call-tree grid looked
the same as the other matches. One theme sets the 2 find colours to
rgba(39, 103, 130) at alpha 0.56 and 0.5. The editor tells them apart
because it also selects the current match: its selection colour lies
under the find colour.

- lv-grid adds the current match's range to a third highlight,
  `lv-grid-current-find-match-under`, below the current-match
  highlight. It uses the CSS Highlight API only, with no DOM change.
  The priorities set the order of the 3 layers.
- lv-grid gets `--grid-find-current-under-bg`, transparent by
  default, so other hosts see no change.
- The call-tree grid maps it to editor.selectionBackground, with the
  system Highlight colour outside VS Code.

Chromium with that theme's colours: the current match is now clearly
brighter than the other match in its row.
…d test

The test replaced globalThis.Highlight and CSS and did not put them
back. It now restores them after its tests run.

The comments on the new internal code in view.ts and highlight.ts,
and on the test's fake, are now line comments. AGENTS.md keeps JSDoc
for exported API.
…r toggle

After a sort, a filter or an expand or collapse, the row that the grid
keeps in place often moved, and in 5 of 7 checks it left the screen.

The grid put the row back correctly, then the rows around it were
measured over the next frames. virtual-core corrects the scroll only
for rows above the viewport. A change in height of a row between the
viewport top and the kept row moved the kept row.

- setRows pins the place it restored. After each draw, the view
  scrolls the kept row back to where it was. virtual-core's own
  correction is off while the pin holds.
- The pin ends when the user scrolls, on go to row, and on destroy.
  The user's scroll is read on each draw, not on the scroll event:
  that event comes after the frame, and a draw before it would undo
  the scroll.
- A pinned place past the end is clamped by the browser. The view
  then stops, so the draw does not loop for ever.

Chromium, sample log, 7 actions (sort, sort again, clear sort,
collapse, expand, Show Details off and on): the kept row stays at the
same pixel. Bench at 580k rows: Time Order and Bottom-Up stay under
the Tabulator baseline, and fling drops 0 of 120 frames.
A second click on a twisty soon after the first did nothing, and the
grid then ignored clicks until the text selection was cleared.

The second press of a double-click selects a word. The grid ignores a
click while text is selected, so that a drag to copy text does not
select a row, and that rule also dropped the twisty click.

- A twisty or group row click toggles even while text is selected.
  The rule stays for selecting a row.
- The second press of a double-click on a twisty or group row selects
  no word.

Chromium, a double-click on a twisty: the row toggles twice and no
text is selected. Before, it toggled once and left a selection.
@lukecotter lukecotter changed the title feat grid spike feat(log-viewer): add lv-grid, a virtual table to replace Tabulator Oct 9, 2026
Comment thread log-viewer/bench/grid/main.ts Fixed
Comment thread log-viewer/bench/grid/main.ts Fixed
Find counts matches in each column's text, but the marks searched all of the
cell, so the match numbers moved. A cell that shows more than its text, such
as a percent after a value, now marks the element that holds the text with
FIND_TEXT_ATTR. The painter puts no space around a cell's content, so a
search for a space finds only the spaces in the text.
A space in the search text now matches any run of spaces, tabs or line breaks,
so a search goes across a line break in a cell. Find and its marks use the one
pattern, so the count and the marks still agree.
… from its text

A column can set exportText. Copy and export write it in place of text, so a
cell can show a formatted value while a spreadsheet gets the full one. Find
still searches text, which is what the cell shows.
Find numbers its matches across the shown columns, so a hidden or shown column
makes those numbers wrong. lv-grid-reshape now also fires, with the reason
columns, when the list of shown columns changes. A new array with the same
shown columns fires nothing.
…grid is removed

CSS.highlights is global, so the marks of a removed grid kept the grid alive.
The grid view now disconnects its observers and clears its marks when the
element leaves the document, and puts them back when it returns. Its rows stay.
A click on the twisty of a row with no children selected nothing and kept a
stale toggle. Only a twisty that can open now has data-toggle, so the click
selects the row. Column fit finds the twisty by its class.
A step that threw left the store busy, and every caller waited for ever. The
store now rejects its waiters with the error and stays usable. When a newer
change came during the failed step, the store builds that change and gives no
one the error of the step it replaced.
…uild

reveal built all the rows again even when nothing opened. When the path is
already open, it now finds the row in the rows on screen. On 563k shown rows
this takes under 0.5ms, against 34-41ms before.
The store reads the clock once per 256 yields, and totals yielded once per
1024 rows, so a slice could run for 262,144 rows. Totals now yield every 32
rows, from one exported ROWS_PER_YIELD.
Leading spaces paste badly into a spreadsheet. When rows have children, a copy
or export of the tree now starts each line with its level: 1 for a top-level
row or a group, 2 for its children, and so on. Name cells have no indent.
The bench indexed a plain object with the c URL parameter, so a value such as
toString could call an Object.prototype method. CodeQL reported this as an
unvalidated dynamic method call.
lv-grid and the painter had the same total function. lv-grid now uses the
painter's.
Comment thread log-viewer/bench/grid/main.ts Fixed
Comment thread log-viewer/bench/grid/main.ts Fixed
CodeQL still reported an unvalidated dynamic method call after the Map lookup,
because the page called the value it found by the c URL parameter. A switch
on literal names now makes each contender, so no call goes through a value
that the URL found.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants