0.4.0 - 2026-10-18
| Change | Who is affected | What to do |
|---|---|---|
state.sorts can now carry more than one key; setState() used to keep only the first entry | A host that reads only state.sorts[0] in fetchData / fetchMeta; it silently ignores the extra keys | Read the whole array, in priority order: state.sorts.map(s => s.field + ' ' + s.dir).join(', ') |
| When a selection reaches rows that were never loaded, those rows are left out of the copied text instead of appearing as empty lines | Code that assumes the copied text has one line per selected row | The number of rows left out is reported by the copy banner (i18n.copyIncomplete), as before. Use exportCsv({ full: true }) when every row is needed |
getState() has a userSizedColumns field, and with stretchColumns on its columnWidths holds each column’s own width rather than its stretched one | Only grids that turn stretchColumns on; with it off nothing changes | Read the on-screen widths from the grid when that is what you need |
getState() takes an argument. The no-argument call is exactly what it was | Nobody | Switch to getState({ rowChanges: false }) where you are saving a view |
Restoring a state saved with getState({ rowChanges: false }) leaves the rows already added to the grid, and their edits, in place; a state that carries rowChanges still replaces all of it | Code that expected a layout restore to clear the rows as well | Call clearEdits() alongside it, or restore a state that carries rowChanges |
The row indexes validateAll() reports can change | Code that reads the keys of validateAll() / getInvalidCells() after inserting or permanently deleting a row | The old values were wrong. The keys are now the visual row indexes they claim to be |
| A multi-line cell in a row too short for it draws whole lines from the top, ending in an ellipsis, instead of a centred block with half-cut lines | Screens with line breaks in their values and no extra row height | A drawing change only. Turn on autoRowHeight, or give the row height with setRowHeight(), to see every line |
role="grid" moved off the grid’s root element onto a container of its own, into which the rows on screen are mirrored as real DOM (ariaRowMirror, on by default) | Code that finds the root with [role="grid"], and tests or snapshots that count DOM nodes inside the grid | Find the root through GRID_CLASSES. Turn the mirror off with ariaRowMirror: false if it is in the way |
The TypeScript return type of getEdits() widened from Record<number, Record<string, string>> to Record<string \| number, Record<string, unknown>>, because of byKey / typed. At runtime the default call still answers with text keyed by row index | TypeScript users calling string methods straight on a value, which no longer compiles | Wrap in String(v), or narrow with getEdits() as Record<number, Record<string, string>> |
Added
-
stretchColumnshands the space left over at the right edge to the columns, instead of leaving the rest of each row blank.new JHGrid({ container: '#grid', stretchColumns: 'last', // the last scrollable column takes the leftover space // stretchColumns: 'all', // split it between the columns, in proportion to their widths // stretchColumns: 'none', // the default, and what the grid did before data: rows, });It only ever adds width: no column ends up narrower than the width it would have had anyway (its
columnDefswidth,colWidth, or whatever the user dragged it to), and a grid whose columns already overflow is untouched.Two kinds of column keep their own width and take no share. Frozen columns, because widening one eats into the scrollable band and can push the grid into dropping it out of the frozen band and back as the window resizes. Columns the user resized by hand, by dragging the header border or through
autoFitColumns(field)- otherwise dragging the column that absorbs the slack would do nothing, since the freed pixels would go straight back to it. With'last'the leftover then goes to the rightmost column they have not sized; if they have sized every one, the space stays blank.refresh()clears those manual widths and the stretch takes over again.The widths are recomputed when the layout changes - a resize, a column added, hidden, shown or deleted, a change of frozen counts, a restored state - and never per frame.
getState().columnWidthsreports each column’s own width rather than its stretched one, andgetState().userSizedColumnslists the columns the user sized by hand. That is what lets a layout saved on a 1,600px screen restore inside a 900px one without a horizontal scrollbar. -
getState({ rowChanges: false })returns the view settings on their own. The no-argument call still carries every unsaved edit, added row and delete mark, as before.grid.getState(); // unchanged: edits included grid.getState({ rowChanges: false }); // the view settings onlyMeasured on the million-row demo:
State getState()getState({ rowChanges: false })No edits 959 bytes 897 bytes 200,000 edited cells 5,378,738 bytes (56ms) 897 bytes (0.7ms) One column edited in every row 21,889,848 bytes (694ms) 897 bytes (0ms) The lean call never builds the edit map at all, so neither its size nor its cost moves with the number of edits. What it removes is a snapshot that cannot be stored (
sessionStoragestops at about 5MB) or cannot even be produced (JSON.stringifythrowingInvalid string length).setState()already accepted a state withoutrowChanges; it now also leaves the grid’s own added rows and their edits alone in that case, the way a sort or filter change does (edits on server rows still go, since the rows are re-fetched). A state that carriesrowChangesreplaces the row work entirely, as before. -
Shift+ wheel scrolls sideways. Some browsers swap the axes before the event arrives and some leave it to the page, so a shift-held wheel that still carries a vertical delta is turned horizontal here. Line-mode wheels (deltaMode: 1) step by column width rather than row height. -
Cut,
Ctrl+X: copies the selected range and then empties its editable cells. The clear is the oneDeletealready performs, so read-only columns keep their values, and the copy and the clear land as a single undo step. The browser’s owncutevent (the Edit menu, a context menu) takes the same path.keyboardShortcuts: { cut: false }switches it off without claiming the key, leaving it to a handler of your own. With only rows selected and no cell, there is nothing it could sensibly clear, so it does nothing. -
Multi-column sort: the grid holds more than one sort key at a time. There is no option to turn on.
await grid.setSorts([{ field: 'dept', dir: 'asc' }, { field: 'salary', dir: 'desc' }]); grid.getSorts(); // [{ field: 'dept', dir: 'asc' }, { field: 'salary', dir: 'desc' }] await grid.addSort('score', 'desc'); // appends; an existing field is re-pointed and moved last await grid.moveSort('score', 0); // change a key's priority await grid.clearSorts(); // sort by nothingdirdefaults to'asc'. A field that is not a column (hidden columns are fine), adirother than'asc'/'desc', the same field twice, or aprioritythat is not a non-negative integer each throw. All four apply throughsetState({ sorts }), so they share the unsaved-edit confirmation and the reload path of a sort applied from the UI, and they return its Promise.From the UI, Shift+click Asc or Desc in a header’s filter panel appends that column to the current sort, and the panel shows each sorted column’s priority as
#1,#2and so on; a plain click still replaces the sort with that one column. The header’s sort arrow carries the same number.The sorting itself is the host’s job. The grid only hands the key list to
fetchMeta/fetchDataasstate.sorts, in priority order. -
Wrapped cell text (
wrapCells) and auto row height (autoRowHeight). Cell text wraps at the column’s width instead of being cut off with an ellipsis, and each row takes the height its tallest cell needs. The two are separate options - wrapping alone leaves the row height as it is and clips the lines past the first.new JHGrid({ container: '#grid', data: rows, wrapCells: true, autoRowHeight: true });A word wider than the column is broken across lines, so Korean, Japanese and Chinese text, which carries no spaces, wraps like a spaced sentence does. Line breaks inside a value still break lines, and
autoRowHeightcounts those too.Heights are measured against the column widths of the moment: dragging a column narrower,
autoFitColumns(), or a container resize underresponsivere-measures the loaded rows. Editing a cell re-fits its row both ways. A row set withsetRowHeight()or dragged by the user keeps that height.How much is measured depends on the row count. Up to 5,000 rows every loaded row is measured, so the scrollbar is exact. Above that the grid measures a window around the viewport (the rows on screen plus 50 either side) and gives rows that scrolled far away their default height back, so a million-row grid still fits the rows you are looking at - at the cost of the scrollbar shifting a little as taller rows come into view. If that matters more, leave
autoRowHeightoff on a grid that size and fit the rows you need withsetRowHeight()or a double-click on the bottom edge of a row number.wrapCellsitself has no row limit.Multi-line cells in a row too short for them are drawn differently: the block used to be centred, cutting the first and last line in half. Whole lines are now drawn from the top, and the last visible one ends in an ellipsis.
-
Runtime event subscription:
on/off/listenerCount. All 19 constructor callbacks can be subscribed to after the grid exists. An event is its callback without theonprefix and with a lowercase first letter (onCellChange→'cellChange'), and a handler receives exactly what the callback received.const stop = grid.on('cellChange', ({ row, field, newValue }) => save(row, field, newValue)); grid.on('rowSelect', (rows) => toolbar.setEnabled(rows.length > 0)); grid.on('sort', fn, { once: true }); stop(); // the unsubscribe function on() returns grid.off('sort', fn); // one named handler grid.off('rowSelect'); // every handler of that event grid.listenerCount('sort'); // subscribers, not counting the constructor callbackThis removes the destroy-and-rebuild workaround for using the grid inside a component lifecycle: keep what
on()returns, call it when the component goes away, and the grid itself is left alone.The constructor’s own callback runs first, then the handlers in subscription order. Subscribing the same function twice replaces the first subscription instead of running it twice. A handler that throws is logged and does not stop the handlers after it or the render. During an event, a handler taken off is not called for the event already running and one added is called from the next event on, the way DOM listeners behave. An unknown event name throws a
RangeErrorlisting the events.destroy()drops every subscription.The core’s call sites are unchanged: only an event that has been subscribed to swaps its option for a dispatcher, so an event nobody listens to -
onRender, called on every frame - costs exactly what it did. Replacing a callback throughsetOptions()keeps the subscribers. -
Runtime theme swap:
setTheme/getTheme. Changes the theme of a live grid: the keys given replace theirs, the rest stay, and unsaved edits, the selection and the scroll position are kept - the dark-mode toggle used to be destroy-and-rebuild, which lost all three. Open panels and menus close and are rebuilt in the new colours on the next open. A change tofontSizeorcellPaddingre-measures the rows underautoRowHeight. -
Runtime data-source swap:
setDataSource. Takes the same three shapes the constructor takes ({ fetchMeta, fetchData },{ fetchPage },{ data }) and starts over exactly asrefresh()does. There was no public way to do this before:DataManager.setFetchis plumbing andreloadData()overwrites it. When only the data behind the grid changed,reloadData()is still the right call - it keeps filters, sort and column state. -
A grand total row:
summaryRow. Give a column an aggregate and a total row is pinned above or below the data.new JHGrid({ container: '#grid', data: rows, rowKey: 'id', summaryRow: { position: 'bottom', aggregates: { salary: 'sum', score: 'avg' }, label: 'Total', labelField: 'name' }, }); grid.getSummaryValues(); // { salary: 412000000, score: 81.4 } grid.getSummaryStats(); // { rows: 1000, missing: 0, capped: false, source: 'computed' }It is an ordinary pinned row, added after whatever the host pinned, so the host’s own pinned row indices do not shift (
getSummaryRowIndex()reports where it sits). Edits, sorts, filters and newly arrived chunks all update the numbers, and so do undo and redo. An edit does not count every row again - it is added to and subtracted from running totals, which costs 0.02ms per committed cell on a 200,000-row grid. Where that cannot be done (the smallest or largest value disappearing, a custom aggregate function, host-supplied totals) everything is counted again;refreshSummary()asks for that pass directly.positionis'bottom'(the default),'top'or'both';labelandlabelFieldplace the label, andbackgroundpaints the row (nullleaves it unpainted). The aggregates share one implementation with group aggregation (common-core/module/aggregate.js), and a money total is carried as an integer of the smallest unit seen, so0.1 + 0.2style error never reaches the last digits: 200,000 rows of decimal text total in 25.6ms.A server-backed grid that is missing rows leaves the total blank, with one warning, rather than showing a plausible-looking wrong number (
getSummaryStats().missing);allowPartialtotals the loaded rows andsetSummaryValues()takes a server’s own totals. PastmaxRows(200,000 by default) nothing is totalled andcapped: truesays so. -
Rows by key:
getRowIndexByKey,getRowDataByKey,setCellValueByKey. A sort, a filter or a row inserted above moves row indices; therowKeyvalue keeps naming the same record. Each walks the rows the grid holds, so they are O(loaded rows) - not for a render loop. -
Typed edits:
getEdits({ byKey, typed })andcolumnDefs[].parse.getEdits()still answers with text keyed by row index.byKeykeys by therowKeyvalue instead, andtypedruns the column’sparse(value, rowData, field)- or restores the type the original value had - so numbers come back as numbers and'true'/'false'as booleans.getChanges()goes through the sameparse. The grid still works in text internally; only the host boundary changed. -
Row move from code:
moveRow(from, to). Until now a row could only be moved by dragging the row-number gutter; this does the same thing from code.await grid.moveRow(5, 0); // row 5 goes first. true when it movedtois where the row ends up - the same numberonRowReorderreports astoIndex. It goes through the drag’s own path, soonRowReorderfires, the state keyed by row index (edits, selection, undo) is discarded exactly as a drag discards it, and an unsaved edit asks first. If the user keeps their edit at that confirm, the order stays as it was and the call answersfalse.It needs
rowReorder: true. The order can only be rewritten once the scan that option starts has the rows in memory, so the first call takes as long as that scan - which is why it is a Promise: the caller starts the scan instead of waiting for a drag to start it. Rows added and not saved yet, and pinned rows, cannot be moved, as with the drag. -
Screen-reader table navigation:
ariaRowMirror. The rows on screen are mirrored into real DOM. Until now only the active cell was exposed, so a reader’s table mode had nothing to read around it and the cell to the left could not be heard without moving the selection. The grid carriesaria-rowcountfor the whole table and mirrors the window being looked at, as a virtualised ARIA grid does.It is rebuilt when that window moves, not per frame, and its nodes are reused, which puts a scrolled frame at 212µs over five columns (Chromium: 744µs with the mirror against 460µs without). On by default;
ariaRowMirror: falseturns it off and{ maxCells }(default 2000) sets where a very wide grid stops paying for it - past that only the active cell is exposed, with one console warning.Pinned rows (
pinnedTopRows/pinnedBottomRows) and the grand total row never leave the screen, so they are always mirrored, andaria-rowindexfollows reading order: the header is 1, then the rows pinned above, the body, and the rows pinned below. The role sits on a container of its own rather than the grid’s root, which also holds the canvas, the hidden text field that takes keyboard input, the live region and whatever panels a feature puts on it - none of which ARIA allows as children of agrid. The focused text field points at that container witharia-controlsand at the active cell witharia-activedescendant. -
One row’s edits as pairs:
getRowChanges(rowIndex, { typed }). Each edited field with the value it had:{ qty: { before: '10', after: '99' } }. The two halves used to come from different calls (getOriginalRowData()andgetEdits()) and lining them up was the host’s job. -
A number-only editor:
editor: 'number'. Digits, one decimal separator and a leading minus. Arrow Up and Down step bystep, and the committed value is rounded todecimalsand clamped intomin/max. An empty cell stays empty rather than becoming 0.{ field: 'qty', editor: 'number', editorOptions: { min: 0, step: 10 } } { field: 'rate', editor: 'number', editorOptions: { decimals: 2 } } -
A radio column:
type: 'radio'. Hand it a list of options and they are drawn as circles inside the cell, one click away from being chosen. No popup, no editor.{ field: 'stage', type: 'radio', options: ['Waiting', 'Running', 'Done'] }optionsis whatdropdowntakes - an array of strings, an array of{ value, label }, or a function giving a different list per row. The cell holds the chosen option’svalue, so to a filter,getEdits(), CSV and Excel it is an ordinary text column: a CSV carries the chosen value as it is.It shrinks itself to the column. Labels are drawn where they fit; where they do not, the circles alone; where even those are too many, the gaps close; and past that the row of circles is pushed so that the chosen one stays visible. Nothing is ever drawn outside the cell (checked in pixels in a browser for two to six options at widths of 320, 200, 120 and 70px).
A click picks the option whose circle or label was hit, and
Spacesteps to the next one and wraps around (the arrow keys still move between cells). A read-only column is drawn and never changes.For a different alignment or circle colour, give the renderer directly:
renderer: CellRenderers.radio({ options, align: 'center', checkedColor: '#2563eb' }). -
Hiding rows:
hideRow/hideRows/showRow/showAllRows/getHiddenRows/isRowHidden. Takes a row off the screen without deleting it. Right-clicking the row-number gutter offers Hide this row, and Show all hidden rows next to it once something is hidden (both go away with'row-hide'left out ofrowContextMenuItems). A hidden row leaves an extra line in the gutter where it was.A hidden row drops out of the row plan the same way a permanently deleted one does, so
getTotalRows(), the row indexes below it, a CSV or Excel export and every aggregate that walks the rows already leave it out. It is not a change to save, so it appears in neithergetChanges()norgetRemovedRows(); it is a view setting, sogetState()carries it ashiddenRows.grid.hideRow(3); grid.hideRows([0, 2, 5]); // one undo step; answers with how many were hidden grid.getHiddenRows(); // [{ index: 3, key: '42' }] grid.showRow(3); // by the index above grid.showAllRows();Give the grid a
rowKey. A sort, a filter andreloadData()renumber every server row, so a hidden row is found again by itsrowKeyvalue (and hidden as its chunk arrives, if it lands in a range that is not loaded yet). Without arowKeythere is nothing to follow it by, so hidden rows come back after a re-fetch and the console says so once. A row added to the grid and not saved yet has no value to identify it with and cannot be hidden - the menu does not offer it either. -
summaryRow.rowsandsummaryRow.excludeRow. The host says which records the grand total counts.rowsreturns the records to total instead of the rows on screen, andexcludeRowdrops a row that is on screen but is not data - a header row that already shows a subtotal, say. It is what a host that owns its records and shows a shaped view of them needs.summaryRow: { aggregates: { amount: 'sum' }, rows: () => myRecords, excludeRow: (r) => r.isHeader } -
Space under a row:
reserveRowSpace(rowIndex, px), withgetRowRectandgetViewportRect. Keeps a strip under a row’s cells that the grid lays out but never draws in and that clicks pass through. The row grows, the rows below move down, and the strip is the host’s to cover with its own element.grid.reserveRowSpace(3, 180); grid.on('render', () => { // scroll, sort and resize all redraw const rect = grid.getRowRect(3); Object.assign(panel.style, { top: `${rect.reservedTop}px`, left: `${rect.left}px`, width: `${rect.width}px`, height: `${rect.reservedHeight}px` }); });The strip is not part of the row’s cells:
getRowHeight()still reports the cell height,setRowHeight()still sets it,autoRowHeightmeasures text as if the strip were not there, and an editor covers the cells only.getViewportRect()gives the band to clip host DOM to, so it cannot cover the header, a pinned row or the scrollbars.getReservedRowSpace(rowIndex)andgetReservedRows()report what is reserved where. Opening or closing several strips at once updates the layout immediately -getRowRect()right after the call is correct - while the painting is coalesced into one per frame. -
Reverting one row:
revertRow(rowIndex). Throws that row’s unsaved edits away and puts its cells back to the values the data source gave. One undo step, soCtrl+Zbrings them back, and the validation marks that came with those edits are cleared with them. Until now the choice wasclearEdits(), which discards every row’s edits and the undo history, or readinggetOriginalRowData()and writing it back by hand withsetCellValues().if (response.status === 409) grid.revertRow(rowIndex); // someone saved first: start that row overIt leaves delete marks alone (
undeleteRow()clears those), and a row added withaddRow()is not removed - it goes back to the values it was added with. A failed save needs no call at all: edits stay untilacknowledgeSave()/acknowledgeChanges(), so doing nothing already means “keep the user’s work and retry”. -
Header groups survive a column drag:
lockHeaderGroups. A group cell is drawn over the columns of that group that sit next to each other, so a column dragged out of its group - or an outsider dropped into the middle of one - split that one cell into two. With this on, the insertion point moves to the nearest edge that leaves every group in one piece.new JHGrid({ lockHeaderGroups: true, columnDefs: [ { field: 'bpNm', label: 'Customer', group: 'Trade' }, { field: 'loNm', label: 'Centre', group: 'Trade' }, { field: 'qty', label: 'Qty' }, ] });A grouped column moves within its own group and no further; an ungrouped one can go before or after a group, never inside it. Off by default (the old behaviour), and it changes only what a drag can do - not the starting column order,
hideColumn()oraddColumn(). Only the top header row is enforced. -
Dragging rows between grids:
rowDragGroup. Grids given the same name exchange rows through the row-number gutter. The grid the row lands on draws the drop line; the one it is leaving keeps it shaded until the button comes up.new JHGrid({ container: '#backlog', rowDragGroup: 'tasks', columnDefs, data: backlogRows }); new JHGrid({ container: '#sprint', rowDragGroup: 'tasks', columnDefs, data: sprintRows, onRowDropIn: ({ rowData, toIndex }) => console.log('took', rowData.task, toIndex), });The move is made of two public APIs:
addRow(rowData, { index: toIndex })on the receiving grid anddeleteRow(rowIndex)on the sending one. Each half therefore shows up in that grid’s owngetChanges(), and each host saves its own. Under the defaultdeleteMode: 'mark'the row that left stays visible and struck through; give the sending griddeleteMode: 'permanent'to have it go at once.onRowDragOutcan call the whole move off withfalse, andonRowDropIncan refuse withfalseor return a row object to insert instead - which is where two grids with different columns meet.onRowDropIn: ({ rowData }) => ({ title: rowData.task, assignee: rowData.who, state: 'todo' }),Dragging out does not need
rowReorder: only the one row travels, so the full scan that option starts is not involved. Set both when the same grid should also reorder within itself. Rows added and not saved yet, and pinned rows, still cannot be dragged, and the move is announced through the live region asi18n.rowDragOutAnnounce. -
Number and money formatting:
rounding,negative,negativeColor, anddecimalsoncurrency. Accounting rounding and bracketed negatives without writing a renderer.{ field: 'amount', renderer: CellRenderers.number({ decimals: 0, rounding: 'halfEven', // banker's rounding negative: 'parens', negativeColor: '#ef4444' }) } // -1234 -> (1,234), in red { field: 'price', renderer: CellRenderers.currency({ locale: 'ko-KR', currency: 'KRW', decimals: 2 }) } // overrides the currency's own 0 digitsroundingtakes Intl’s nine rounding modes (ceilfloorexpandtrunchalfCeilhalfFloorhalfExpandhalfEvenhalfTrunc). The option comes fromIntl.NumberFormatv3, which is newer than the supported browser floor: where it is missing the renderer says so in the console once, rather than silently, and draws with the defaulthalfExpand. An unknown mode, or an unknownnegativevalue, throws as the renderer is built.
Fixed
-
validateAll()validated the wrong rows after a row was inserted or permanently deleted. Walking the loaded server rows, it passedDataManager’s server index straight to the validator, which keys everything by visual row. One locally added row shifts every row below it by one, so a row that really broke its rule came back clean while an untouched row was marked invalid.getInvalidCells()andisValid()reported the same wrong rows. -
Editing a cell in a bottom pinned row (
pinnedBottomRows) made it appear ingetChanges().updatedas an edit to a server row. The pinned-row test was called without the total row count, so it caught only top pinned rows (negative indexes) and let bottom ones (indexes at or past the row count) through - which means sending an UPDATE for that row’srowKey. Bottom pinned rows are now filtered out like top ones. -
aria-rowcountleft the pinned rows out. Eight code paths wrote it - a chunk arriving, a row added or deleted, a filter,setStateand the rest - and every one of them wrotebody rows + 1, so a grid usingpinnedTopRows/pinnedBottomRowstold a screen reader the wrong size: 5 body rows, 2 pinned rows and a total row reportedaria-rowcount="6". One function (_syncA11yCounts) writes it now, counting the header, the rows pinned above, the body and the rows pinned below, so the same grid reportsaria-rowcount="9". -
An automated audit reported a critical accessibility violation on every grid.
role="grid"sat on the grid’s root element, which also holds the canvas, the hidden text field that takes keyboard input, the live region and whatever panels a feature puts on it - and ARIA allows agridno children but rows and rowgroups, so axe reportedaria-required-childrenon all seven grids of the demo page. The role moved to a container of its own that only the row mirror writes into, so nothing added to the root can break it again. The focused text field points at that container witharia-controlsand at the active cell witharia-activedescendant;aria-rowcount,aria-colcount,aria-busyandaria-multiselectablelive on it too. Across the five demo states the report went from one critical and one serious violation type to none. -
Hint text in the overlays sat at 3.19:1 against their white background, under the 4.5:1 WCAG AA asks for body text. The default
theme.overlayHintTextwent from#909090to#6E6E6E(5.10:1), and the dark-theme example’s#6b7280(3.04:1) to#9ca3af(5.78:1). -
A panel or menu left open could stay on screen after the point it hangs from had scrolled out of view. Whether to close was decided inside a
requestAnimationFramecallback, and a browser delays those when it is busy - with frames stopped altogether the panel never closed (measured: open before the fix, closed after). The decision is now made on the scroll event itself, and only the repositioning is still coalesced into a frame. -
A grid whose columns had not arrived yet put a row with no cells in the accessibility table. ARIA asks a
role="row"for at least one cell, so an automated audit reports it as critical (aria-required-children) - which it did as soon as one still-booting grid shared the page. With no columns the mirror is left alone.
Performance
-
Changing many columns at once was quadratic. Each column added or deleted snapshotted the whole structure and repainted the canvas - deleting 1,000 columns meant 1,000 paints. A new method
batchStructural(fn)runs the lot as one snapshot, one paint and one undo step: on the same grid, deleting 400 columns went from 1140ms to 12.3ms and 1,000 from 6451ms to 58.8ms (Chromium, each mode measured on its own freshly loaded page). -
A paste wrote one undo record per cell. On a million rows over five columns - five million cells - that record was 45% of the whole operation. A paste over 50,000 cells now snapshots the edit map instead (
ShardedMap.fork()is copy-on-write, so a fork costs nothing per cell): 12.8s down to 5.2s for the same paste, still one undo step, and edits made before the paste still come back when it is undone. Resolving the row data once per row rather than once per cell is part of the same change. -
autoFitColumns()measured every loaded row withmeasureText. A server-backed grid is protected by its chunk cache, but in adataarray grid every row counts as loaded, so one call on a million rows over nine columns froze the page for 12.9 seconds - anIntlformat and a canvas measurement per cell. It now shortlists the 32 longest raw values and formats and measures only those: 302ms for the same grid, fitting to the same width. -
exportCsv({ full: true })andprintGrid({ full: true })walked the rows one page at a time atchunkSize(300 by default) - 3,334 serial round trips for a million rows. Pages are now fetchedfullScanConcurrencyat a time (2-8, from the core count) and handed over in order, atfullScanPageSizewhen that option is set. Walking a million rows takes 93ms against in-memory data and 6.2s over HTTP (1.7s withfullScanPageSize: 20000), and a million-row, twenty-two-column CSV export went from over 240 seconds to 5.3. -
Selecting a million rows (
Ctrl+A) and copying froze for 5.2 seconds and built a 22.4MB string, 999,400 of whose lines were nothing but tabs for rows that had never been loaded. Rows with nothing to copy are now skipped, and the same gesture finishes in 74ms and 0.41MB. The number of rows left out is still reported by the copy banner. -
getChanges()found the locally added rows by walking the visual rows from the top - a row added at the end of a million meant a millionisNewRow()calls._rowPlanalready knows each local row’s visual index, so it is used directly: 11.5ms down to 0.3ms in that case. -
Changing a single column painted the canvas twice.
deleteColumn,addColumnandhideColumneach paint at the end, and the wrapper that turns them into one undo step painted the finished state again. The wrapper now owns the only paint: on a 300-column grid, 200deleteColumncalls went from 355.8ms to 236.4ms and 80hideColumncalls from 271.8ms to 232.2ms (Chromium, mean of three runs). -
Every single column change rebuilt the whole accessibility header. The header row a screen reader reads has one element per column, and
deleteColumn,addColumnandhideColumnrebuilt all of it on each call - on a 1,000-column grid, deleting 1,000 columns meant building a million elements nobody read, which was nearly all of what was left once the paints were coalesced (633.4ms across 1,002 calls). A batch now defers it and builds it once at the end: those calls went from 633.4ms to 48.8ms, the whole batched 1,000-column delete from 487ms to under 59ms, and a 1,000-column rebuild down to 6 columns from 665.9ms to 84.9ms (measured in Chromium).aria-colcountandaria-colindexmatch the final columns once the batch ends.
Docs
-
The documentation is several pages instead of one.
api.mdhad grown past 2,500 lines and nothing could be found in it. It is now split by subject, with look-up-by-name references, a feature index and a guide list.api.mdstays as an index in which every old anchor still resolves, so saved links need no fixing. -
The README benchmark was measured again. The other grids are named with their versions now and every figure was re-measured. Three sort cases were added along with a row for scrolled frame p95 and frames over 50ms, and the table says how the gap opens between 10,000, 100,000 and 1,000,000 rows. The run is reproducible:
node scripts/bench-vs-grids.mjs --sizes 1000000 --libs <node_modules holding the other grids>.