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:

Relevant options:

Per-column sort redirection:

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:

  1. sortColumnsState: Array<{ columnIndex, direction }>
    • Full ordered sort chain for multi-sort
  2. sortState: { columnIndex, direction }
    • Legacy primary sort snapshot (first chain entry)

getSortState() returns a richer snapshot with:


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:

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:

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:

  1. Ignore click if a drag reorder just happened (_didColumnDrag guard).
  2. Ignore click on .vn-grid-col-resizer.
  3. Validate sorting is enabled and index is valid.
  4. Resolve current direction for that column from sortColumnsState.
  5. Compute next direction with getNextSortDirection(...).
  6. Call sortColumn(columnIndex, newDirection, { multi: e.shiftKey }).

Direction cycle logic:


5. Programmatic Sort APIs

Primary methods:

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:

5.2 sortColumns(...)

Central state transition entry point:

  1. Sanitizes input via sanitizeSortColumns(...).
  2. Updates sortColumnsState and legacy sortState.
  3. Computes snapshot via getSortState().
  4. Fires onSort(column, direction, sortState) if provided.
  5. Branches by mode:
    • serverSort: true → only refresh header indicators
    • options.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 own setLoading(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:

This keeps state robust when called with external data.


7. Client-Side Sort Engine

applySorting() handles local ordering.

Behavior:

  1. If no sort chain: displayRows becomes direct reference to rows.

  2. 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-comparison Date parsing, parseFloat, String() allocation, or locale lookup inside the O(n log n) loop. displayRows is 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 by tests/node/sorting-precompute.test.js.

  3. 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 from typeof options.sorting.compareValues === 'function' — and not by whether the injected compareValues is callable. That distinction is load-bearing: vanilla-grid.js resolves compareValues to _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 only compareValues — 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.enumOrderexcept 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.

  1. null / undefined are ordered first.
  2. Type-aware comparison based on column.type:
    • 'number': numeric compare via parseFloat.
    • 'boolean': false < true via coerced numeric rank.
    • 'date', 'datetime', 'time': chronological compare via Date.getTime().
  3. 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 µs Intl construction 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():

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 displayRowsfinish({ 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:

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 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:

  1. sortColumns(...) flips isLoading true (painting the shimmer as a side effect — see §7.2) and refreshes the header indicators.
  2. For each entry in the sort chain, _sortViaWorker() computes a per-column signature and compares it against _sortColumnSignatures (keyed by column.key + sort-field index) before extracting anything. A column whose stored signature matches sends values: null ("reuse what you already have") instead of re-running column._getValue(row)/sortGetters and 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 column type and direction multiplier.
  3. The payload is postMessaged to the worker. For any column whose values arrived 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 as values: null reuse their cached keys instead. The worker then computes a Uint32Array of 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 is toLowerCase() code-unit order (not the main thread's Intl.Collator) — unchanged from the previous per- comparison implementation.
  4. On resolve, isLoading flips back false, then the main thread maps rows[indices[i]] into a new displayRows array, rebuilds the virtual pool, and re-renders. If the worker instead rejects (unavailable/failed), isLoading still flips false before falling back to the in-thread applySorting(), 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 stringnumber retype changes the packing chosen inside the Worker, so accessor identity cannot capture it.
column.booleanCoerce same reason: a strictloose 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:

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:

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:

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.sortFieldsstring[] 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.sortFieldTypesstring[] (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:

  1. Compare rows by sortFields[0] first.
  2. If equal, compare by sortFields[1].
  3. Continue through the array; the first non-zero comparison determines row order.
  4. 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.FirstNameEmployee/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:

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:

  1. User triggers sort.
  2. Grid fires onSort(...) (→ DataManager.handleSort() when present).
  3. Host fetches sorted data from the backend (e.g. from an onSortChanged callback, or its own onSort).
  4. Host calls setRows(sortedRows) (or loadRowsAsync() / 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:

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)

10.2 appendRows(newRows)

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:

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:

  1. Capture old sort chain as { key, direction }.
  2. Reorder this.columns.
  3. Resolve new indices by key.
  4. Rebuild sortColumnsState and sortState.

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:

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):

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 onlyonSort 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

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(...).