0.3.0 - 2026-10-01
Upgrading from v0.2.0
Nothing has to change for your code to keep working. No export, method, option, cell renderer, editor or CSS class of v0.2.0 was removed or renamed; methods were only added (checked by comparing against the v0.3.0 bundle). The items below behave differently in an existing implementation and are worth a look.
| Change | Affects | What to do |
|---|---|---|
With neither width nor height given, the grid now takes the container element’s CSS size instead of a fixed 1200x700 | Grids constructed without width/height that relied on the 1200x700 fallback. A container with no CSS height collapses the grid | Give the container a CSS size (recommended), or pass responsive: false to keep the fixed 1200x700. Passing either width or height keeps the old fixed size as before |
The filter panel, right-click menus and column chooser are drawn directly under <body>, not inside the grid element | CSS that styles them through a grid container selector such as #grid .jhg-panel or #grid .jhg-menu | Select .jhg-panel / .jhg-menu / .jhg-dialog directly. .jhg-root { --jhg-... } variables still apply |
clearFilters() asks for confirmation when a server row has an unsaved edit | Code that calls clearFilters() while the user may be editing | Cancelling leaves the filters, sort and edits as they were. Confirming resets edits, delete marks and undo history and loads again from the first page |
Image cells only load http:, https:, blob: and data:image/... | Image values that use file:, ftp:, data:text/html and the like | They show the error tile. Relative paths work as before |
| The row-number right-click menu gains a “Pin row” item by default | Automated tests that rely on the menu’s item count or positions | Pass a rowContextMenuItems list without 'row-pin' to switch it off |
| The column right-click menu item “Freeze column” is now “Freeze columns to the left” | Tests that find the item by its text | A value you set through i18n (colFreeze / colUnfreeze) is still used as is |
type: 'date' validation is stricter (February 31 and out-of-range hours, minutes and seconds are invalid) | Data that used to pass with an impossible date | Clean up the data |
| A load that fails on a network error is attempted up to 3 times | Code that assumed the failure callback (onChunkError) arrives at once | The callback arrives later |
Repeated setCellValue() calls repaint once on the next frame | Tests that read the canvas right after the call | The value and getEdits() reflect each call immediately; check the picture on the next frame |
- TypeScript: the parameter of an
onSelectionChangecallback is nowCellSelection | null(typeis optional andcolisnumber | string). A callback whose parameter you typed by hand with the narrower shape can fail to compile understrictFunctionTypes; useCellSelectioninstead. Runtime behavior is the same. - Sorting and filtering of a
dataarray give the same rows as v0.3.0, ties included (see Changed / Performance below). - The items under Fixed below correct behavior that was wrong, so nothing needs to be done for them.
Added
- Pinned rows: rows that stay fixed at the top or bottom of the viewport regardless of scroll, sort, filter or paging. Intended for totals/summary rows that must not be sorted away with the rest of the data.
- Options:
pinnedTopRows/pinnedBottomRows(arrays of row data). - Methods:
pinRow(rowIndex, position)/unpinRow(handle)/isRowPinned(rowIndex)/togglePinRow(rowIndex, position)/getPinnedRows(position)/setPinnedRows(rows, position).isRowPinnedmatches by source-row reference, so a row that was evicted from the cache and refetched can read as unpinned. - Pinned rows are their own small data source, not a live view of the main dataset.
pinRow()copies a snapshot ofgetRowData()at call time (the original stays in the body), and editing a pinned cell changes only the pinned copy (onCellChangestill fires). Edits stay through a sort or filter change andreloadData(). - A pinned row is addressed by an encoded index,
-(i+1)for top andtotalRows + ifor bottom, which works as-is ingetRowData/setCellValue/onCellChange/onRowSelectand friends. - Cell editing (respecting
editableCols), row selection, frozen columns, undo/redo and arrow-key navigation work as on a normal row, and pinned rows are included ingetState()/setState(). - The row-number right-click menu gains “Pin row to top” / “Pin row to bottom” / “Unpin row”. Switch it off or on with the
'row-pin'key ofrowContextMenuItems. The default menu now has more items, so check anything that relied on the item count or positions. rowPinButtonoption: draws a pin icon at the right of the row-number gutter to pin/unpin with one click.trueor'top'pins to the top band,'bottom'to the bottom. WithshowRowNumbersoff it is ignored with a console warning.onRowPinToggle({ pinned, rowIndex, position, rowData, handle })callback: called right after the user pins or unpins a row with the pin icon or the right-click menu (not when code calls the methods). A host that wants a “move” pin (original taken out of the body) can drop the original from its own data here and callreloadData().- A row the host supplied through the options or
setPinnedRows()(a totals row, say) gets no right-click menu and no pin icon, so it cannot be unpinned from the UI. - Not supported in this version: per-row height/auto-grow/resize for pinned rows, row drag-reorder, the fill handle, the keyboard row-focus mode (← from the first column), and drag range-select spanning into or out of a pinned band.
- Options:
responsiveoption: tracks the container element’s CSS size with aResizeObserverand sizes the grid to it. On by default when neitherwidthnorheightis given, off when either is (the old fixed-size behavior). An explicitresponsive: falsekeeps the fixed 1200x700 with no width/height given.- While it is on,
width/heightare ignored with a console warning. Size the container with CSS instead. - Browsers without
ResizeObserversize the grid once and do not follow later changes.
- While it is on,
setOptions(patch): the options you can change after creation arerowContextMenuItems/colContextMenuItems/cellContextMenuItems/cellContextMenuExtraItems/rowPinButton/onRowPinToggle. Any other key is ignored with a console warning while the rest of the same call still applies. Passingundefinedfor a menu option restores its default.- An unknown key (a typo, say) in
rowContextMenuItems/colContextMenuItems/cellContextMenuItemsnow logs a console warning listing the supported keys. Before, the item just went missing silently. keyboardShortcutsoption: switches built-in keyboard shortcuts off individually.{ delete, undo, redo }; any key you leave out stays on.delete:Delete/Backspaceclearing the selected cells,undo:Ctrl+Z,redo:Ctrl+Y/Ctrl+Shift+Z.- Only the keyboard is affected.
undo()/redo()calls and clearing cells through the context menu or the API keep working. - A switched-off key is not
preventDefaulted either, so a hostkeydownlistener can use that key itself.
size/alignoptions onCellRenderers.image(): keep the image a fixed size regardless of column width and row height. With no options it fills the cell as before.size: 32is a 32x32 box andsize: { width, height }sets both sides.fitapplies inside the box ('contain'leaves margins,'cover'crops).size: { height: 32 }(orwidthalone) fixes one side and lets the other follow the image’s aspect ratio.- The box shrinks, keeping its proportions, to stay inside the cell (minus padding), and is placed horizontally by
align: 'center' | 'left' | 'right'(default'center').
- Page-size selection: pass
pagination: { enabled, pageSize, pageSizeOptions: [25, 50, 100] }and the pager bar gets a page-size dropdown. Without the option there is no dropdown, as before.pageSizePosition: 'left' | 'right'places the dropdown (default left). Its accessible name and tooltip followlocaleand can be overridden withi18n.pagerPageSize.setPageSize(n)/getPageSize(): change and read the rows per page from code. The first row of the page being viewed stays on screen, so the page number is recomputed, and selection is cleared. Anything but a whole number of at least 1 throws aTypeError; with pagination off it does nothing.onPageSizeChange(pageSize)callback.onPageChangealso fires when the page number moved.
reloadData({ keepScroll }): re-fetches the data behind the grid after the host changed it (row removed, rows appended, server push) while keeping the current filter, sort, column state and scroll position. Selection, unsaved edits and the undo history are cleared. Returns aPromisethat resolves once the re-fetch is done; withkeepScroll: falseit starts from the top.onCellClick(rowIndex, rowData, field)callback: called when the left button is pressed and released on the same cell (a tap, on touch). It fires alongside the built-in selection, and not for a drag to another cell, a right-click, or a click on a header or the row-number gutter. On a pinned rowrowIndexis the pinned-row index.- Methods to control cell selection, focus and editing from code.
getSelection(): the current cell selection in the shapeonSelectionChangereports ({ type: 'single', row, col }/{ type: 'range', r1, c1, r2, c2 }/null).setSelection(selection, { scroll }): select a cell{ row, col }or a range{ r1, c1, r2, c2 }, or clear withnull. A column is an index or a field name, and whatevergetSelection()returns can be passed straight back in. The selected cell (a range’s top-left cell) is scrolled into view unlessscroll: false, and a row on another page switches to that page. It callsonSelectionChangeand leaves row selection and keyboard focus alone.focusCell(row, col): select the cell and give the grid keyboard focus, so the arrow keys,Enterand typing carry on from that cell.startEditing(row, col): select the cell and open its editor, like a double-click. Returnstruewhen an editor opened,falsefor a read-only cell and for checkbox/button columns.stopEditing({ cancel }): commit the open editor, or discard it withcancel: true. Does nothing when no cell is being edited.- A pinned row can be selected, focused and edited as a single cell but cannot be part of a range, and with pagination a range cannot span two pages. A row that does not exist or a malformed selection throws a
TypeError; an unknown field, an out-of-range column or a range spanning two pages throws aRangeError.
index.d.tsgains the type declarations for the options, methods and callbacks above.await grid.ready()now waits for the first rows to load. Before, only the columns were known andgetRowData(0)right after it returned null. It is released if the first chunk fails or is replaced by a reload, and it is capped at 10 seconds.rowKeyoption andgetChanges():{ updated, added, deleted }. Changed rows are identified by key instead of row number, and a cell that was a number comes back as a number.getEdits()is unchanged.acknowledgeChanges(): tells the grid that the changes have been saved (edits, added rows and delete marks are dropped) and reads the data again. It exists becauseclearEdits()followed byreloadData()leaves the rows added in the grid in place, so they appear twice once the server returns them. Filter, sort and scroll position are kept.datacan be a Promise or a function:data: () => fetch('/api/rows').then(r => r.json()). The function is called again at start and byreloadData()/refresh()(sorting and filtering work on the rows already held, so they do not call it), and no top-levelawaitis needed. “Server paging + editing + saving the changes” goes from 18 lines to 11 (with a single endpoint).
Changed / Performance
- Looping over
setCellValue()used to repaint synchronously on every call; it now repaints once on the next frame. 3,000 calls drop from about 3 seconds to about 17ms. The value andgetEdits()reflect each call immediately. - Scrolling with many invalid cells is cheaper. With 20,000 invalid cells, frame cost drops from about 3.4ms to 1.0ms, with identical output.
- Clearing a very large range (
Ctrl+AthenDelete) no longer keeps undo memory for every cell. Clearing 1,000,000 rows × 20 columns (20,000,000 cells) takes about 15 seconds and about 1 GB of extra memory, andCtrl+Ztakes about 12 seconds. Repeating the clear and the undo does not accumulate memory, and cells that are already cleared are skipped when cleared again, soonCellChangeis not called for them again (ranges above 50,000 cells). - With a very large number of edited cells, starting a column resize, a column reorder or a row-height resize no longer pauses for seconds to copy every edit; it responds at once.
- Sorting and filtering of a
dataarray are faster (same rows as before).- Each sort column is extracted once and sorted as indexes, and one sort or filter now shares its result between
fetchMetaandfetchData. Before, each recomputed everything. - A text column has its distinct values sorted first and gives each a rank; the rows are then sorted by rank. This helps a lot on columns with repeated values such as a department or a status.
- A text column with many distinct values (names, e-mails, …) is first sorted by a number built from the first characters, then the equal keys and every neighbouring pair are checked and corrected with the real compare. Numeric-aware ordering (
staff9 < staff10) is reflected in the key. If the check fails (a locale with special case or accent rules, ignorable characters, …) the values are sorted the old way, so the result is always the same. - A text column that has values reading as numbers, such as
"123", does not use this key (numbers compare as numbers but as text next to letters, which a character-by-character key cannot express). - After you change the array in place, the next query always reflects the current array (
reloadData()and so on). -
Compared with v0.3.0 (Chromium, randomly generated data, the
dataarray option, median of 3 runs per operation). Time runs from the API call until the grid shows the right first row and row count. v0.3.0 is the same file as@jh-grid/jhgrid-js@0.3.0on npm.Operation 100k rows 1M rows 2M rows Sort a number column 83ms → 67ms (1.2x) 351ms → 216ms (1.6x) 700ms → 450ms (1.6x) Sort text, 5 distinct values (department) 200ms → 50ms (4.0x) 1.48s → 119ms (12.5x) 3.07s → 249ms (12.3x) Sort text, Korean names (some repeats) 700ms → 100ms (7.0x) 8.52s → 335ms (25.5x) 19.0s → 651ms (29.2x) Sort text, all values different (name) 1.18s → 116ms (10.2x) 14.98s → 820ms (18.3x) 33.6s → 1.78s (18.8x) Sort text, all values different (e-mail) 650ms → 100ms (6.5x) 8.95s → 783ms (11.4x) 19.9s → 1.75s (11.4x) Filter (department = Sales) 50ms → 34ms (1.5x) 99ms → 66ms (1.5x) 166ms → 83ms (2.0x) Sort a number column inside a filter 51ms → 50ms (1.0x) 167ms → 83ms (2.0x) 332ms → 150ms (2.2x) Quick filter 100ms → 83ms (1.2x) 700ms → 382ms (1.8x) 1.45s → 766ms (1.9x) Item v0.3.0 v0.3.0 Longest page stall while sorting 2M rows by name 33.6s 1.8s Scrolling 3 s with the mouse wheel, frame time 16.7ms (0 frames over 50ms) 16.7ms (0 frames over 50ms) JS heap after loading 2M rows 264MB 278MB - The first 50 rows and the row count were identical between the two versions for every operation. The expected result was worked out separately by scanning the data.
- Differences of 10 to 30ms are of little meaning because of the paint interval (about 17ms).
- Each sort column is extracted once and sorted as indexes, and one sort or filter now shares its result between
Changed / Security
- Image cells only load
http:,https:,blob:anddata:image/...URLs. Any other scheme (javascript:,file:,ftp:,data:text/html, …) is not requested and shows the error tile instead, with a single console warning. Relative paths work as before. This closes a path where merely drawing a cell could fire a request for an arbitrary scheme when the data comes from outside. - The image cache is now bounded, so scrolling quickly through data with very many distinct URLs no longer grows memory and pointless requests without limit.
- The window opened by
printGrid()no longer has awindow.openerreference back to the page.
Changed (UI)
- The filter panel, the right-click menus, the column chooser and the insert-column dialog are now drawn directly under
<body>instead of inside the grid element. If you styled them through a container selector (#grid .jhg-panel,#grid .jhg-menu), select.jhg-panel/.jhg-menu/.jhg-dialogdirectly instead..jhg-root { --jhg-... }variable overrides still apply to them. - The column header right-click menu items “Freeze column” / “Unfreeze column” are now “Freeze columns to the left” / “Unfreeze columns to the left” (Korean, Japanese and Chinese likewise). A value you set yourself through
i18n(colFreeze/colUnfreeze) is still used as is.
Fixed
- Clearing a range of more than about 16.77 million cells (for example
Ctrl+AthenDeleteover 1,000,000 rows × 21 columns) stopped part-way withRangeError: Map maximum size exceeded, leaving some cells uncleared. The whole range is now cleared, and it can be undone. - After dragging a column header to reorder columns, a sort, a filter,
reloadData(), or hiding and showing a column put the columns back in their original order. - A
formaton atype: 'date'column that mixes separators (for exampleYYYY-MM-DD HH:mm) printed the first separator everywhere while typing digits, giving2024-05-06-13-45. It now gives2024-05-06 13:45. - Pasting roughly 130,000 rows or more at once failed with a
RangeError. - A column
renderer/editornamed after somethingObjectalready has ('constructor','__proto__','toString', …) made grid initialization fail or threw while drawing. Such names now fall back to the default renderer/editor, the same as an unregistered name. - A string
validation.patternthat was not a valid regular expression threw when a cell edit was committed. It now logs one console error and skips that rule. - A grid placed inside a container with
overflowset tohiddenorauto(a card, a panel, …) had its header filter panel cut off at the container’s edge. Popups are now drawn on<body>(see Changed above), so they are no longer clipped. - Fill handle problems with frozen columns: with two or more frozen-left columns, no handle showed for any of them except the last; and when horizontal scrolling slid the selected cell under the frozen-right column, the handle was painted on top of that column and an invisible handle could be grabbed.
- Images in image cells (
type: 'image'/CellRenderers.image) were stretched to the cell’s aspect ratio regardless offit. Images are now scaled to the cell while keeping the source aspect ratio, and raster images are never upscaled beyond their original size. - Changing the filter or sort again before the previous request had finished applied the earlier state’s row count instead of the latest one. For example the grid could show only the 11 rows of an earlier filter when 100 were expected, or leave empty rows at the end. The earlier answer is now discarded and the latest state is fetched; rows added locally keep their edits and heights.
clearFilters()did not clear edits, delete marks or row heights on server rows, so after the filter was removed they landed on different rows. PassinggetDeletedRows()on to a backend could delete the wrong record. It now behaves like removing the filters one at a time: if a server row has an unsaved edit a confirmation is shown (cancelling leaves the filters, sort and edits as they were), and once confirmed the edits, delete marks and undo history are reset and loading starts again from the first page. Edits on rows added locally and on pinned rows are kept.onFilter/onSortare called as for any other filter change.exportCsv({ full: true })put edited values on the wrong rows when rows had been added locally or permanently deleted. Permanently deleted rows are no longer exported. When every server row is loaded,full: trueand the default export now give the same result.- Inserting a row that
acknowledgeInsert()confirmed into the middle of the list made the last row disappear until the data was reloaded. The server row count could also end up off by one after that call, so undo and redo could leave a wrong row count. - A
formatwith a time part on atype: 'date'column (for exampleYYYY-MM-DD HH:mm) marked valid input as invalid, including values the built-in editor produced. The whole format is now checked, along with the number of days in the month (leap years included) and the hour, minute and second ranges (February 31 used to pass). Formats without separators (YYYYMMDD) are checked by the width of each part. - A network error (the
TypeErrorthatfetchthrows) made a data request fail at once without being retried. It is now attempted up to 3 times like any other error. A response that is not shaped like{ rows: [] }is still reported without retrying. - An image request that never got an answer kept its loading slot for good, so several of them stopped every image in the grid from loading. After 20 seconds without an answer the cell now shows the error tile.
- A data request that failed late, after
destroy(), still calledonChunkError. - Editing a
type: 'date'column whoseformatis'locale'kept only six characters of what was typed. It now edits with the browser’s native date picker and stores ISO text. validation.min/maxfailed every value on atype: 'date'column whoseformatis not ISO-style (YY/MM/DD, say). The value is now read through the column’sformat; limits can be written in that format or asYYYY-MM-DD.- Opening a dropdown or multiselect cell with
Spaceand pressingEntersaved a space.Spacenow types a space only into a plain text cell; any other editor just opens. - A date column added with
addColumn()lost itsformatfor the editor, validation and copy. index.d.ts: added'richtext'toColumnDef.type,GridI18n.richtextToolbarLabeland the chip/tree keys ofGRID_CLASSES, and corrected theautoFitColumnsdescription.- With a
datafunction, rows requested whilereloadData()/refresh()was still downloading (by scrolling,getRowData()and so on) were answered from the old array, and the old values stayed on screen after the new data arrived. It showed most with many rows, where the download takes time (reproduced at 2,000,000 rows). Requests made during a reload now wait for the new data, and the download happens once.