DocumentationUpgrades
What's New in Version 13
Overview
Version 13 introduces a new sparse storage engine and a modular architecture. Memory and processing now scale with the data a worksheet actually contains, not with the size of the grid — a worksheet with 500,000 mostly empty rows costs a fraction of what it did in version 12.
This release also rebuilds the undo and redo system for real-time collaboration: history records track rows, columns and worksheets by identity, so undo applies to the correct cells even after structural changes made locally or by other connected users. The legacy property methods accumulated over several versions were unified into one organized API.
Highlights
- Sparse Storage: Cell records are created on demand and organized in pages; blank areas of the grid cost nothing
- Modular Architecture: The monolithic core was split into ES modules
- Collaboration-Aware Undo: History follows rows, columns and worksheets by identity — undo lands on the right cells after inserts, deletes, moves or changes made by other users
- Lazy Borders: Border elements are only materialized inside the viewport
- Organized Properties API: One consistent, batch-first API for cell, column and row properties
New features
- Properties API:
setCellProperties, setColumnProperties and setRowProperties with their getters, batch signatures and the events onchangecellproperties, onchangecolumnproperties and onchangerowproperties
- Row and column identity: a
guid on every row and column, managed automatically and synchronized between connected users
- Column and row styles:
setStyle({ 'A:C': ... }) stores one entry on the column itself; covered cells inherit it
- Structural awareness: tables, media anchors and range filters follow inserts, deletes and moves of rows and columns, with exact undo
- Multi column sorting:
orderBy([{ column, direction }, ...]) and Shift + double click to add sorting levels
- Fill shortcuts:
Ctrl+D and Ctrl+R with formulas and formatting in one undoable operation
- Nested header editing:
splitNestedCell, mergeNestedCell, moveNestedHeader, the boundary drag, the group drag and the context menu entries, with allowEditNestedHeaders to disable the interactive editing
- Select then grab: the header of a fully selected column, row or nested group is the drag handle, with a distinct colour, as in Google Sheets
- Nested header selection: Shift-click extends across groups, Ctrl-click adds a group, and a right-click keeps a multi-group selection
- Clipboard API:
pasteArea, getClipboard, isClipboardText, the paste options (values, numberFormat, transpose, formatOnly, ...) and formulas that follow a transposed paste
- Conditional rules and visual scales:
duplicate, unique, aboveAverage, belowAverage, top and bottom validations, the dataBar and colorScale actions and stopIfTrue on style rules
- Spilled range operator:
B2# references a spilled array and follows copy, fill and structural changes
- Excel intersection operator:
=B2:B4 A3:C3
- Row group outline levels: the Excel outline model with up to eight levels and the
groupIndentation option
- Raw data: the
raw argument on getData, getDataFromRange, getRowData, getColumnData and the range form of getValue
- Plugins: the
events map for per-event subscriptions, including the gated events
- Accessibility: the ARIA grid model (
gridcell, aria-rowindex, aria-colindex, aria-rowcount, aria-colcount, aria-sort, aria-selected)
- Helpers:
helpers.transformTokenWithF4 and helpers.verifyWorksheetName; getCellRowObject(y) for direct access to the sparse row records
- Events:
onchangezoom and onbeforemoveworksheet
Behavior and Critical Updates
- Persistence payloads carry the original method input. Transformations (casting, formulas, events) are repeated by every peer that replays the operation; transformed values never travel
- Undo after delete and restore: undoing a change on a row or column that was deleted and later restored (by undo, local or remote) now applies correctly; when the target no longer exists, the change is skipped instead of writing to the wrong cell
- Row and column identity: row and column definitions may carry a
guid property, managed automatically and preserved across insert, delete, undo and collaboration messages
- Paste replaces destination merges. The pasted content replaces everything in the target area, merges included; only the merges the content carries are re-created. Version 12 kept the destination merge and dropped the values landing on its covered cells (see Clipboard)
Upgrade Requirements
Version 13 keeps the best compatibility possible with version 12, with one significant exception: the legacy property methods were replaced by a new API. Review the changes below before upgrading.
Dependencies
The dependencies of version 12 remain valid: version 13 runs with Formula Pro 6 and jSuites 6, so no dependency upgrade is required.
<script src="https://cdn.jsdelivr.net/npm/@jspreadsheet/formula-pro@6/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsuites@6/dist/index.min.js"></script>
Potential Breaking Changes
Properties API
The legacy methods mixed cell and column targets in a single overloaded signature. Version 13 replaces them with three explicit method pairs. All setters take an array, so batch operations are native.
| Status |
Method |
Description |
| New |
setCellProperties(data) |
data = [{ x, y, value }] or a map { A1: { ... } }. Replaces the cell own properties; null or {} resets to the column definitions |
| Updated |
setCellProperties bounds |
Coordinates beyond the grid or invalid entries are skipped: batches apply the valid entries and skip the rest, all-invalid batches return false (previously a beyond-grid entry crashed the whole batch) |
| Updated |
rotate(cell, angle) |
Validated (false on non numeric angles, invalid names or beyond-grid cells) and rebuilt on the properties pipeline: the render owns the rotation, with history, persistence and headless support. The previous implementation wrote an invalid CSS transform directly to the DOM |
| New |
getCellProperties(x, y) |
Effective properties of a cell: own properties, validations or the column definitions as fallback |
| New |
setColumnProperties(data) |
data = [{ column, value }]. Replaces the column definitions; position, elements and identity are preserved |
| New |
getColumnProperties(x) |
The column definitions |
| New |
setRowProperties(data) |
data = [{ row, value }]. Replaces the row definitions; position, element and identity are preserved |
| New |
getRowProperties(y) |
The row definitions |
| Removed |
setProperty |
Replaced by setCellProperties and setColumnProperties |
| Removed |
resetProperty |
Replaced by setCellProperties with value: null |
| Removed |
getProperty |
Replaced by getCellProperties and getColumnProperties |
| Removed |
getOptions |
Replaced by getCellProperties |
| Removed |
setColumnOptions |
Replaced by setColumnProperties |
| Removed |
getColumnOptions |
Replaced by getColumnProperties |
Migrating the common calls:
// Version 12: cell target (x, y, options)
worksheet.setProperty(1, 4, { type: 'text' });
// Version 13
worksheet.setCellProperties([{ x: 1, y: 4, value: { type: 'text' } }]);
// Version 12: column target (x, options)
worksheet.setProperty(1, { type: 'checkbox' });
// Version 13
worksheet.setColumnProperties([{ column: 1, value: { type: 'checkbox' } }]);
// Version 13: rows now have the same capability
worksheet.setRowProperties([{ row: 3, value: { height: 40 } }]);
The new setters use replace semantics by default: the object passed becomes the new definitions. To change a subset of properties, add update: true to the record — only the given keys are applied over the current ones (and only the delta travels to other connected users):
worksheet.setCellProperties([{
x: 0,
y: 0,
value: { mask: '0.00' },
update: true,
}]);
Events
Every event addition and signature change in version 13; the full details
live in each feature section.
| Status |
Event |
Description |
| New |
onchangecellproperties |
(worksheet, records) after setCellProperties |
| New |
onchangecolumnproperties |
(worksheet, records) after setColumnProperties |
| New |
onchangerowproperties |
(worksheet, records) after setRowProperties |
| New |
onchangezoom |
(worksheet, newValue, oldValue) after the zoom changes |
| New |
onbeforemoveworksheet |
(worksheet, from, to) before a worksheet moves; false cancels |
| Updated |
onresize |
Now (worksheet, width, height) with the effective dimensions |
| Updated |
onchangeconfig |
Now (worksheet, config, spreadsheetLevel) |
| Updated |
onbeforeformula |
A returned string now replaces the expression before evaluation |
| Updated |
Gated events for plugins |
onbeforesearch, onsearchrow, onbeforeformula, ... reach plugins subscribed through the events map |
| Removed |
onchangeproperty |
Replaced by the three specific property events above |
Each properties records entry carries value (the new properties) and oldValue (the previous ones).
New Methods
A quick reference of every method added in version 13; the details live in
each feature section.
| Status |
Method |
Description |
| New |
setCellProperties(data) |
Batch cell properties (see Properties API) |
| New |
getCellProperties(x, y) |
Effective properties of a cell (see Properties API) |
| New |
setColumnProperties(data) |
Batch column definitions (see Properties API) |
| New |
getColumnProperties(x) |
The column definitions (see Properties API) |
| New |
setRowProperties(data) |
Batch row definitions (see Properties API) |
| New |
getRowProperties(y) |
The row definitions (see Properties API) |
| New |
getCellRowObject(y) |
Direct access to the sparse row records (see Rows and Columns) |
| New |
splitNestedCell(x, y) |
Splits a nested header group at grid column x (see Headers and Nested Headers) |
| New |
mergeNestedCell(x, y, to?) |
Merges a nested header group with its right neighbour, or with every group up to the one owning column to — the inverse of a split (see Headers and Nested Headers) |
| New |
moveNestedHeader(x, y, d) |
Moves a nested header group and its columns to another position of its row (see Headers and Nested Headers) |
| New |
orderBy(levels) |
Multi column sort overload: [{ column, direction }, ...] (see Sorting) |
| New |
getData(..., raw) |
Sixth argument returns computed values without formatting; the same raw argument exists on the getValue range form, getRowData and getColumnData (see Data) |
| New |
helpers.transformTokenWithF4(token) |
Cycles a reference through the absolute forms (see Formula Editor) |
| New |
helpers.verifyWorksheetName(name) |
Quotes a worksheet name when a reference requires it, so a caller can build a qualified range the parser accepts |
| New |
pasteArea(area, x, y, cut, source) |
Pastes an area straight into another position, no copy involved (see Clipboard) |
| New |
getClipboard() |
Origin of the last copy or cut made inside the spreadsheet (see Clipboard) |
| New |
isClipboardText(text) |
Whether the clipboard still holds that copy (see Clipboard) |
New Options
| Status |
Option |
Description |
| New |
allowEditNestedHeaders |
Worksheet option; false disables the interactive nested header editing — boundary drag and the context menu entries. The programmatic API stays available (see Headers and Nested Headers) |
| New |
guid |
Row and column definitions carry an identity guid, managed automatically and preserved through structural changes and collaboration (see Rows and Columns) |
| New |
groupIndentation |
Worksheet option; enables the default row group outline visuals — true for a 16px indentation step, or the step in pixels (see Defined Names, Groups and Media) |
Rows and Columns
| Status |
Item |
Description |
| Updated |
Structural performance |
Insert, delete and move of rows and columns repair references only from the first affected position and never walk the empty area of a sparse grid: on a million-row worksheet a column delete dropped from ~270ms to ~50ms against version 12, and operations near the end of the grid cost milliseconds |
| Updated |
moveRow / moveColumn validation |
Out of range positions return false instead of corrupting the grid containers |
| Updated |
Delete validation |
A delete batch holding any invalid position is refused as a whole — it can no longer leave group updates half-applied |
| Updated |
Insert broadcasts |
Plain inserts (no data) no longer ship filler rows of empty strings to the peers, and peers no longer run the value pipeline over them |
| Updated |
Restored rows and columns |
Undoing a delete restores the row or column without stale runtime state (selection flags, dependency chains), with the exact rectangular data shape, and the complete document — styles, meta, comments, merges, validations, defined names, footers, groups and dimensions recover together (pinned by a full-document round-trip test battery) |
| Updated |
setMerge document hygiene |
Merging cells without borders no longer writes empty style entries: the border transform only touches cells that actually carry the border being moved. Documents stay clean through merge, delete and undo cycles |
| New |
Tables follow the structure |
Data table ranges shift with row and column inserts and deletes, grow on inserts inside, shrink on deletes inside, and the table is removed when its whole area is deleted — all with exact undo. Version 12 crashes on structural changes near a table. Moves keep table areas in place (content permutes; a permutation does not preserve a range) |
| New |
Media follows the structure |
Image and chart anchors (cellAnchor) shift with inserts, deletes and moves of rows and columns. Deleting the anchor's own row keeps the media on the next row, and undo restores the exact anchor |
| New |
Filters follow the structure |
A range filter (filters: 'A1:D4') shifts, grows and shrinks with row and column changes, an edge delete re-anchors it (matching the merge semantics), and deleting the whole area turns the filters off — with exact undo. Active filter values live on their columns and survive every transform |
| Updated |
Filter controls cleanup |
Disabling a range filter (including by undo) removes the filter controls completely — previously the empty action containers stayed on the header cells and were re-appended on every render. Deleting the first row of a range filter no longer removes the filter controls |
| Updated |
Table names |
Table names are unique across the workbook, matching how formulas resolve them: setTable with a name owned by a table on another worksheet is refused (previously it created a duplicate, and undo recreated the other worksheet's table on the wrong sheet) |
| Updated |
Groups and moves |
Moving rows or columns into a group's covered range grows the group, moving members out shrinks it (version 12 semantics), reorders inside are neutral, and moving the group owner keeps the group at its position — all with exact undo |
| Status |
Item |
Description |
| New |
guid |
Row and column definitions carry an identity guid, managed automatically. It travels with insert and delete operations so all connected users agree on it — do not overwrite it |
| Updated |
deleteRow / deleteColumn |
With the array form, the second argument carries the identity guids of a replayed collaboration message, aligned with the indexes: deleteRow([2, 3], [guid1, guid2]). The scalar form keeps its legacy meaning: deleteRow(row, numOfRows) |
| New |
getCellRowObject(y) |
Direct access to the sparse row records |
Clipboard
- Notes and threaded comments belong to the cell and travel with it: a copy carries them to the destination, and a cut moves them, leaving none at the origin. Version 12 pasted the values and left the notes where they were
- The pasted content replaces everything in the destination area, merges included: a merge touching the target range is removed and only the merges the pasted content carries are re-created. In version 12 the destination merge survived and the values landing on its covered cells were silently dropped
- A merge travels only when the copied area holds it whole: a merge partially inside the area cannot move as one, so it is dropped instead of re-created shifted — and a cut still removes it from the origin
- Cut and paste follows the Excel move contract for formula references: a reference follows the cell it points at if, and only if, that cell moved along. This applies inside the moved formulas (
=B28+C28 moved with its C28 becomes =B28+D28) and to every formula in the workbook referencing the moved cells, on any worksheet, with $ markers preserved; a range reference follows when it moved as a whole. A move across worksheets requalifies both sides: the moved formula keeps pointing at the source cells (=A1*2 becomes ='Sheet1'!A1*2) and the formulas left behind follow the moved cells (=B1+5 becomes ='Sheet2'!D5+5). In version 12 every reference stayed as written, pointing at the old positions
- A paste is a single history entry: the value writes, the source clearing of a cut and the merge moves apply and revert as one transaction. Cutting a merged area and pasting it over its own source keeps the merged content, and one undo restores values, styles, cell configuration — the number format of the destination included — and merges at both ends. A paste made while
history.cascade is on joins the entry that is open, so a caller can group it with the operations around it
pasteArea(area, x, y, cut, source) pastes an area of the worksheet (or of another one, through source) straight into a position with the fidelity of an internal copy — values, styles, formats and merges travel, and cut clears the origin. No copy has to happen first and the clipboard stays untouched: whatever the user copied is still there to be pasted. The formulas follow the operation being performed: a copy shifts their references to the position they land on (=A1*2 copied one row down becomes =A2*2), while a cut applies the move contract above
getClipboard() returns the origin of the last copy or cut made inside the spreadsheet — { worksheet, selection, highlighted, cut, value } — or null when there is none, a destroyed spreadsheet included. The system clipboard carries text alone, so this is what a caller needs to paste on its own terms: only the number formats, transposed, or as a link to the source cells
isClipboardText(text) tells whether the text is still the one that copy produced, or whether another application has replaced it on the clipboard since. It is the question a paste answers for itself before rebuilding the cells, now available to a caller that reads the clipboard on its own — a menu that offers to paste a part of the copy, for instance, has to know before showing the options
- Both
paste and pasteArea take options for what of each cell travels, so a caller pastes a part of it instead of pasting everything and undoing the rest: values (the result of the formulas instead of the formulas), styles: false, properties: false (no cell configuration), numberFormat (of the configuration, only the mask), borders: false (the styles except the borders), transpose (rows become columns) and formatOnly (the looks alone — styles, configuration and merges — leaving every value and note of the destination as it is, and the origin of a cut untouched). The valuesOnly, dataOnly and styleOnly of version 12 keep working and say the same in fewer words
- Formulas follow the transposition: each relative offset swaps its axes, so a reference to the neighbour on the left becomes a reference to the neighbour above, a whole column becomes a whole row (
=SUM(I:I) becomes =SUM(10:10)) and absolute references stay where they are
Undo and Redo
History records reference the worksheet by id and rows/columns by identity instead of numeric coordinates alone:
- Undo and redo apply at the current position of the affected cells, even after rows or columns were inserted, deleted or moved — locally or by another connected user
- Undoing a change on a row or column that was deleted and later restored works across all connected users
- When the target genuinely no longer exists (deleted and not restored), the change is skipped — nothing is written to the wrong cell
- Every worksheet-scoped action (styles, widths, visibility, merges, comments, groups, sorting, and the rest) resolves its worksheet by id at undo time: undoing changes made before a worksheet was deleted and restored applies to the restored worksheet, never to the dead instance
- The identity contract covers values, formulas and formula reference rewrites, styles and style resets, comments, merges, row/column visibility, widths and heights, headers, groups (definitions and open/close state) and the filter range — every positional action. Undoing a column deletion that rewrote formula references restores the exact original formulas, with no stale
#REF! copies on the wrong cells
Persistence Protocol
If you implemented a custom persistence handler or server integration, note:
- Payloads contain the original, untransformed method input — every peer replays the method and repeats the transformations
- Internal working fields (
w, col, row, oldValue) never travel
deleteRow / deleteColumn broadcast the plain indexes with the identity guids aligned as the second argument — deleteRow(rows, guids) — so the replay signature stays the method signature
Zoom
| Status |
Item |
Description |
| Updated |
setZoom(z) |
Only positive finite numbers are accepted; anything else resets to 1. Setting the current value again is a no-op |
| New |
onchangezoom |
(worksheet, newValue, oldValue) after the zoom changes |
Zoom is a personal view preference: it is never persisted, synced to other users or recorded in the undo history. Use onchangezoom to save it locally (e.g. localStorage) if your application needs to restore it.
Worksheets
| Status |
Item |
Description |
| New |
onbeforemoveworksheet |
(worksheet, from, to) before a worksheet moves; return false to cancel |
| Updated |
moveWorksheet(f, t) |
Invalid positions return false instead of corrupting the workbook. Moving a non-active worksheet no longer changes the active one |
| Updated |
renameWorksheet(index, name) |
Invalid names return false instead of throwing; numeric names are accepted; case-only renames (Data → DATA) are allowed |
| Updated |
deleteWorksheet(index) |
The last-worksheet guard runs before onbeforedeleteworksheet, so the event no longer fires for a refused delete. Refused operations consistently return false |
Viewport
| Status |
Item |
Description |
| Updated |
setViewport(w, h) |
Percentage strings ('100%') are now accepted and preserved — previously they were parsed to pixels or silently dropped. Pixel values below 151 are still ignored |
| Updated |
onresize |
Now (worksheet, width, height) with the effective dimensions — matching the signature the typings always documented. Previously the raw parsed input was sent without the worksheet |
Serialization
| Status |
Item |
Description |
| Updated |
getConfig() |
Per-user runtime view state (selected, hidden-neighbor arrow flags) no longer leaks into the serialized rows and columns. Documents saved mid-session no longer restore one user's selection for everyone. Document state such as visible is kept |
| Updated |
Style serialization |
A style applied to a full column or row serializes as a single entry on that column or row (columns[i].s, row s), never duplicated into the covered cells. The saved document is identical whether it was produced by a browser or by a headless server, and reloading restores the inheritance. The explicit "unstyled over a block style" reset marker is runtime state and never serialized |
Validations
| Status |
Item |
Description |
| Updated |
Sparse evaluation |
Defining a validation no longer materializes a cell record for every covered cell — a column-wide validation on a large sheet keeps the sparse storage benefits. Cells are validated by coordinates (region lookup) and at render time |
| Updated |
getValidations(index) |
Safe when no validations are configured (returns undefined instead of throwing) |
| Updated |
setValidations |
The string form ({ index, value: 'Sheet1!A1:B2' }) targeting a validation that does not exist is ignored instead of throwing |
| Updated | hasErrors() | The full scan visits only coordinates covered by validation ranges instead of the whole grid |
| Updated | List sources | Resolved once and cached; the cache invalidates when a source cell changes — which also fixes stale warnings: editing a source cell now re-validates the covered range. The resolved source never serializes into the document |
| New | Conditional rules | The types duplicate, unique, aboveAverage, belowAverage, top and bottom evaluate each cell against its whole range. For top and bottom, value: [N] sets the rank size (default 10) and criteria: 'percent' reads it as a percentage of the numeric values in the range. A changed value re-applies the covered range through the dependency chain; the range statistics are computed on demand and never materialize cells of the sparse store |
| New | Visual scales | The actions dataBar and colorScale paint every cell by the position of its value between the range minimum and maximum: a proportional background bar (format.color) or an interpolation over two or three color stops (format.colors, Excel's red-yellow-green by default). Presentation only — no pass or fail condition |
| New | Stop if true | Format rules follow the Excel conditional formatting model: every matching rule applies, stacking in config order (version 12 stops at the first match), a property conflict resolves to the first rule, and a matched rule with stopIfTrue: true cuts the rules after it. The xlsx parser imports the flag and inserts the sheet's rules by Excel priority, and the export writes it back |
| New | Rule reordering | The validations extension modal edits the stop if true flag and reorders the rules — the config order is the evaluation order |
Defined Names, Groups and Media
| Status |
Item |
Description |
| Updated |
getDefinedNames(name) |
Safe for unknown names or when none are configured (returns undefined instead of throwing) |
| Updated |
setDefinedNames |
Requires a valid string name per record — invalid entries are skipped; respects the editable flag; returns false when nothing applies |
| Updated |
resetDefinedNames |
Accepts plain names (['TOTAL']) as well as descriptors ([{ index: 'TOTAL' }]) |
| Updated |
Defined names reload |
A configuration saved with getConfig restores working defined names — the serialized object form is accepted at load time (previously the names reloaded broken) |
| Updated |
setRowGroup / setColumnGroup |
Invalid positions or a negative size return false instead of throwing |
| Updated |
getMedia() |
With no argument returns all media items (previously null) |
| Updated |
Serialization |
The derived group collapsed flag and the media element reference never serialize; both are rebuilt at load time |
| Updated |
onbeforeloadimage |
Fires only when the image source is new for the element: on creation, on a src change and on its undo or redo. Moving, resizing or rotating the media no longer calls the event nor reassigns img.src (version 12 reloaded the image on every setMedia) |
| New |
Row group outline levels |
Nested row groups render the Excel outline model: jss_group_header marks the owner rows and jss_group_level_1 to jss_group_level_8 carry the nesting depth (the Excel limit). The classes are always emitted; the default visuals — owner highlight and the first data cell indented per level — are opt-in through the groupIndentation worksheet option (true for 16px, or the step in pixels, exposed as the CSS variable --jss-group-indentation) |
| Updated |
Nested group visibility |
A position stays hidden while any enclosing group is closed: opening a parent never reveals the content of a closed nested group, and nested owners keep their own open state (Excel semantics) |
Search, Pagination and Shortcuts
| Status |
Item |
Description |
| Updated |
page(n) |
Pages beyond the last are clamped to the last page; invalid input returns false; null/-1 keep meaning the last page |
| Updated |
whichPage(y) |
Returns null when the row is not part of the current search or filter results (previously a negative page number) |
| Updated |
search(term) |
Numeric terms are accepted (search(0) searches for 0); considerably faster on large worksheets — the displayed label is only resolved for cells that can present one |
| Updated |
jspreadsheet.shortcuts.set |
The string form resolves multi-character keys ('ctrlKey.Enter' registered the key r); custom shortcuts now take precedence over the built-in entries |
| Updated |
oncloserowgroup / onclosecolumngroup |
Closing a group dispatched the OPEN event due to an always-true condition; the correct event fires now |
Group open and close, search terms and the current page are personal view preferences: never persisted or synced to other users. Group open/close history entries are kept for compatibility with previous versions.
Plugins
The plugin system was reworked for performance: every handler is indexed once at registration, so the per-operation overhead no longer grows with the number of plugins.
Breaking: the array registration form was removed. setPlugins accepts only an object of plugin methods. If you registered plugins with the array form, migrate as follows:
// Version 12 (also worked): array of descriptors
spreadsheet.setPlugins([
{ name: 'myPlugin', plugin: myPlugin, options: {} },
]);
// Version 13: object form only
spreadsheet.setPlugins({ myPlugin });
The plugin itself is unchanged — a method that receives (spreadsheet, options, config) and returns the plugin instance. All existing hooks keep working: init, persistence, toolbar, contextMenu, onevent.
New: subscribe to specific events. Instead of receiving every event through the generic onevent funnel — including the per-cell rendering events — a plugin can declare an events map. Each handler is called only for its own event, with the same arguments as the equivalent config handler:
const myPlugin = function(spreadsheet, options, config) {
return {
events: {
// No event name argument and no switch needed
onchange: function(worksheet, cell, x, y, newValue) {
// ...
},
onselection: function(worksheet, x1, y1, x2, y2) {
// ...
},
},
// The generic funnel still works and can coexist with events
onevent: function(event) {
// ...
},
};
}
| Status |
Item |
Description |
| Removed |
Array registration |
setPlugins([{ name, plugin, options }]) is no longer accepted — use the object form { name: method } |
| New |
events map |
Handlers fire only for their own event; return values behave like config event returns |
| Updated |
Gated events |
Conditional events (onbeforesearch, onsearchrow, onbeforeformula, ...) are reachable by plugins subscribed through the events map — previously they only fired when a config handler existed. The generic onevent funnel keeps its historic behavior and does not receive them |
| Updated |
onevent |
Unchanged and fully supported — indexed once at registration instead of type-checked on every dispatch |
| Updated |
Hooks |
init, persistence, toolbar and contextMenu are also indexed at registration — a registered plugin adds no measurable cost per operation |
Data
| Status |
Item |
Description |
| New |
raw argument |
getData, getDataFromRange, getRowData, getColumnData and the range form of getValue accept a raw flag alongside processed, mirroring getValue(cell, true, true): the computed result before any formatting — the number behind a mask, the result of a formula. Resolved directly from the cell records, so it is dramatically faster than the formatted mode |
| Updated |
CSV escaping |
The delimiter is treated as a literal string — delimiters such as | or . no longer quote every value (they were interpreted as regular expressions) |
| Updated |
Processed values |
getValue(cell, true) and the processed data methods resolve through the editor's get — every type returns its presentation (a checkbox 'true', the dropdown text, the masked string) and plain values keep their native type. Previously the result was read from the rendered DOM, so numbers came back as strings when the cell happened to be rendered and as numbers when it did not |
| Removed |
extended argument |
getProcessed / getLabel no longer take the extended flag — color and progressbar resolve through their editors like every other type. Custom editors implement get(options, value, instance) (the extended parameter was removed from the signature) |
// A1 = 1234.5 with mask #,##0.00 ; B1 = =A1*2 with the same mask
worksheet.getData(); // [[1234.5, '=A1*2']] stored input
worksheet.getData(false, true); // [['1,234.50', '2,469.00']] formatted
worksheet.getData(false, true, null, null, null, true); // [[1234.5, 2469]] computed, unformatted
worksheet.getValue('A1:B1', true, true); // same view over a range
Formulas and History
| Status |
Item |
Description |
| Updated |
History |
Recording no longer copies the undo stack on every operation — long editing sessions keep constant time per edit |
Configuration and Dimensions
| Status |
Item |
Description |
| Updated |
setConfig with minDimensions |
The grid growth is structural and applies on every environment — a server replaying the configuration keeps the same grid as the clients (it was previously frontend-only) |
| Updated |
setConfig partial updates |
A partial configuration no longer resets the resizable state when the property is absent |
| Updated |
setWidth / setHeight |
Respect the editable flag and validate the positions — missing columns or rows return false instead of throwing; mixed arrays apply to the existing entries only |
| Updated |
getWidth(column) |
Returns undefined for a missing column instead of throwing |
| Updated |
Dimension validation |
Widths and heights must be positive numbers — invalid values return false, invalid entries in an array keep the current value |
| Updated |
setConfig input |
An invalid JSON string or a non-object returns false instead of throwing |
| Updated |
onchangeconfig |
Now (worksheet, config, spreadsheetLevel) — the worksheet comes first, consistent with every other event. Previously the config string was the first argument |
Events and Extensions
| Status |
Item |
Description |
| Updated |
Window focus |
Returning to the browser window restores the keyboard navigation when a cell selection is active (the check referenced a variable that never existed, so it never ran) |
| Updated |
Touch double tap |
Resolves the cell element instead of the raw tap target — double-tapping formatted cells (rendered content, rotated text) opens the editor correctly |
| Updated |
setExtensions |
An extension whose name conflicts with the core API (helpers, history, ...) is ignored with an error instead of silently overwriting it |
| Updated |
International decimal |
The international locale decimal is resolved on every environment — a server replaying inputs casts numbers exactly like the clients (it was previously frontend-only, leaving the server without a decimal definition) |
Formula Engine Integration
| Status |
Item |
Description |
| Updated |
onbeforeformula |
Returning a string now replaces the expression before evaluation, as documented (the returned value was silently discarded before, in version 12 as well). The replacement must be a full formula string starting with =. Returning false still keeps the raw expression as the cell result |
| Updated |
setFormula persistence |
The persistence payload is the original input normalized to coordinates — {x, y, value, force} per record. It previously shipped internal records carrying live worksheet references, which cannot be serialized to a socket (circular structures). Applies to the direct API and to internally batched formula updates (defined names changes, reference transforms) |
| Updated |
F4 reference cycling |
Sheet-qualified tokens (Sheet1!A1) now cycle their reference part only — Sheet1!$A$1, Sheet1!A$1, ... (version 12 corrupts the token). Available as jspreadsheet.helpers.transformTokenWithF4 |
| Updated |
Chain resolution errors |
A formula referencing a missing worksheet no longer logs to the console on every evaluation — the cell resolves to #REF! and the user notification still shows. Set debugFormulas: true to restore the console output (a headless server would otherwise flood its logs) |
| Updated |
Value reads on plain worksheets |
Reading a cell without a record no longer queries the spill index when the worksheet has no array spills — large range recalculations run at version 12 speed (an edit inside a 50,000-row SUM range: ~9ms to ~4ms) |
| Updated |
Spill arrays and redo |
Re-doing the removal of a spill anchor now clears the spilled members again. The internal recalculation entries produced by a spill reset were replayed by redo as if they were user changes, restoring the array formula over the cleared cell (a "ghost spill") |
| New |
Intersection operator |
The Excel intersection operator survives formula securing: a space between two reference-like tokens is kept as the operator (=B2:B4 A3:C3), while any other whitespace is still dropped and runs collapse to a single space. References, ranges, full rows and columns, defined names, calls that return references (INDIRECT(...)), structured references, spilled ranges (A1#) and error literals all qualify as operands. Evaluation requires a formula engine with intersection support |
| New |
Spilled range operator |
B2# references the whole array spilled from the anchor cell. The reference participates in the dependency chain — formulas holding it recalculate when the spilled values change — and translates on copy-paste, fill and structural changes: inserts, deletes and moves of rows and columns shift the anchor and keep the operator, and deleting the anchor leaves #REF!# (the operator applied to the error, exactly as Excel reads it). F4 cycles the anchor reference and the formula editor highlights the anchor cell. Evaluation requires a formula engine with spilled-range support |
Headers and Nested Headers
| Status |
Item |
Description |
| Updated |
Nested header definitions |
The configuration is normalized on load on every environment: string entries become {title} objects, colspan defaults to 1 and align to center. Version 12 only applied defaults while rendering, so a server and its clients serialized different configurations, and spans without an explicit colspan corrupted the structural math (insert or delete column updated the wrong group) |
| Updated |
Nested header titles |
Rendered with textContent — markup in a title is displayed as text. Version 12 parsed HTML on creation but not on update, so HTML titles were already lost on the first setNestedCell; the creation path was also an injection surface on collaborative documents |
| Updated |
setNestedHeaders / resetNestedHeaders |
Respect the editable flag and return false when the worksheet is not editable. Replacing the definitions now emits a single remote operation (peers replaying setNestedHeaders clear the previous state themselves — the internal reset no longer echoes a second operation) |
| Updated |
setNestedCell |
Coordinates outside the definitions return false instead of throwing; an array input applies the valid entries and skips the missing ones (history and persistence carry only what was applied) |
| Updated |
getNestedCell |
Returns null for a missing cell or on the server instead of throwing |
| Updated |
Nested render maps |
The column-to-group maps are cached per nested row and invalidated on any colspan change — scroll rendering no longer rebuilds them per visible column |
| New |
splitNestedCell(x, y) |
Splits a nested header group at grid column x (nested row y): the group keeps the columns before x, a new untitled group takes the rest. Available from the nested header context menu ("Split column", at the clicked position). Full undo/redo; peers replay the same deterministic split |
| New |
Nested boundary drag |
Dragging the boundary between two nested groups moves columns from one to the other (col-resize cursor on the group's right edge). The colspans follow the pointer live while dragging; the drop commits as one undoable operation through setNestedCell and syncs to peers. The neighbour compensates, so the row always covers every column and both groups keep at least one column |
| Updated |
Appended columns |
A column inserted after the covered area (for example Tab on the last column) extends the last group of every nested row, so the headers grow with the grid instead of leaving an uncovered gap. Undo restores the previous spans |
| Updated |
Nested groups and frozen panes |
Nested header groups are never split by a frozen pane: when the freeze control is dropped inside a group, the boundary moves to the closest edge of that group |
| New |
mergeNestedCell(x, y, to?) |
Merges the group owning grid column x with its right neighbour, or with every group up to the one owning grid column to: the combined group keeps this group's title and covers all the spans — the exact inverse of splitNestedCell. Available from the context menu ("Merge columns"): when the selection spans several groups they all merge into the first one, otherwise the group merges with its neighbour. Full undo/redo; peers replay the same merge |
| New |
moveNestedHeader(x, y, d) |
Moves the group at index x of nested row y to index d of the same row, carrying every column it covers as one block. Only that row changes: the other nested rows keep their spans and the columns slide beneath them, exactly as a plain column move. The row's own colspans are untouched, so a position inside another group cannot be expressed. Refused when the column move is refused (a merged cell crossing, onbeforemovecolumn returning false). One undoable operation — the inner column move records neither history nor persistence — and the undo reaches peers as the inverse move |
| New |
Nested group drag |
Click a group to select its columns, then press the selected group and move: the group drags with its columns (grab cursor on hover, grabbing while dragging). A ghost follows the pointer and the drop indicator snaps to the group edges of the dragged row — the left half of a group drops before it, the right half after it. The drop commits through moveNestedHeader. Disabled by columnDrag: false or allowEditNestedHeaders: false |
| New |
allowEditNestedHeaders |
Set to false to disable the interactive nested header editing: the boundary drag, the group drag and the context menu entries (Rename, Split column, Merge columns). The programmatic API stays available — remote peers replay it |
| Updated |
Column and row drag |
Select then grab, as in Google Sheets. Version 12 started the drag from an 8px strip at the bottom of the column header or the right edge of the row header — invisible and competing with the resize edge. Version 13 removes the strips: the header of a fully selected column or row (the selection spans every row, or every column) is the handle. It shows a grab cursor, a press on it arms the drag and the first movement past 4px starts it, with the whole selected block travelling together. A release without movement is a plain click, so pressing one header inside a wider selection narrows the selection to it. Shift and Ctrl presses keep extending or toggling the selection. A cell range selection under a header does not make the header a handle. columnDrag: false and rowDrag: false disable it as before |
| New |
Full selection handles |
Headers have two selection states, as in Google Sheets. A header touched by any selection keeps the light highlight (selected). The header of a fully selected column or row — the selection spans every row, or every column — and a nested group whose whole range is inside such a selection carry a second class, jss_full, with a slightly stronger colour from the new --jss-background-color-full variable (light and dark palettes, plus a jss_modern value). This is the same rule that shows the grab cursor, so the stronger colour marks exactly the headers that can be dragged. The flag is per-user view state: it never reaches getConfig, the properties API snapshots or the history |
| Updated |
Nested header selection |
Shift-click on a nested group extends the selection to the union of the current columns and the clicked group (leftwards too), and Shift-click on a column header after a group selection extends to that column. Ctrl-click adds a group as a second range. A right-click on a group whose columns are all selected keeps the selection, so a context menu action applies to every selected group; a right-click on a group outside, or only partly inside, the selection selects that group. Version 12 reset the selection on every right-click and ignored the modifiers on nested headers |
| Updated |
insertRow / insertColumn |
Without the create-selection argument, the current selection keeps its borders and header highlights after the insert. Version 12 cleared the selection styling and left it invisible until the next click, while the delete operations already restored it |
| Updated |
Persistence of sub-steps |
The persistence callback no longer receives the sub-steps a public operation runs internally (for example the nested colspan updates inside insertColumn, or the column move inside moveNestedHeader). Peers replay the public method, which repeats the sub-steps itself. The client extension already filtered these; a custom persistence handler now sees the same stream |
New Shortcuts
| Status |
Shortcut |
Description |
| New |
Ctrl+D |
Fill down (Excel behaviour): a multi row selection fills from its own first row; a flat selection copies from the cell above. Formulas shift their relative references, and the source formatting travels with the fill (an unformatted source clears the target, like Excel). One undoable operation covering values and styles |
| New |
Ctrl+R |
Fill right: the same contract over columns — a multi column selection fills from its own first column, a flat selection copies from the cell on the left. Formatting travels the same way |
| Updated |
Ctrl+Arrow |
Excel block navigation: lands on the last cell of the current value block, on the first value after a gap, or on the grid edge (previous versions stopped on the last blank before the next value) |
| Updated |
Ctrl+Shift+Arrow |
Extends the selection to the block target following the same contract |
| Updated |
F4 (formula editor) |
Cycles the reference under the caret through the absolute forms (A1 → $A$1 → A$1 → $A1); sheet-qualified references now cycle their reference part only |
Accessibility
| Status |
Item |
Description |
| Updated |
ARIA model |
The grid exposes the ARIA model: gridcell, row with aria-rowindex, columnheader with aria-colindex, rowheader, aria-rowcount/aria-colcount on the virtualized grid, aria-sort on sorted headers and aria-selected on the cursor cell |
Frozen Rows and Columns
| Status |
Item |
Description |
| Updated |
Freeze boundary over nested headers |
Dragging the freeze control over a nested header group snaps to the nearest group edge — the frozen area cannot split a nested group. This is the same behaviour the control already had over merged cells; the drop position and the number of pinned columns follow the snapped edge |
| Updated |
Freeze validation |
Invalid configurations are sanitized instead of breaking the render: array entries must be valid positions (deduplicated and sorted), positions beyond the grid are dropped on every environment, and non numeric input freezes nothing. The empty state is always null |
Navigation
| Status |
Item |
Description |
| Updated |
Ctrl+Arrow block navigation |
Follows the Excel contract: from a value cell inside a block the cursor lands on the LAST cell of the block; from a value cell followed by blanks — or from a blank cell — it lands on the FIRST cell holding a value after the gap; when only blanks remain it lands on the grid edge. Previous versions stopped on the last blank before the next value. Hidden rows and columns are transparent |
| Updated |
Ctrl+Arrow over merged cells |
A merged cell acts as ONE cell whose state is its anchor's value: an empty merge is a blank (jumps pass through it to the next value or the grid edge), a merge with a value stops the jump on its anchor and joins contiguous value blocks, and a jump started from inside a merge always escapes it. Previous versions treated every merge as a value block — an empty merge trapped the jump on its anchor, and Ctrl+Arrow from a merged cell could never leave it |
| Updated |
Entering a merge across the viewport |
Arrowing left or up into a merge whose span starts off-screen scrolls the viewport to the START of the merge (Google Sheets behaviour) instead of moving one column or row. Entering right or down keeps the start of the merge visible |
| Updated |
Jump performance |
A vertical jump across the empty tail of a large grid resolves through the occupied-area bound instead of visiting every row (1,000,000 blank rows: ~20ms to ~0.4ms) |
| Updated |
Navigation on the server |
The navigation methods (right, left, up, down, first, last) move the selection headless with the same block semantics — scroll positioning is applied on the clients only (they previously crashed without a DOM) |
Scroll and Selection
| Status |
Item |
Description |
| Updated |
Scroll performance |
Row offsets resolve through the cumulative height index instead of walking every row per scroll event. On a 1,000,000-row worksheet a scroll event dropped from 3–15ms to under 1ms (version 12 needs 15–26ms); a scroll event that does not change the window is now free. The vertical position cache also works again (a typo made every scroll event rewrite the table position) |
| Updated |
Selection on the server |
The selection container is environment independent: getHighlighted, getSelected, getSelectedRows and getSelectedColumns return the same results on the server as on the clients (they were empty on the server before) |
| Updated |
Multiple spreadsheets on a page |
getHighlighted and everything built on it report only the instance that holds the selection — an instance without a selected cell returns an empty set (it previously leaked the selection of whichever instance selected last) |
| Updated |
Locked cells |
A single-cell selection with selectLockedCells: false tests the real cell: explicitly unlocked cells are selectable again (the check previously probed an undefined corner and refused everything) |
| Updated |
Fill on the server |
The fill handle explicitly refuses on the server (it was refused by accident before; the contract is documented: peers replay the resulting value updates, never the gesture) |
Context Menu
| Status |
Item |
Description |
| Updated |
Nested header context menu |
Restored — the nested section (Rename this cell, and the new Split column) never appeared in version 13 because the menu checked the legacy section name while the events pass the nested-header role |
Formula Editor (Picker)
| Status |
Item |
Description |
| Updated |
Caret positioning |
The formula editor places the caret with document.createRange() instead of the Range constructor, which is not present on every environment, and it no longer throws on an empty element. This also makes the editor unit-testable |
Sorting
| Status |
Item |
Description |
| New |
Multi column sort |
orderBy([{ column, direction }, ...]) sorts by several levels: ties on one level resolve on the next, each level with its own direction. Empties sink and errors rise per level; full ties keep the original order. One undoable operation; the exact order travels to the peers. The single column form is unchanged |
| New |
Shift + double click |
Adds the column as the next sorting level, or toggles its direction when it already participates. A plain double click resets the sorting to that single column. Every level shows its own sorting arrow |
| Updated |
Sorting large columns |
Numeric-looking strings are coerced once per row instead of on every comparison — sorting 100,000 text rows dropped from ~220ms to ~127ms. Version 12 takes several seconds for the same sort |
| Updated |
Sorting with an active search |
The search results are recomputed from the stored search term, so a server sorting a searched worksheet reaches the same result as the clients (the term was previously read from the DOM input, which does not exist on the server) |
| Updated |
orderBy validation |
An out of range column returns false instead of undefined |
Toolbar and Spreadsheet Instance
| Status |
Item |
Description |
| Breaking |
Toolbar methods moved |
getToolbar, setToolbar, showToolbar, hideToolbar and refreshToolbar live on the spreadsheet only: spreadsheet.showToolbar() (from a worksheet: worksheets[0].parent.showToolbar()). The toolbar belongs to the spreadsheet, so the worksheet-level copies were removed |
| Updated |
Multiple instances |
Toolbar pickers, the progress bar timer and other per-instance resources are now scoped to their own spreadsheet: destroying or operating one instance never releases or interferes with another instance on the same page |
| Updated |
setToolbar(items) |
Rebuilding the toolbar releases the previous toolbar instance and its pickers (they used to accumulate on every rebuild) |
| Updated |
Toolbar merge button |
Works on cells that were never materialized (used to throw on empty selections) |
| Updated |
jspreadsheet.destroyAll() |
Also destroys the spreadsheets created with a namespace: they are registered under named keys of the spreadsheet list, which the previous iteration never visited, so they survived the call with their worksheets still registered |
| Updated |
CommonJS interop |
require() of the version 13 entry returns the jspreadsheet object itself, as the version 12 UMD did, so server code and bundlers keep working without a .default access |
| Updated |
notification(message) |
The spreadsheet-level notification entry point is callable (spreadsheet.notification and the legacy plural alias both work — the alias pointed to a method that never existed). Routes through onerror when defined, and brands with the application name |
Styles
| Status |
Item |
Description |
| New |
Column and row styles |
setStyle({ 'A:C': '...' }) and setStyle({ '2:5': '...' }) store one entry on the column or row itself and every cell inherits it when it renders. Styling a full column of a 100,000 row worksheet is a single assignment (~3ms) instead of one entry per cell — version 12 walks the whole grid and can crash on sparse documents |
| Updated |
getStyle(cell) |
Resolves the style the cell actually shows: its own entry, its cell options, or the inherited column or row style — identical in every environment, whether the cell was ever rendered or not. In version 12 the answer depended on the cell having been scrolled into view |
| Updated |
Copy, paste and fill |
Copying, pasting, drag-filling or filling with Ctrl+D / Ctrl+R from cells inside a styled column or row carries the visible style, including cells far outside the rendered viewport. The target owns the style from then on, like other spreadsheet applications |
| Updated |
Stylesheet updates |
The CSS of the spreadsheet only rebuilds when a change introduces a style that did not exist before. Repeated formatting with existing styles (toolbar bold, repeated colors) went from ~1.5ms per call with a few thousand styles to ~0.01ms |
| Updated |
Undo precision |
Styling or resetting cells that only inherit a column or row style restores the inheritance on undo instead of materializing per cell copies, so undo returns the exact prior document |
| Updated |
Style precedence |
When styles overlap, the cell shows: its own style, then its cell options style, then the column style, then the row style. Deterministic in every environment |
| Updated |
Headless styles |
setStyle, getStyle and resetStyle are fully functional without a DOM: a server replaying a peer's style operations stores and serializes them exactly as a browser does |
Meta and Cache
| Status |
Item |
Description |
| Updated |
setMeta values |
Zero and false are stored as given — only undefined becomes an empty string (version 12 coerced every falsy value to '') |
| Updated |
setMeta / setCache validation |
A cell name without a property name, invalid names or cells beyond the grid return false instead of corrupting records or throwing. Batches apply the valid entries and skip the rest — only the applied entries reach the peers |