Sorting Implementation in Vanilla-Grid
This document explains how sorting is implemented in Vanilla-Grid, including header click behavior, single vs multi-sort state, client/server modes, comparator chaining, and interactions with row updates and column reordering.
1. Feature Scope
Sorting supports:
- Header-click sort toggling (
asc→desc→ clear) - Shift+click multi-column sort chains
- Client-side sorting (default)
- Server-side sorting delegation (
serverSort: true) - Public APIs to set/clear/read sort state programmatically
Relevant options:
sorting.enabled(defaulttrue) — global toggle for grid-level sortingcolumn.sortable(defaulttrue) — per-column override; whenfalse, the column header shows no sort indicator, no click handler, and programmatic sort skips this columnsorting.serverSide(defaultfalse)sorting.onSortcallbacksorting.compareValuescomparator callbacksorting.shimmerThreshold(default5000) — row-count threshold above which a local sort is deferred one animation frame, the loading skeleton is shown, andisLoadingflips true (blocking scroll and anydisabled-when="isLoading"toolbar command) for the duration, so the UI never appears frozen — or interactive in a way that races the sort — during a slowArray.sort(). Set to0to always show, set tofalse/ a negative number /Infinityto disable. See §7.2 below.sorting.useWorker(defaulttrue) — enable Web Worker offload for very large local sorts (see §7.3). When false, all sorts run on the main thread.sorting.workerThreshold(default50000) — row-count threshold above which the sort is sent to a Web Worker (when eligible) so the main thread stays responsive. Same parsing rules asshimmerThreshold(false/Infinity/ negative → disabled).
Per-column sort redirection:
column.sortFields—string[]— sort by these field paths instead ofcolumn.field(see §7.4).column.sortFieldTypes—string[]— type hints parallel tosortFields(see §7.4).
The HTML attribute sortable="false" on <vn-grid-column> maps to column.sortable = false.
The HTML attributes sort-fields and sort-field-types on <vn-grid-column> map to column.sortFields and column.sortFieldTypes respectively (comma-separated, parsed by vanilla-grid-element.js).
2. Internal Sort State Model
Two related state objects are maintained:
sortColumnsState: Array<{ columnIndex, direction }>- Full ordered sort chain for multi-sort
sortState: { columnIndex, direction }- Legacy primary sort snapshot (first chain entry)
getSortState() returns a richer snapshot with:
- primary
columnIndex/direction/column sortColumnslist where each entry includes resolvedcolumn
3. Context Menu Integration
Header right-click (onHeaderContextMenu, src/vanilla-grid/features/header-menu.feature.js)
includes sort actions when sorting is enabled grid-wide (sorting.enabled) AND
the column itself is sortable (column.sortable !== false) — omitted
entirely otherwise, same gating as the "Filter…" item:
- Sort ascending — disabled when the column is already sorted ascending
- Sort descending — disabled when the column is already sorted descending
- Clear sorting — disabled when the column has no active sort
All three call sortColumn(columnIndex, direction, { multi: true }) (null
direction for Clear) — multi: true means the menu only ever touches this
column's entry in the sort chain, leaving any other column's sort (e.g. from
a shift-click multi-sort) untouched. This is deliberately different from a
plain header click, which replaces the whole chain (multi: false) unless
Shift is held.
Menu labels are localized through:
messages.sortAscendingmessages.sortDescendingmessages.clearColumnSort
Each action closes the menu (_closeHeaderContextMenu()) before calling
sortColumn, matching the Auto-fit item's pattern.
4. Header Click Flow
renderHeader() wires th click handlers to handleHeaderClick(e, index) when sorting is enabled.
handleHeaderClick(...) performs:
- Ignore click if a drag reorder just happened (
_didColumnDragguard). - Ignore click on
.vn-grid-col-resizer. - Validate sorting is enabled and index is valid.
- Resolve current direction for that column from
sortColumnsState. - Compute next direction with
getNextSortDirection(...). - Call
sortColumn(columnIndex, newDirection, { multi: e.shiftKey }).
Direction cycle logic:
null→ascasc→descdesc→null(remove column from sort chain)
5. Programmatic Sort APIs
Primary methods:
sortColumn(columnIndex, direction, options)sortColumns(sortColumns, options)clearSort()getSortState()
All four are index-based. For an initial sort declared before the grid
has columns — from markup or from an options object — use the key-based
sort-by attribute / sorting.columns option instead (§12.1).
5.1 sortColumn(...)
Builds a next chain from a single column request:
multi: true: updates/removes only that column entry while keeping othersmulti: false: replaces chain with just that column (or clears if direction is null)
5.2 sortColumns(...)
Central state transition entry point:
- Sanitizes input via
sanitizeSortColumns(...). - Updates
sortColumnsStateand legacysortState. - Computes snapshot via
getSortState(). - Fires
onSort(column, direction, sortState)if provided. - Branches by mode:
serverSort: true→ only refresh header indicatorsoptions.deferToReload: true→ like server mode, only refresh header indicators; skip the client-side apply entirely. Used when the caller will immediately trigger a coordinated data reload that re-renders every row (e.g.clearColumnFiltersAndSorting()). This avoids the client sort's ownsetLoading(true)→setLoading(false)shimmer bracket — which finishes in ~2 animation frames — tearing the reload's loading skeleton down before the (potentially slow, worker-backed) reload delivers rows, which would flash a blank, shimmer-less grid in between. The generation token is still bumped so any in-flight deferred/worker sort is superseded.- client mode → apply local sorting, refresh header, update visible rows
By default, local sort resets viewport.scrollTop to 0 unless options.preserveScroll is true.
6. Sort Sanitization Rules
sanitizeSortColumns(sortColumns) enforces:
- Input must be an array.
- Each entry must be an object with:
- integer
columnIndexin current range directionof'asc'or'desc'
- integer
- Duplicate column indices are removed (first valid entry wins).
- Invalid entries are silently dropped.
This keeps state robust when called with external data.
7. Client-Side Sort Engine
applySorting() handles local ordering.
Behavior:
If no sort chain:
displayRowsbecomes direct reference torows.If sorting active with the built-in comparator (precomputed-key fast path): sort keys are precomputed per sort column in one O(n) pass by
_buildSortKeyColumns()(sorting-comparator.feature.js), then an index array is sorted comparing only the key arrays — no per-comparisonDateparsing,parseFloat,String()allocation, or locale lookup inside the O(n log n) loop.displayRowsis rebuilt from the sorted indices. Pairwise fallback semantics are preserved exactly: for number/date columns both the numeric key and the string key are precomputed, and whether a given pair compares numerically or as collated strings still depends on that pair (an unparseable value poisons the pair, not the column). The ordering is pinned bytests/node/sorting-precompute.test.js.If a host supplies
sorting.compareValues, the raw-value pairwise path is used verbatim (the custom comparator's contract — raw cell values plus the real column object — is untouched).Which path runs is decided by
hasCustomCompareValues— the boolean the grid derives fromtypeof options.sorting.compareValues === 'function'— and not by whether the injectedcompareValuesis callable. That distinction is load-bearing:vanilla-grid.jsresolvescompareValuesto_resolveCallback(sorting.compareValues, this.defaultCompareValues.bind(this)), so the sorting feature is always handed a function, and testing callability selected the pairwise path for every grid ever built. It is the same authority_isWorkerSortEligible()already uses for the same question. When no flag is wired at all — a partial embedding that injects onlycompareValues— the older callability test still applies, so such a consumer keeps its comparator. The pairwise path itself is:- iterate sort entries in order
- compare current column values
- first non-zero comparison decides row ordering
Comparator source is compareValues option, defaulting to defaultCompareValues(...).
A custom compareValues must be a consistent ordering — antisymmetric and
transitive, returning 0 exactly for values that belong together. The sort is
undefined without it, and grouping relies on it too: the boundary scan finds
each group's end by a galloping search over the sorted rows with this same
comparator (Row Grouping § 3.2), which is
only correct when equal values are contiguous.
A custom compareValues may order by any property of the column object it
receives — including one the grid has never heard of, such as
column.enumOrder — except the display-only properties listed in § 10.3.
setColumns() re-sorts when such a property changes (§ 10.3). A comparator
that depends on a display-only property, or on anything outside the column (a
closure variable, a lookup table the host mutates), cannot be detected: the host
must re-sort itself by passing the current sort state back to sortColumns()
(grid.sortColumns(grid.getSortState().sortColumns, { preserveScroll: true })). That call has no "state
unchanged" short-circuit, so it re-orders — and, on a grouped grid, re-applies
the effective chain — but it also calls onSort (so vn-grid-sort-changed
fires) and persists the same state again.
Every write to displayRows — both the branches above and the Worker path's
own write (§7.3) — is immediately followed by a rebuild of the renderEntries
projection, because the renderer and every geometry consumer read
renderEntries, not displayRows (see
Grouping Implementation). The rebuild is
wired into the injected setDisplayRows callback in vanilla-grid.js, so no
sort path can forget it. Sorting only ever reaches that callback with grouping
inactive — applySorting() hands an active grouping over to
applyGroupingSort (which applies the effective sort and rebuilds the
projection itself), and the Worker path is ineligible while grouping is active
— so the rebuild there is a zero-allocation reference assignment
(renderEntries === displayRows).
7.1 Default comparator (defaultCompareValues)
The canonical implementation is the shared pure function
window.VanillaGridDefaultCompare (sorting-comparator.feature.js). Both
VanillaGridSortingFeature.defaultCompareValues and
VanillaGrid.prototype._builtinCompare delegate to it — a single source of
truth with no drift between the two entry points; _builtinCompare keeps
only a minimal nulls-first string fallback for partial bundles that omit the
comparator feature.
null/undefinedare ordered first.- Type-aware comparison based on
column.type:'number': numeric compare viaparseFloat.'boolean':false < truevia coerced numeric rank.'date','datetime','time': chronological compare viaDate.getTime().
- Fallback to locale-aware, case-insensitive string compare via a per-locale
cached
Intl.Collator(sensitivity: 'base'). The collator is constructed at most once per locale and reused across all string comparisons in that locale, eliminating the 5–50 µsIntlconstruction cost per comparison call. For the precomputed path the collator is resolved once per sort, not per comparison.
Descending direction is implemented by multiplying comparison by -1.
7.2 Smart shimmer for slow local sorts
Array.prototype.sort is synchronous — it blocks the main thread until it
returns, so the browser cannot paint a “loading” state during the call.
For large datasets (200k+ rows) this can freeze the UI for hundreds of
milliseconds. To avoid that, sortColumns(...) checks the row count against
sorting.shimmerThreshold (default 5000) before calling applySorting():
rows.length < shimmerThreshold→ sort runs inline (current behaviour, no perceived latency for small/medium datasets).isLoadingis left untouched.rows.length >= shimmerThreshold→grid.setLoading(true)is called (which itself paints theshowLoadingSkeletons()shimmer), the sort + render are deferred behind tworequestAnimationFramecalls so the browser has a chance to paint the skeleton before the blocking work begins, andsetLoading(false)is called once the deferred sort completes (inside the inner rAF, right beforeapplySorting()+ the re-render).
While isLoading is true — for either the deferred in-thread path or the
Web Worker path below — wheel/keyboard scrolling is blocked (same guard
filter/search already relies on) and, if a host wires
disabled-when="isLoading" on a <vn-grid-toolbar-command>, that command is
disabled for the duration too. This mirrors exactly how isLoading is already
gated around DataManager.fetchRows() for filter/search/reload
(dataLoading.shimmerThreshold, also default 5000) — sorting used to leave
isLoading untouched, which meant a large sort left scroll/toolbar commands
live while an equally large filter/search froze them; both operations now
follow the same rule. _setLoadingState() is the single choke point for this:
when a setLoading callback is wired (the normal <vn-grid> case), it is the
only shimmer trigger — setLoading(true) already calls
showLoadingSkeletons() internally, so sortColumns() does not call it
separately (that would rebuild the skeleton DOM twice for no benefit). The
direct showLoadingSkeletons() call is used only as a fallback when no
setLoading callback was injected (e.g. VanillaGridSortingFeature exercised
standalone in unit tests, without the full grid wired in).
Server-sort mode never defers (no local sort to wait for), never paints the
local shimmer, and never touches isLoading — the data manager / host is
responsible for its own fetching UI (which already flips isLoading via the
filter/search path when it reloads). Custom slow comparators on small datasets
can opt into the shimmer (and the isLoading flip) by lowering the threshold
(e.g. shimmerThreshold: 500).
This matches the strategy already used for infinite-scroll page loads
(infiniteScroll.shimmerDelay) and reuses the same skeleton DOM.
7.2.1 runReorderPipeline() — the shared bracket
The decision above (run now / run behind a shimmer / run off-thread) is not
owned by sortColumns(). It lives in runReorderPipeline({ workerSortState, apply, finish }), and it has three callers:
| caller | what it reorders |
|---|---|
VanillaGridSortingFeature#sortColumns() |
a header sort / a programmatic sort change |
VanillaGridGroupingFeature#setGroupState() |
a group level added, removed, re-nested or cleared |
VanillaGrid#setRows() |
a full data load — the initial load, a reload, a filter or search result |
Each was, at some point, its own fully synchronous pipeline with no shimmer, no
Worker and no supersede guard, and each froze the tab for seconds on a
million-row dataset while the identical sort on the same data was both shimmered
and off-thread. setRows() was the last of the three, and the loudest caller
was the toolbar's "Reset grid layout" command.
The bracket owns exactly one decision and nothing else; each caller keeps its
own finish sequence, because a sort and a group change genuinely differ in what
they must do afterwards. finish is handed { deferred, viaWorker }:
| path | sequence |
|---|---|
| sync | apply() → finish({ deferred: false }) |
| deferred rAF | skeleton → two frames → apply() → finish({ deferred: true }) |
| worker | skeleton → off-thread sort resolves the sorted array → the bracket stores it in displayRows → finish({ deferred: true, viaWorker: true }) |
viaWorker exists because the off-thread ordering never passes through
applySorting() → applyGroupingSort(), so the render projection still points
at the pre-sort array and has to be rebuilt explicitly.
onReorderSettled runs after the winning finish, on every path. It is an
optional constructor callback that the grid wires to
VanillaGridGroupingFeature#settlePendingWork(). A superseded reorder never
reaches it; the reorder that superseded it does. That is how a group change or
aggregate change overtaken by a header sort or a reload is still persisted and
announced once. Sorting only calls the callback, so it has no dependency on
grouping. See Grouping Implementation § 5.2.
The projection is built exactly once per reorder, by finish. Every write
to displayRows goes through the grid's setDisplayRows injection, which calls
VanillaGridGroupingFeature#realignRenderEntriesForDisplayRows(). With no
grouping applied that is the zero-allocation re-alias R1 depends on. With
grouping applied it deliberately does nothing, because the projection is then a
full scan of the dataset and the caller's finish is about to build it from the
settled order in the same synchronous run — building it at both points scanned a
million rows twice for one answer, roughly 600 ms of blocked main thread per
grouped reorder.
The shimmer this bracket raises is publicly observable. raiseLoading() and the tail that
releases it both go through VanillaGrid#setLoading(), which announces every real transition of
isBusy() as vn-grid-busy-changed
(01 § 12.1). That matters most for the paths this
bracket owns: a deferred sort or group change is a busy window with no fetch in it, and a load
whose rows reorder here stays busy after its own vn-grid-loaded has been emitted. Anything
that greys itself out while the grid works must key off that event, not off the load lifecycle
pair.
A raised shimmer is not repainted away. The bracket's contract is that
everything between raiseLoading() and finish is covered by the skeleton, and
for a long time one thing broke it from outside: a viewport resize during the
deferred window re-rendered real rows over the skeleton, drawn from the
not-yet-reordered displayRows, and nothing put the skeleton back. Grouping hit
it every time — mounting the group bar resizes the viewport, inside the reorder's
own shimmer — but any load overlapping any resize did the same. The guard lives
with the resize handler rather than here, because the bracket cannot see a
repaint it did not ask for: _onViewportResized() re-paints the skeleton at the
new size while isLoading is true, and still does its geometry work either way.
See Row Virtualization
§ 1.12.1.
A generation token (_sortGeneration) is stamped per call and re-checked in
every deferred continuation, so a rapid second reorder — of either kind —
silently discards the first instead of racing it to displayRows. Sharing one
counter between sorting and grouping is deliberate: both reorder the same array,
so a group change must supersede an in-flight sort and vice versa.
The bracket is also the only thing that stores an off-thread result.
_sortViaWorker() resolves the sorted array and deliberately does not write
displayRows itself, so that write happens under the generation check like
every other one. See § 7.3, "Last-wins sort generation", for the ordering
corruption that arrangement prevents.
What deferral means for a caller of setRows(). Below
sortShimmerThreshold rows the bracket runs apply + finish synchronously,
so a small grid keeps the post-conditions it always had: displayRows is
ordered, the pool is built and the body is rendered by the time setRows()
returns. At or above it the reorder is deferred, and setRows() returns with
the ordering still outstanding — the bracket says so through its boolean return
("true when the work was DEFERRED"). Two properties keep that safe for callers
that read the grid straight afterwards:
displayRowsandrenderEntriesare pointed at the freshly-set rows beforesetRows()returns, so a synchronous reader sees this dataset unordered, never the previous dataset's rows.- Row-count-facing state (
getLoadedRowCount(),_hasMoreData, the selection key map) is settled synchronously; only the ORDER is deferred.
A load with nothing to order — no active sort, no applied grouping, or server-side sorting, where the rows arrive pre-ordered — skips the bracket entirely and stays synchronous at any row count. That is the commonest load, and it costs it neither a shimmer nor two animation frames.
setRows() also owns one piece of state consumption: sort / group state that
clearPersistedSettings({ deferToReload: true }) deliberately left parked is
consumed here (state only, no reorder of its own) so the single reorder below
it produces the ordering once. See
Local Storage Settings § 6.5.
See Row Grouping Implementation § 6.1 for
what this means for setGroupState()'s return value.
7.3 Off-thread sort (Web Worker)
When the row count meets sorting.workerThreshold (default 50000), the
sort is dispatched to a lazily-created Web Worker so the main thread stays
responsive (the page can still scroll, animate, and accept clicks while
the worker runs Array.sort() on hundreds of thousands of rows).
Grouping uses the Worker too. A grouped grid is ordered by the effective sort (group levels leading the user's own columns), and the Worker sorts exactly the chain it is handed — so it is handed the effective chain, not the user's. Two consequences:
- Group levels name their column with a direct
columnreference rather than acolumnIndex, because an applied level's column has been removed from the visiblecolumnsarray (see Row Grouping § 18.1)._sortViaWorker()and_buildSortKeyColumns()both resolveentry.column || columns[entry.columnIndex]. - Eligibility gains a gate:
_areGroupLevelsWorkerSafe(groupLevelColumns).buildRenderEntries()re-derives group runs from the sorted array using the main-thread comparator, so any ordering disagreement on a group column would scatter equal values and render two captions for one logical group. Exact parity is required, not "close enough". Only group levels are gated — a user sort column merely orders rows within a group and can never move a boundary.
| group level column | worker-safe | why |
|---|---|---|
| string / untyped | yes | both sides collate through the same Intl.Collator(locale, { sensitivity: 'base' }) — see §7.3.1 |
| number | yes | both compare numerically, with the same one-NaN-poisons-the-pair fallback |
| boolean | yes | extracted as its main-thread ordering rank, so booleanCoerce cannot diverge |
| date / datetime / time | no | the Worker parses with new Date(v); the main thread uses the shared temporal parser (strict dateFormat patterns, invalid-value buckets, column.nulls placement) |
sortFields |
per declared field type, same rule |
An unsafe group level routes the whole chain to the deferred in-thread path (§7.2) — still shimmered, still non-freezing from the user's point of view, just on the main thread. Closing the temporal gap means sending precomputed bucket ranks the way the boolean path now does; it is a self-contained follow-up, not a prerequisite for grouping.
7.3.1 Worker/main-thread ordering parity
The Worker used to compare strings with raw < / > on String(v).toLowerCase()
while the main thread used a locale-aware Intl.Collator with
sensitivity: 'base'. For an ungrouped sort that was an invisible
inconsistency (nothing cross-checks the two). For grouping it is a correctness
bug: Ångström and Angstrom are equal to the collator but far apart in
byte order, so the boundary scan would see them separated by other values and
emit two captions for one group.
The Worker now builds the same collator from the locale sent in the message and
collates once per distinct value, not once per comparison: it sorts the
distinct set with collator.compare, gives collator-equal neighbours a shared
integer rank, and lets the O(n log n) comparison loop compare integers. That
produces byte-identical ordering to collator.compare on every pair while
staying fast — on a group column, whose cardinality is bounded by
GROUP_CARDINALITY_CEILING, the distinct set is tiny next to the row count.
A one-value-per-row column degrades to a single collator sort of n strings,
still no worse than comparing pairwise.
This also closes the pre-existing ungrouped inconsistency: a Worker sort and an
in-thread sort of the same data now produce the same array.
tests/playwright/grouping-large-dataset.spec.js pins it position-by-position
over 200k rows.
Flow on a worker-eligible sort click:
sortColumns(...)flipsisLoadingtrue (painting the shimmer as a side effect — see §7.2) and refreshes the header indicators.- For each entry in the sort chain,
_sortViaWorker()computes a per-column signature and compares it against_sortColumnSignatures(keyed bycolumn.key+ sort-field index) before extracting anything. A column whose stored signature matches sendsvalues: null("reuse what you already have") instead of re-runningcolumn._getValue(row)/sortGettersand re-cloning its values — see "Persistent worker + per-column cache" below. Only columns whose signature is absent or differs extract their values (chunked and yielded — see the chunking note below) along with the columntypeand directionmultiplier. - The payload is
postMessaged to the worker. For any column whosevaluesarrived this message, the worker (re)computes that column's key arrays in O(n) (epoch ms for date types, floats for numbers, collation ranks for strings — §7.3.1) and caches them; columns sent asvalues: nullreuse their cached keys instead. The worker then computes aUint32Arrayof sorted indices comparing those keys (cached or fresh, uniformly) and posts it back as a transferable buffer (zero-copy on the return trip). Note the worker's string comparison istoLowerCase()code-unit order (not the main thread'sIntl.Collator) — unchanged from the previous per- comparison implementation. - On resolve,
isLoadingflips back false, then the main thread mapsrows[indices[i]]into a newdisplayRowsarray, rebuilds the virtual pool, and re-renders. If the worker instead rejects (unavailable/failed),isLoadingstill flips false before falling back to the in-threadapplySorting(), so a failed worker never leaves scroll/toolbar blocked; the per-column cache is also cleared in this case, since the worker's resident state is now unknown and must be rebuilt from scratch next time.
Persistent worker + per-column cache
The Worker is not recreated on every sort — it persists for the grid's
lifetime, and both it and this feature's own bookkeeping retain
already-extracted columns across sort clicks. Whether a retained column may be
reused is decided per column by a signature, held in
_sortColumnSignatures (Map<identityKey, signature>), not by a residency
flag: a column is resident iff its stored signature equals the one computed
for it this call. This mirrors the invalidation design the filter Worker
already uses (StaticDataManager#_computeColumnSignatures()), so the two
worker caches in the codebase reason about residency the same way.
The signature covers every input that determines the bytes the Worker cached, and nothing else:
| component | why it is in the signature |
|---|---|
_sortDatasetVersion |
different rows ⇒ different values. Bumped by bumpDatasetVersion(), called from vanilla-grid.js's setRows()/appendRows(). Folding it in is what removes any separate compare-and-clear step: a row-data change changes every signature at once. |
the identity of the compiled accessor — column._getValue, or column._sortGetters[f] for a sortFields column |
it is what produced the extracted values. setColumns() recompiles these for every supplied column, so signing the function itself cannot drift from _compileColumnAccessor()'s own rules the way an enumeration of field/valueGetter/sortFields could. Functions can't be serialized, so each one seen is assigned an incrementing id in a per-feature WeakMap. |
column.type |
it selects the encoding, not which values are read — a string → number retype changes the packing chosen inside the Worker, so accessor identity cannot capture it. |
column.booleanCoerce |
same reason: a strict → loose change alters the ordering ranks computed on the main thread (see the boolean note in §7.3.1's table) without touching the getter. |
Deliberately not in the signature: sort direction (multiplier travels with
every message and never affects the cached keys) and the position of a column
within the chain.
So a sort click that changes neither rows nor column definitions — toggling a
column's direction, re-adding it as a tiebreaker, or combining it with a
newly-added column — reuses whatever's already cached and only extracts+sends
columns whose signature actually moved. Conversely, a setColumns() that
replaces a column under the same key — whether it changes the accessor or only
the encoding — produces a different signature, so the next sort re-extracts
instead of ordering the new column by the old column's values.
Committed entries are merged into the map rather than replacing it. The
sort Worker never garbage-collects workerState.cols (it is only reset
wholesale on a datasetVersion change), so a record for a column the Worker
still holds must survive a call that didn't mention it — which is what keeps
sorting by A, then B, then A from re-extracting A.
_sortWorkerInstanceGen keeps a wholesale clear of its own: if the Worker was
silently recreated (e.g. after a prior error), a mismatch drops every record,
both because a new Worker holds nothing and because dropping them bounds
memory. If the Worker is ever asked to reuse a column it has no cached entry
for (a main-thread/Worker desync), it replies with an error instead of silently
sorting against missing data, and the caller falls back to the in-thread path
for that call. A failed round trip clears _sortColumnSignatures entirely,
since the Worker's resident state is then unknown.
Signatures are committed after postMessage(), not during extraction. The
pass collects { identityKey, signature } locally and writes them into the map
only once the message carrying the values has been posted. That makes two races
structural rather than guarded:
- A
setColumns()landing mid-extraction (possible because extraction yields — see below) cannot corrupt anything. The pass commits the signature it computed at entry, which correctly describes the values it actually extracted and sent; the next sort computes a different one from the new definitions and re-extracts. The record is never a lie, so there is nothing to invalidate. - Nothing is recorded resident before it has been posted, so a concurrent
_sortViaWorker()can never sendvalues: nullfor a column still in flight. Worst case both calls extract and send the same column, and the Worker simply overwrites its entry.
Chunked, abortable extraction. When a column does need (re)extraction, the
column._getValue/sortGetters loop processes rows in batches (100,000 at a
time), yielding to the event loop between batches through the shared
VanillaGridYield module (main-thread-yield.js) — so even a first-ever sort
by a new column over a very large dataset keeps the page responsive throughout,
rather than blocking for the extraction pass's whole duration in one
synchronous sweep. That module deliberately does not yield through
requestAnimationFrame: rAF resolves at a frame boundary and would cap the
pass at one slice per frame (~30% duty cycle), whereas a fast macrotask keeps
the same slice length at a ~98% duty cycle. dom-scheduler.js keeps rAF
because its work must align with a paint; this pass merely must not monopolise
the thread. The same module serves StaticDataManager's filter/search
extraction, so the two chunked passes schedule identically.
runReorderPipeline() threads its generation token in as a liveness predicate
(() => generation === this._sortGeneration), re-checked immediately after
each yield resumes. A superseded sort therefore abandons its extraction
mid-pass: it posts no message, bumps no _sortWorkerSeq, and — because
signatures are committed only after the post — leaves no residency claim to
unwind. A rapid sequence of header clicks over a large dataset no longer runs
several full O(n) extraction passes to completion for results that are all
discarded but the last.
A per-request sequence id ensures stale worker results from superseded
requests are silently discarded inside the worker response handler — and,
per the last-wins rule below, only the winning (latest) sort's callback is
allowed to flip isLoading back false.
Last-wins sort generation
VanillaGridSortingFeature maintains a monotonically-incrementing
_sortGeneration counter. Every call to sortColumns(...) increments this
counter and captures the current value as a local sortGeneration constant.
rAF (in-thread deferred) path — The generation is checked in both the
outer and inner requestAnimationFrame callbacks. If a newer sort has been
triggered by the time the outer rAF fires, the callback returns immediately
without scheduling the inner rAF and without calling applySorting(). No
sort computation happens at all for the stale click.
Worker (off-thread) path — the Worker is persistent (see "Persistent
worker + per-column cache" above), so a rapid follow-up sort no longer
terminates it. _sortViaWorker() resolves the sorted array rather than
storing it, and runReorderPipeline() writes displayRows from inside the
generation check — so a superseded reply is discarded before it can touch the
grid. Cancellation is partial, and the split is worth being precise about:
- The main-thread extraction is cancelled. The generation token is passed
into
_sortViaWorker()as a liveness predicate and re-checked after every chunk yield, so a superseded pass stops extracting and never posts. - The Worker's own scan of a message already posted is not — it cannot be un-posted, so it runs to completion off-main-thread before its result is thrown away. That is the deliberate trade-off for keeping the Worker and its per-column cache alive across clicks, unlike the rAF path, which discards a superseded sort before any computation happens at all.
Row-data axis — a reorder is superseded not only by a newer reorder but by
a change to the rows themselves. setRows() and appendRows() call
bumpDatasetVersion(), which bumps _sortGeneration alongside
_sortDatasetVersion. This is required, not merely tidy: a deferred reorder's
answer describes the dataset it was computed against, and on the Worker path
that answer is a precomputed array whose elements were read out of the row
array captured at dispatch (sortedRows[i] = rows[indices[i]]).
So when a search, filter or reload lands while a Worker sort is in flight, the
sort is abandoned rather than applied. Concretely: the reload clears the
loading state, installs the new rows and re-sorts them synchronously — and the
in-flight sort's later reply is discarded at the generation guard instead of
overwriting that correct ordering with rows drawn from an array the grid no
longer references. The invariant this protects is the simplest one the grid
has, displayRows is always a permutation of rows; before this, a search
narrowing 200,000 rows to 15,062 could leave 200,000 rows on screen, 184,938 of
them foreign, under a status bar reading "Loaded 15,062/15,062". The rAF path
was only ever wasteful rather than wrong here — it re-derives from
this._getRows() fresh — but it is superseded too, which also skips a
redundant re-sort of rows setRows() has already sorted.
The extraction is cancelled by the same token: shouldContinue is
generation-based, so a row-data change stops a chunked extraction at its next
yield and posts nothing.
Because a row-data change is not a reorder, nothing else would clear a loading
state the pipeline raised. runReorderPipeline() therefore records that it
raised it (_reorderOwnsLoading) and bumpDatasetVersion() clears it when it
supersedes such a reorder — keyed on ownership, never on isLoading being
true, so a loading state set by the data loader is never torn down. That
distinction is load-bearing: loadDataInChunks() legitimately calls
setRows()/appendRows() mid-load with more chunks still to come, and a bare
isLoading check would kill its shimmer.
_sortGeneration is the only supersede authority, because it is the only
counter every reorder — and every row-data change — bumps. _sortViaWorker() also stamps each dispatch with
an incrementing _sortWorkerSeq and resolves null when a newer worker
request has overtaken it, but that is a cheap early-out, not the guard: a
reorder that supersedes an in-flight worker sort without using the worker
never bumps _sortWorkerSeq at all. Ungrouping the last group level is exactly
that case — its effective sort state is empty, so grouping passes no
workerSortState and the reorder takes the rAF path — and letting the worker
store its own result there overwrote the newly-applied plain sort with the
stale grouped ordering. The generation check then skipped only the re-render,
so nothing repainted and the wrong order stayed invisible until the next
scroll. sortColumns({ deferToReload: true }), which bumps the generation and
returns without reordering, is covered by the same rule.
The worker path is skipped — and the in-thread deferred path used instead — in the following cases:
sorting.useWorkerisfalsesorting.workerThresholdis set tofalse/Infinity/ a negative numbersorting.compareValueswas overridden with a custom function (functions cannot be serialised across the worker boundary)Workeris unavailable in the runtime
The worker source is a small self-contained string embedded in
sorting-worker.feature.js; it is materialised through a Blob URL so no
extra file or network request is needed. As described above, a single worker
instance is reused across sort clicks for the lifetime of the grid, along
with its per-column cache.
7.4 Sorting by alternative or composite fields (sortFields, sortFieldTypes)
By default, clicking a column header sorts by the value returned by column._getValue(row), which follows column.field / column.key (including dotted paths like address.city).
Some columns — most commonly template columns whose renderCell assembles a display value from multiple sub-fields — need sorting to use a different field (or several fields) from the row data rather than the column's own key. The sortFields and sortFieldTypes column properties address this:
column.sortFields — string[]
An ordered array of dot-notation field paths to sort by instead of column.field. Each path is compiled into a precompiled accessor (_sortGetters) by setColumns(), following the same _compileColumnAccessor pattern used for column._getValue.
column.sortFieldTypes — string[] (optional)
Parallel array of type hints ('string', 'number', 'date', 'datetime', 'time') for each sortFields entry. Defaults to 'string' for any omitted index. These are passed to compareValues so the default comparator applies the correct ordering semantics.
Semantics
When a column has sortFields, the comparator treats the fields as a priority chain:
- Compare rows by
sortFields[0]first. - If equal, compare by
sortFields[1]. - Continue through the array; the first non-zero comparison determines row order.
- Only if all fields are equal do rows remain tied.
This implements tiebreaker semantics within a single column click — for example, clicking an "Employee" column with sortFields: ['Employee.FirstName', 'Employee.LastName'] sorts primarily by first name, breaking ties alphabetically by last name.
Worker path expansion
When sortFields is present, _sortViaWorker() expands one sort-chain entry into one payload entry per field before postMessage. Each payload entry carries its own values array, type, and multiplier. The worker processes them in order, providing the same tiebreaker semantics off-thread without any change to the worker source itself.
OData $orderby expansion
For server-side sorting, ODataDataManager.handleSort() expands sortFields into multiple comma-separated $orderby clauses, one per field path. Dot notation is converted to OData slash notation (Employee.FirstName → Employee/FirstName). For example:
$orderby=Employee/FirstName asc, Employee/LastName asc
Columns without sortFields continue to generate a single clause from their key.
API vs declarative usage
Via JavaScript:
column.sortFields = ['Employee.FirstName', 'Employee.LastName'];
column.sortFieldTypes = ['string', 'string']; // optional
grid.setColumns(columns);
Via <vn-grid-column> attributes (parsed by vanilla-grid-element.js):
<vn-grid-column
field="EmployeeID"
header="Employee"
sort-fields="Employee.FirstName,Employee.LastName"
sort-field-types="string,string">
</vn-grid-column>
8. Server-Sort Mode
When sorting.serverSide is true:
- Sort state still updates in-grid.
onSortcallback still fires with full state snapshot.- Header indicators are refreshed.
- Local row order is not changed by
sortColumns(...).
Re-fetching on sort (auto-reload — recommended)
For a data manager that exposes handleSort() (e.g. ODataDataManager,
GraphQLDataManager), the grid resolves onSort to that method
(vanilla-grid-element.js). handleSort() updates the manager's sort clause
($orderby / GraphQL sort value) and then fires _fireConfigChanged('handleSort')
— sort is treated as a query mutation just like setFilter / setSearchTerm.
So the recommended host flow is declarative: enable auto-reload once, and a sort re-fetches page 0 automatically — no per-sort reload callback:
gridElement.setAutoReloadOnConfigChange(true);
// header click → grid onSort → dm.handleSort() → config-changed
// → grid re-fetches page 0 with the new ordering (coalesced, last-wins)
Any onSortChanged callback on the manager still fires, but it is now a
notification hook (logging, status text) — it does not need to trigger the
reload. See 03-data-manager-implementation.md.
Manual host flow (explicit control)
When auto-reload is off (the default), the host owns the reload:
- User triggers sort.
- Grid fires
onSort(...)(→DataManager.handleSort()when present). - Host fetches sorted data from the backend (e.g. from an
onSortChangedcallback, or its ownonSort). - Host calls
setRows(sortedRows)(orloadRowsAsync()/reloadDataManager()).
setRows(...) and appendRows(...) both skip local re-sort in server-sort mode.
9. Rendering and Header Indicators
Header sort classes are re-derived in renderHeader() from sortColumnsState:
vn-grid-sortedvn-grid-sort-ascorvn-grid-sort-desc
The glyphs themselves are pure CSS: the .vn-grid-sort-indicator span is
empty, and its ::before content comes from required per-theme tokens
(--vn-grid-sort-icon-asc/-desc/-sortable and the sortable-opacity pair —
see 15-themes-implementation.md §4.5.1). The base stylesheet ships no default
glyphs; themes may also declare an unsorted "sortable" affordance, always
visible or hover-only (the Carbon themes use a hover-only ⇅).
Tooltip text also includes sort priority metadata like [Sort #N ASC|DESC].
After local sort actions, refreshHeaderLayout() rebuilds headers and rows are re-rendered through virtualization (renderVisibleRows(true)).
A temporary pointer-events toggle is applied to header table after sort to avoid sticky hover artifacts after DOM replacement.
10. Interaction with Row/Data Updates
10.1 setRows(rows)
- Reapplies active sort in client mode (
applySorting()). - Uses original row order directly in server mode.
- Calls
bumpDatasetVersion(), which both invalidates every per-column entry in the sort Worker's key cache and supersedes any reorder still in flight (see § 7.3, "Row-data axis"). The synchronous re-sort above is the answer that stands; a deferred reorder computed against the replaced rows is discarded, and the shimmer it raised is cleared.
10.2 appendRows(newRows)
- Appends rows.
- Reapplies active sort in client mode.
- Keeps incoming order in server mode.
- Calls
bumpDatasetVersion()for the same two reasons. It pushes ontothis.rowsin place, so a stale reply's indices all stay in range — it would corrupt by length rather than with foreign rows, silently dropping the appended page from the viewport until the next reorder. Reachable under infinite scroll, where a page can land while a Worker sort of the already-loaded rows is running.
This ensures sorting remains consistent as data changes.
10.3 setColumns(columns)
setColumns() recompiles every column's accessor, so a new definition can
change how a column orders — its field/valueGetter, type, nulls,
sourceFormat/inputPattern, booleanCoerce, sortFields/sortFieldTypes.
When a column the active client ordering reads changes one of those, the rows
already in displayRows are in the order the old definition produced, so
setColumns() re-applies the ordering:
- Ordering columns are every applied group level's column and — unless
serverSort— every column in the user sort state. - Detection compares each ordering column's
VanillaGridSortingFeature#orderingSignature(column)with the one recorded at the end of the previoussetColumns()(a column that was not declared then counts as changed). With the built-in comparator the signature covers exactly the properties above (ORDERING_COLUMN_PROPERTIES). With a hostcompareValuesit covers every own enumerable property except_-prefixed internal caches andDISPLAY_ONLY_COLUMN_PROPERTIES:label,secondaryLabel,header,width,minWidth,maxWidth,resizable,frozen,hidden,renderCell,formatOptions,timeZone,highlightSearchMatches,sortable,filterable,filterFields,filterType,filterOperators,defaultFilterOperator,filterValueGetter. Plain objects and arrays compare by value (a fresh but equalenumOrderarray is not a change); functions and other objects by identity — so a host that re-creates an inlinevalueGetteron every render pays a re-sort each time. A Node test fails when a property is added toVanillaGridColumnDefwithout being classified on one side or the other. - The reorder is
_reapplyClientOrdering()— the same effective-sort / plain-sort choice andrunReorderPipeline()bracketsetRows()uses (shimmer, Worker offload, supersede token). Itsfinishrebuilds the grouping projection and re-lays the pool, but keeps the scroll position, as a sort does. - Nothing else changes: the sort/group state is the same, so no
vn-grid-sort-changed/vn-grid-group-changedevent fires,onSortis not called and nothing new is persisted. - An identical re-declaration — which the element makes for late
<vn-grid-column>children,initializeGrid()andclearPersistedSettings()— has equal signatures and pays no reorder and no rebuild. UnderparkSortAndGroupStatethe reorder is skipped because the coordinatedsetRows()that follows orders the rows once anyway.
Why a signature instead of the Worker's per-column cache key (§ 7.3): that key
includes the compiled accessor's identity, which setColumns() renews on every
call, so it cannot tell "ordering changed" from "same declaration re-applied";
and it omits nulls / sourceFormat / inputPattern, which do not change the
Worker's bytes but do change the order.
11. Interaction with Column Reordering
When columns are reordered, reorderColumn(...) remaps sort entries by column key:
- Capture old sort chain as
{ key, direction }. - Reorder
this.columns. - Resolve new indices by key.
- Rebuild
sortColumnsStateandsortState.
Result: sort semantics stay attached to logical columns, not stale indices.
The same remap runs whenever the visible column set changes, including when row grouping removes an applied group level's column from it. That is a genuine hazard: remapSortStateAfterColumnReorder() filters out every entry whose column it cannot find in the new array, so a sorted column leaving the array would silently delete the user's sort, with nothing to bring it back on ungroup. (This is exactly why hideColumn() refuses a sorted column in the first place.)
Grouping avoids it by transferring the sort instead — see § 11.1.
11.1 Sort transfer when a column becomes a group level
Grouping a column that carries a user sort moves that sort onto the group level, before the column leaves the visible array:
- the entry's direction seeds the new group level's
direction, unless the caller passed an explicit'asc'/'desc'togroupByColumn()/addGroupLevel()/setGroupState(), which always wins; - the entry is removed from
sortColumnsStatethroughsortColumns(remaining, { deferToReload: true }), so the removal composes exactly like any other sort mutation:onSortfires (and with itvn-grid-sort-changed, keeping a host that mirrors sort state consistent), the header indicators refresh, and the state persists.deferToReloadsuppresses only the client-side re-sort + re-render, because grouping's own effective sort runs over the same rows immediately afterwards; - the visible row order does not change, because the effective sort already leads with the group fields (§ 7 and Row Grouping Implementation § 6);
- the header sort indicator leaves with the header; the direction stays visible and adjustable on that level's chip in the group bar.
Ungrouping does not restore the sort entry — the chip is where that direction lives now. The transfer applies only to levels that are new in a given call (re-issuing an existing level never re-seeds it), and only while the grouping is actually applied: a suspended grouping keeps its column and its header sort indicator in place, so there is nothing to transfer away from.
sortRowsByState() accepts a direct column reference. An applied group level's column is no longer in the visible columns array a columnIndex indexes into, so there is no index left to name it by. Both the precomputed-key path (_buildSortKeyColumns) and the pairwise path resolve sortEntry.column || columns[sortEntry.columnIndex]. Only grouping uses the column form; the user's own entries keep their columnIndex, since they are by construction visible columns.
12. Web Component Integration
In <vn-grid> initialization (vanilla-grid-element.js):
onSortis resolved from:- explicit grid option
onSort, or DataManager.handleSort(...)when available
- explicit grid option
This allows declarative component usage to participate in server-sort workflows without custom wiring in every consumer.
When the resolved handler is a DataManager.handleSort(), that method fires a
config-change after updating the sort clause, so a grid with
setAutoReloadOnConfigChange(true) re-fetches page 0 automatically (see §8).
12.1 Declarative initial sort (sort-by / sorting.columns)
A grid can come up sorted without any JavaScript. This is distinct from
sort-fields (§7.4), which declares which fields a column sorts by, not that
it starts sorted.
<vn-grid sort-by="lastName:asc, salary:desc"></vn-grid>
grid.initializeGrid({
sorting: { columns: [{ key: 'lastName', direction: 'asc' },
{ key: 'salary', direction: 'desc' }] },
});
Both produce exactly what sortColumns([...]) produces — this is an input
path, not a new capability.
Grammar. Identical to group-by's (see
Grouping §15), parsed by the same
_parseKeyDirectionList() helper on the element:
sort-by := entry ("," entry)*
entry := key ":" direction
direction := "asc" | "desc" (case-insensitive)
Entry order is meaning — sort-chain order, primary column first, matching
sortColumns()'s own array semantics. The direction is required; an entry
without one, or with a token that is neither asc nor desc, is skipped with
a logger.warn naming the attribute and the offending entry, while the
attribute's remaining entries still apply.
Key-based, not index-based. sorting.columns and sort-by take
{ key, direction } — markup cannot know column indexes — whereas the runtime
sortColumn() / sortColumns() APIs (§5) take { columnIndex, direction }.
The two shapes are deliberately different and are declared as two distinct
types (VanillaGridGroupEntry vs VanillaGridSortEntry).
Where it is applied. The declarative request is parked in _initSorting()
as _pendingSortColumns and resolved in setColumns(), in the same block that
resolves a persisted sort — both are key-based, so both go through one
key→index resolution (normalized keys: trim + case-fold, so sort-by="LASTNAME:asc"
resolves, matching grouping's long-standing behavior) and one
sortColumns(resolved, { preserveScroll: true }) call under
_isRestoringPersistedState. That flag suppresses persistence writes only —
onSort and vn-grid-sort-changed still fire, so a server-side manager has its
order clause before page 0 is fetched and the first page comes back ordered
(no extra round trip), while an authoring-time default never becomes sticky
persisted state.
Precedence. Persisted sort > sorting.columns option > sort-by
attribute. The attribute collapses into the option at the element, so the core
sees one source.
Read once. sort-by is read at initializeGrid() time only; changing the
attribute afterwards does nothing (the same contract group-by,
selection-mode and row-key-field follow). Use sortColumns() / clearSort()
on the live grid.
Order relative to grouping. The sort is applied first, the grouping second,
which is the correct order: setGroupState() re-derives the effective sort so
the group fields lead. group-by="country:asc" sort-by="salary:desc" therefore
yields country groups with salary descending inside each, with no
coordination code.
Entries that are skipped
- Unknown key — no column matches; skipped with a warning. (This also
covers the persisted path, which used to
.filter()unresolved keys out silently.) - Non-sortable column — the column is
sortable: false, or the whole grid issorting.enabled: false. These are one gate at two scopes, not two kinds of switch: every consumer readsthis.sortable && column.sortable !== falsein a single expression, and the data pipeline (sortColumns(),sanitizeSortColumns(),sortRowsByState()) consults neither. A sort the user cannot perform is not one the grid declares: with no sort-indicator element rendered, no Sort/Clear menu item and a click handler that returns early, the rows would be reordered with no arrow, no menu entry and no click that clears it. The check sits at the resolution point insetColumns(), so it covers the persisted source too — a column that was sortable in an earlier release and issortable: falsenow no longer leaves returning users with a stuck, invisible, unclearable sort restored from storage. - Hidden column —
sortColumns()indexes the visible column array, so asort-byentry naming a hidden column is inert (and warned).group-byon a hidden column resolves against_allColumnsand works; the asymmetry is long-standing.
To fix the order of the data in a way the user cannot change, order it in the
data layer instead — ODataDataManager's defaultOrderBy / query.$orderby,
GraphQLDataManager's sort variable, or the rows handed to
StaticDataManager. sort-by is for the sort the user could have performed
themselves: the initial position of a control they own.
A declared sort is a default, not a lock
sort-by / sorting.columns say how the grid opens until the user decides
otherwise. Once the user sorts, their persisted choice wins on every later
visit — including when that choice is "no sort at all": clearing the sort
persists [], and _loadPersistedState() tests key existence
(hasPersistedSort()) rather than array length, so the empty array parks as
[] and wins the precedence ||. clearPersistedSettings() re-parks the
retained _declarativeSortColumns and restores the declaration. See
Local Storage Settings §6.6.
Toolbar note. hasFilterOrSort drives the toolbar's clear command, so a
declaratively sorted or grouped grid comes up with that command enabled at
first paint. That is correct — there is state to clear — but it is a visible
change for any page that adopts the attributes.
13. Example Configuration
const grid = new VanillaGrid({
sorting: {
enabled: true,
serverSide: false,
shimmerThreshold: 5000, // default; set to 0 to always shimmer, false to disable
useWorker: true, // default; offload >= workerThreshold rows to a Web Worker
workerThreshold: 50000, // default; set to false / Infinity / negative to disable
compareValues: (a, b, column) => {
if (column.key === 'name') {
return String(a || '').localeCompare(String(b || ''), 'en', { sensitivity: 'base' });
}
return (a ?? '').toString().localeCompare((b ?? '').toString(), 'en', { sensitivity: 'base' });
},
onSort: (column, direction, state) => {
console.log('Sort changed', column?.key, direction, state.sortColumns);
}
}
});
For backend-driven sorting:
sorting: { serverSide: true }
and handle sorting in onSort (or DataManager.handleSort) followed by setRows(...).