Repository navigation
feat(log-viewer): add lv-grid, a virtual table to replace Tabulator - #1166
Draft
lukecotter wants to merge 49 commits into
Draft
lukecotter wants to merge 49 commits into
lukecotter wants to merge 49 commits into
Conversation
… 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
added this pull request to stack #1168
October 9, 2026 14:54
This was referenced Oct 9, 2026
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📝 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-boundaryoxlint rule: app code imports onlygrid/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.ColumnSettingsControllercan drive an lv-grid as well as a Tabulator table.pnpm bench:grid <log> <out> call-tree [--tree bottom-up]runs the grid in headless Chromium.scripts/grid-bench/compare.mjscompares a run with the saved Tabulator baseline.pnpm measure gridtimes 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.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)
📷 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?
📚 Docs updated?
🧪badge — see RELEASING.md)Anything else we need to know? [optional]
grid/AGENTS.mdhas the layer map. Start atcore, thenrender, thenui.