Imported from handsontable/handsontable (
handsontable/src/plugins/viewState/AGENTS.md). Install upstream withnpx skills add handsontable/handsontable --skill viewState. Copyright stays with the author.
ViewState plugin – a versioned, JSON-safe view state
The plugin behind getPlugin('viewState').getViewState(), .setViewState() and .validateViewState().
It covers viewState.ts (the class), gridViewState.ts (the conversion, the checks and the restore
orchestration) and the engine it drives, ../../utils/viewState/viewState.ts. Read this before touching
any of them.
What it owns and what it does not
- It owns the public shape (
GridViewState), its version, the checks that decide whether a part can be applied, and the result (applied,skipped,previous,error). It owns none of the restore steps: those are the engine's,utils/viewState/AGENTS.md. - It has no setting and no state.
isEnabled()returnstrue, asExportFiledoes, so the plugin is on whenever its module is registered andupdateSettings()never touches it. That is also why it adds nothing tometaSchema.ts,GridSettingsor the wrappers. - Core must not import it, and neither may anything core imports. The first version put the methods on
Core: everyhandsontable/basebuild then carried the engine and the public layer, +19 KB minified and +6 KB gzip (measured by bundlingtmp/base.mjswith and without them). As a plugin it costs a build only when it is registered. Do not move the methods back for convenience. - It reaches other plugins only through
getPlugin()and their@privatemethods (Filters#_findInvalidConditions(),HiddenRows#_replaceHiddenRows()), never their internals. - No priority ordering matters: it registers no hook and keeps no state, so
enablePlugin()has nothing to order.PLUGIN_PRIORITYis 920, in the900+ band with the other plugins that look at everyone else's state, and it must stay unique in the table.
The traps
- Missing means "leave it alone", not "reset". Internally an empty or missing part is the
neutral value:
hiddenRows: []un-hides everything and a missing frozen count unfreezes. The public restore therefore starts from the neutral state, writes the parts the object carries over it, and passesrestoreViewState()/restoreViewport()the set of those parts. Every step outside the set is skipped. Never call the restore without the set from the public path: the merges and custom borders it would re-apply were captured under the grid's current row order, and a restored order moves them onto other rows. - Live selection objects never cross JSON.
SelectionStateholdsCellRange/CellCoordsinstances, and the restore callsisValid(),clone()andincludes()on them. The public form stores[row, col]tuples, anddeserializeSelection()rebuilds the objects throughhot._createCellCoords()andhot._createCellRange(). The unit suite's selection round trips go red when that rehydration is skipped.selection: nullmeans "nothing selected" and deselects. - The viewport step holds the rendering only, never the index cache.
restoreViewport()puts the Pagination page back, and Pagination counts its pages from the index cache: insidesafeBatch()(which also callssuspendExecution())setPage(3)was clamped to a stale count and landed on page 1. SheetsBar's#batchRenderholds the rendering only for the same reason. Pinned by the pagination case oftests/e2e/view-state-api.spec.ts; the jsdom unit case cannot see it. - A part is skipped, never thrown on. A disabled plugin (
pluginDisabled), adataProvidergrid for the sort, filters and pagination, which would fetch, and for the row order, the hidden, trimmed and collapsed rows, the row heights and the selection, which are positions in the page the grid has loaded and would land on other records on another page (externalDataSource), a stored order whose length no longer matches (sizeMismatch, found bywriteOrder()inplanRestore(); the engine's own length check, whichapplyParts()still reports, is only a net for a hook that changes the count mid-restore), a hidden or trimmed index, a size or a scroll position past the end of its axis, or a selection that does not fit (outOfRange), and a value of the wrong shape (invalid) are all reported inskipped. Anotherversionapplies nothing and warns, and a key that is not a part is reportedinvalidunder its own name (an LLM or a URL misspells keys, and a silent ignore reads as success). A new part needs a validator inwritePart(): a state may come from an LLM or a URL, and a malformed value must not reach a plugin API that throws. - Validate against the grid, not only the shape. A value that passes a type check can still
break a plugin: the Filters import throws on an operation or condition name that is not registered
on the page and on two entries for one column with
different operations, after it has already emptied the live conditions; a sort the plugin rejects
(a column past
countCols(), a duplicate, more than one column without MultiColumnSorting) leaves the grid unsorted, because the restore clears the sort first; andsetIndexesSequence()stores any array, so a row order that is not a permutation of0..n-1shows one record twice and hides another.writePart()rejects all of those asinvalidbefore anything is applied, andwriteOrder()reports another length assizeMismatch. Theit.eachtable inviewState.unit.jsholds one case per rule. - The public sort carries no pre-sort order. The sorting plugin builds its pre-sort cache on its
first sort and keeps it until it is disabled, so an order handed to it later is overwritten on a
grid that has sorted before. The restore hands it the current order, and
rowOrderrestores the exact sequence when the state carries it. - By default the restore records no undo step, and keeps the earlier ones. Unlike a sheet switch there
is no
loadData()to empty the stacks, so a later undo of an older sort or filter step acts on the restored view.ViewState#setViewState()'s JSDoc says so, andundo: 'record'is the way out (below). - A hidden row without a visual index goes through
HiddenRows#_replaceHiddenRows(). The plugin's visualshowRows()/hideRows()reject a whole list when one row in it has no visual index, which is the case for every row a filter, TrimRows, or a collapsed NestedRows parent trims. The@privatephysical method takes the full set, runs the hiding hooks, and applies nothing when abeforeUnhideRowsorbeforeHideRowslistener returnsfalse, so the plugin keeps its own map and its veto. Core must never write into the plugin's hiding map itself. Before, a live-grid restore could not show a trimmed hidden row, and a row the state hid while it was trimmed came back visible once the trim lifted (the same held for a sheet switch whose arriving sheet filters a hidden row out). - The filter validation lives in the Filters plugin.
Filters#_findInvalidConditions()checks the columns, the operation, the condition names and the arguments, and answers with the path and the reason of the first problem, which the public layer turns into the skipped entry'spathandmessage(binding a condition runs its input decorator, which throws on arguments of the wrong shape). Core must not import the condition registries: that would pull Filters internals into every build,handsontable/baseincluded. - Frozen rows skip a paginated grid. The restore writes the frozen counts onto the table meta, which
skips the Pagination conflict
updateSettings()raises forfixedRowsTop/fixedRowsBottom, so the public layer reports the part aspluginConflictinstead of leaving both on. - A row order without a sort clears the sort. The sort indicator and
getSortConfig()would otherwise describe an order the rows no longer have. - The public restore starts from
createNeutralViewState(), not a capture. The engine reads only the fields of the parts it was given, so capturing the live view first only cost a scan of the filters, borders, merges and sizes. The view batch runs only when a part outside pagination, selection and scroll is in the request, and the viewport batch only for those three. - Undo is suppressed twice by default, and not at all with
undo: 'record'.withoutUndoEntry()wraps the call in core'soperationScope.suppress(), which also keeps a held continuation (a validator's answer that settles later) unrecorded, and sets UndoRedo'signoreNewActionsflag, which covers the second guard for a transaction that would open meanwhile (UndoRedo records from the operation journal, sosuppress()is what carries the weight). Withundo: 'record'the whole apply runs inside oneoperationScope.run('view_state', 'setViewState', ...): the plugin mutators it calls join that transaction, and UndoRedo records one step from the index-map snapshots, the plugin states and the settings it already captures (sort states, hidden and trimmed maps, sizes, frozen counts, Filters, Pagination, NestedRows). Selection and scroll are not part of a step. The unit suite undoes and redoes a state that carries every part; add a part there before relying on it. - A skipped entry is
{ part, reason, path, message }, and the whole check runs before any write.planRestore()reads every part into the neutral internal state and collects the failures; nothing on the grid changes until it is done. That is whatvalidateViewState()returns, whatatomictests, and why a filter the plugin cannot import never reachesimportConditions(). A check that can only be made while applying (the selection's fit is the one that used to be) belongs inplanRestore(), oratomicstops being atomic. The result'serroris for a state rejected as a whole (notAnObject,unsupportedVersion,atomicRejected); a missing or non-object state is an error, never an empty success. previousis read after the check and before the apply, only for the parts being applied (captureViewState(hot, [], parts)skips the scans of the parts it was not asked for), in the same public shape, sosetViewState(previous)reverts. Parts the state names but the grid skipped are not in it. ArowOrderrestored without asortclears the sort, sopreviouscarries the sort too; without it a revert would bring the rows back and leave the indicator andgetSortConfig()empty.atomiccovers the checks made before the apply, and only those. A step a listener vetoes (beforeFilter,beforeHideRows,beforeColumnSort) and a selectionrestoreViewport()cannot land are found while applying, soappliedcan name a part a listener stopped, and the selection skip is reported after the rest was written. Moving such a check intoplanRestore()is the only way to make it atomic.- A selection and a scroll row are measured after the row count changes.
filters,trimmedRowsandcollapsedRowschangecountRows(), so a view saved on the full grid has its selection at row 50 while a filter leaves 10 rows. With one of them in the stateplanRestore()skips the fit check (deferRows), andapplyParts()measures the scroll row, andrestoreViewport()the selection, once the rows are applied. The cost: those two are reported after the apply, soatomicdoes not cover them. - The sort is cleared only when one is on.
sort([])on a never-sorted grid makes the sorting plugin cache the order from before the restore, which a later sort and clear would bring back over the restoredrowOrder; on a server grid it sends a fetch with an empty sort.clearActiveSort()checks both, andpreviouscarries the sort only when there was one. getViewState()reads only the parts the grid can give. Passing the available parts tocaptureViewState()keeps a grid with merges and borders from paying for scans whose result is dropped.- The NestedRows collapse is replaced, not merged.
restoreCollapsedParents()expands every parent collapsed now before it collapses the stored ones. On a sheet switch nothing is collapsed by then (loadData()drops it), so that step is a no-op there.
Where to look next
../../utils/viewState/AGENTS.mdfor the engine and the order of the restore steps.../sheetsBar/AGENTS.mdfor the sheet switch that shares the engine.../base/AGENTS.mdfor the plugin contract, and thehandsontable-plugin-devskill.
Testing
npm run test:unit --prefix handsontable -- --testPathPattern='plugins/viewState|utils/viewState|sheetsBar'tests/e2e/view-state-api.spec.tsfor the parts jsdom cannot judge (scroll, a header selection, the page with a selection on it):cd tests && npx playwright test --project=e2e-main e2e/view-state-api.spec.tssrc/plugins/viewState/__tests__/viewState.types.tspins the public types and theViewStatenamespace aliases.
