DataManager Implementation in Vanilla-Grid
This document explains the DataManager architecture in Vanilla-Grid, including the base contract, built-in implementations (ODataDataManager, GraphQLDataManager, StaticDataManager), and how <vn-grid> wires DataManager behavior into grid lifecycle.
1. Architecture Goal
The DataManager layer decouples data access from rendering.
VanillaGridfocuses on virtualization, layout, interaction, and state.- Data retrieval, pagination, sorting transport, and response transformation live in DataManager classes.
<vn-grid>acts as the integration boundary between grid and DataManager.
This keeps data-source specifics out of core rendering logic.
2. Base Contract (data-manager.js)
DataManager defines the expected interface:
fetchRows(context)fetchMoreRows(skip, top, context)getPageSize()buildRequestHeaders(context)transformRows(rows, context)handleSort(column, direction, sortState)— server-side subclasses update their sort clause here and fire_fireConfigChanged()so a grid withsetAutoReloadOnConfigChange(true)re-fetches page 0 on sort (see §6.4, §6.5.4)onRowsLoaded(rows, context)cancel()— abort any in-flight network request (no-op by default; see §5.1)setSearchTerm(term)/getSearchTerm()— free-text search (no-op by default; see §11)onTotalRowCountChangedfield +_fireTotalRowCountChanged(count)protected helper — the push channel for total-row-count state (see §3, §6.3, §7, §8). There is no pull-stylefetchTotalCount()on the base contract; subclasses call_fireTotalRowCountChanged()themselves whenever they determine the total for the current query, typically at the end of their ownfetchRows().
Default behavior is intentionally minimal/safe:
- row fetch methods return empty arrays
- page size defaults to
1000 - headers default to
{ Accept: 'application/json' }
Subclasses override as needed.
3. <vn-grid> Integration Flow
VanillaGridElement.initializeGrid(...) performs DataManager-aware wiring:
- Read attached DataManager (
this._dataManager). - Resolve
onSortcallback:- explicit grid option first, else
dm.handleSort(...)bridge when available.
- If DataManager exists and
infiniteScrollis enabled, auto-wire:onLoadMore = dm.fetchMoreRows(skip, pageSize, context)(unless explicitly provided)
- If
pageSizenot explicitly set, usedm.getPageSize().
Separately, setDataManager() unconditionally wires
dataManager.onTotalRowCountChanged = (count) => grid.setTotalRowCount(count)
— this is passive state sync (not a reload trigger), so it is wired regardless
of whether a DataManager or infiniteScroll is configured, and independent of
setAutoReloadOnConfigChange(). See §8.
Selection and row-key attributes are merged into grid options at this same stage.
4. Context Model
VanillaGridElement._createContext(...) builds a context object passed to DataManager methods, including:
elementgriddataManagerenvironmentlocalemaxRowsinfiniteScrolltheme
Per-call overrides are merged in for operation-specific context.
This context standardizes data-layer decisions without exposing low-level DOM internals.
5. Row Loading Lifecycle
VanillaGridElement.loadRowsAsync(options) with DataManager:
- Guard: throws if no DataManager is set.
- Call
dm.cancel()— abort any previous in-flight fetch (last-wins semantics; see §5.1). - Increment
_loadGenerationcounter — stamp this invocation. await this.ready()— ensures persisted column settings are applied.- Emit lifecycle event:
vn-grid-loading. - Set the loading state (see §5.2) —
setLoading(true)unconditionally; above the row-count threshold, additionally await a two-frame paint checkpoint so the skeleton is guaranteed to paint before fetching. - Fetch rows via
dm.fetchRows(context, options). - Generation check after fetch — if another call started while this was in flight, silently discard the response and return (without clearing the loading state — the newer call owns it now).
- Optional transform via
dm.transformRows(...). - Generation check after transform.
- Clear the loading state (if set by this call) and push rows into grid via
setData(rows). - Optional side-effects via
dm.onRowsLoaded(rows, context). - Emit success event:
vn-grid-loaded. - On failure: if
AbortError(or stale generation), return silently without touching the loading state. Otherwise clear it (if set by this call), emitvn-grid-error, and rethrow.
reload() delegates to loadRowsAsync().
5.1 Last-Wins / Cancel-and-Restart Semantics
When a column header is clicked during an active backend load (shimmer visible), a new loadRowsAsync() call races with the previous one. Without cancellation, both would eventually call setData(), causing a flicker or incorrect final state.
The solution is last-wins: each loadRowsAsync() call:
- Calls
dm.cancel()to abort any in-flight fetch from the previous call. - Stamps the invocation with a monotonically-increasing
_loadGenerationcounter. - After every
await, checks whether the generation still matches the latest value. If not, the response is silently discarded.
The AbortError thrown when the previous fetch is cancelled is caught inside loadRowsAsync() and treated as a no-op — it does not fire the vn-grid-error event and does not re-throw.
The loading state (see §5.2) remains set until the winning fetch completes, preventing a flash of empty content between cancellation and the new result. Only the call whose generation still matches _loadGeneration is ever allowed to clear it — a stale/superseded call (whether it resolves or errors) returns silently without touching it, since a newer, still-in-flight call may own it.
Select-all checkbox: while isLoading is true, the header checkbox is always disabled. This prevents confusing behaviour when the user clicks "select all" while rows are still arriving.
5.2 Automatic Loading State (and the Row-Count-Gated Forced Paint)
search(), setColumnFilter(s), clearColumnFilters(), clearColumnFiltersAndSorting(), and reload() all funnel into loadRowsAsync() (directly, or via reloadDataManager()) — as does the initial load. loadRowsAsync() brackets every load with VanillaGrid.setLoading(true)/setLoading(false) itself — success path, error path (a rejecting fetchRows() never leaves a stuck skeleton), generation-guarded against superseded calls (§5.1). Hosts must not wire setLoading into the manager fetch hooks: onBeforeFetch/onFetchResponse/onFetchError are pure app hooks (status text, raw-Response inspection, error UI) — a hand-rolled setLoading there duplicates the built-in bracketing and lacks its generation guard.
Above a configurable row-count threshold, the skeleton paint is additionally forced before dm.fetchRows() begins, so a slow synchronous scan (e.g. StaticDataManager's Array.filter() pass) never leaves the UI looking frozen with no feedback. Below the threshold the loading state is still set, but the paint is deliberately not forced — a fast same-task load resolves before the browser ever paints the skeleton, so no flicker appears.
- Option:
dataLoading.shimmerThreshold(element-only — read byinitializeGrid(), not the bareVanillaGridconstructor). Same parse/clamp rule assorting.shimmerThreshold(0= always force the paint,false/negative/Infinity= never force it). Default 5000. The threshold gates only the forced paint — the loading state itself is unconditional. - Row-count proxy: the forced paint is gated on
_maxSeenRowCount, a running high-water mark of the largest row countsetData()has ever seen — not the grid's current row count.StaticDataManager's column-filter stage can still scan the full underlying dataset (e.g. applying or clearing a filter over all_rows), so the worst-case blocking cost tracks total dataset size. Using the current (possibly already-filtered) count would fail to cover the worst case:clearColumnFilters()going from a small filtered set back to the full dataset. - Paint guarantee: when forcing, the element awaits two
requestAnimationFrames so the skeleton is guaranteed to paint before a synchronous, blocking scan begins —await-ing a same-tick-resolved promise does not itself guarantee an intervening paint. Mirrors the identical technique already used by the sorting shimmer (sorting.feature.js). - Does not, by itself, keep the page interactive during the scan — see the Web Worker offload below for that.
StaticDataManager two-stage filter→search pipeline
_getFilteredRowsAsync() evaluates constraints as an ordered, independently-memoized pipeline — _rows → [Stage 1: column filters] → _columnFilteredRows → [Stage 2: search] → _filteredRows — rather than in one combined pass over the whole dataset. The point is the invalidation asymmetry: a search-term (or search-fields) edit re-runs only Stage 2 against the cached Stage-1 survivors; it does not re-evaluate the column filters. So searching after a selective filter has narrowed 5,000,000 rows to a few thousand scans only those few thousand — the frequent case (typing a search after filtering) never rebuilds a full-dataset search haystack. Because filter AND search is associative/commutative, the staged result is identical to a single combined pass and independent of the order the two constraints were applied (guarded by tests/node/static-staged-search.test.js).
- Stage 1 (
_getColumnFilteredRowsAsync()) evaluates the active column filters, memoizing the survivor rows in_columnFilteredRowsand their GLOBAL row indices (positions into_rows) in_columnFilteredIndices(a parallelUint32Array, same array position, same element count). With no column filter active,_columnFilteredRowsis_rowsby identity and_columnFilteredIndicesis leftnullrather than materializing the implicit0..n-1— no filter means no survivor subset, so nothing would ever read it. It is therefore non-null exactly when the survivors are a genuine subset; both fields are written together at every assignment site. It uses the Worker above the threshold via_filterViaWorker(false)(a column-only call:includeSearch=false, so no search haystack is built or sent — the Worker's reply indices are already GLOBAL since this call always scans the full_rows), falling back to an in-thread_rows.filter(predicate)otherwise (which builds the index array in the same pass)._survivorSetVersionbumps on each fresh computation.- Incremental narrowing. Column filters compose by cross-column AND, so a model change that only tightens the filter (a column added, or an AND condition added to an existing column) can only shrink the survivor set — the new survivors are a subset of the current
_columnFilteredRows.setColumnFilters()detects this via_isStrictNarrowing(oldModel, newModel)and, when proven, stashes the previous survivors (and their GLOBAL indices) in_narrowBaseRows/_narrowBaseIndicesso Stage 1 rescans just that subset instead of the full_rows(soundness:newSurvivors = { r ∈ base : new(r) }exactly whennew ⇒ old), composing the new GLOBAL indices through the previous ones. The classifier is sound, not complete — a false "not narrowing" merely costs a full rescan (the prior behavior), while a false "narrowing" would drop rows, so it proves only the two real "add a filter" gestures and rejects everything that could widen or change the set (a removed column, an added OR condition, a combinator flip, an operator/value edit, a field/type/booleanCoercechange). Phase 1 takes this base-subset scan in-thread when the base is belowworkerThreshold; a still-large narrowed base falls back to the full-dataset Worker scan (narrowing that too — extracting only the added column's values over the subset — is a later phase)._narrowBaseRows/_narrowBaseIndicesare single-use (consumed by the next Stage-1 scan) and cleared bysetRows()/setFilterValueGetters().
- Incremental narrowing. Column filters compose by cross-column AND, so a model change that only tightens the filter (a column added, or an AND condition added to an existing column) can only shrink the survivor set — the new survivors are a subset of the current
- Stage 2 searches the Stage-1 survivors, Worker-gated on the size it actually scans (
_isFilterWorkerEligible(rowCount)takes the count to test):- No column filter → survivors are the full dataset, so the search routes through the full-dataset Worker path (
_filterViaWorker(true)) exactly as before an in-thread scan of a multi-million-row set would block. - Column filter active, survivors ≥
workerThreshold→ a survivor-subset Worker search (_filterViaWorker(true, survivors, _columnFilteredIndices)): search-only, over just the survivor rows. Index-based when the resident full-dataset haystack already covers the current domain (post-prewarm or post-full-search): ships onlysurvivorIndices(aUint32Arrayof GLOBAL positions, ~4 bytes/row) and the Worker slices survivor haystacks from its resident full copy by index — no repack, and the full-dataset haystack (and any prewarmed investment) is never evicted. Falls back to packing a positional survivor haystack (exactly as before this feature) when the full haystack isn't yet resident for the current domain — no regression for hosts that never prewarm. Either way these search-only messages sendgcColumns: falseso they do not evict Stage 1's resident column values. See "Worker-resident haystacks: index-based subset search" below. - Column filter active, survivors <
workerThreshold→ searched in-thread (_searchRowsInThread()): a selective filter's small survivor set searches faster in-thread than a Worker round trip.
- No column filter → survivors are the full dataset, so the search routes through the full-dataset Worker path (
- Fused round trip. When BOTH stages need the Worker together — the column-filter model just changed (no Stage-1 memo yet) AND a search term is active, with no narrowing base to preserve —
_getFilteredRowsAsync()dispatches_filterAndSearchViaWorker()instead of the two sequential calls above: one message evaluates columns over the full dataset, retains the resulting GLOBAL survivor indices (tagged for reuse by a later index-based subset call), then applies the needle over just those survivors using the same haystack, and replies with both index sets (survivorIndicesfor the Stage-1 memo,matchIndicesfor the final result). Falls back to the sequential path on Worker failure or when not fusable (e.g. a narrowing base is available, or Stage 1 is already memoized so only Stage 2 needs to run). - Invalidation:
setSearchTerm/setSearchFieldsnull only_filteredRows(Stage 2), preserving the Stage-1 cache;setColumnFilters/setColumnFilter/clearColumnFilters,setFilterValueGetters, andsetRowsnull_columnFilteredRows/_columnFilteredIndicestoo (Stage 1 depends on the model, the getters, and the dataset) — except that asetColumnFilterschange proven to only tighten the model preserves the prior survivors (and indices) as the next scan's base (see Incremental narrowing above), rather than discarding them for a full rescan.
In-thread search haystack cache (gated). _searchRowsInThread() can match each row against a cached lowercased haystack string (_ensureInThreadHaystackCache() — a WeakMap keyed by row object, mirroring the Worker path's resident haystacks but for the below-threshold in-thread path). The U+0001 field separator baked into _buildHaystack() means a haystack.includes(needle) hit can never span two fields, so a cached match is identical to the per-field _rowMatches() it replaces. Keyed by row object, one cache serves both the full-dataset and any survivor-subset search and survives across search-term keystrokes and column-filter changes (a row's haystack depends only on its own content and the searched fields).
This cache is a reuse optimization, not a free win — so it is gated by a reuse counter (IN_THREAD_HAYSTACK_CACHE_AFTER). Building it is a full up-front pass: it lowercases every searched field of every row (~91 ms for a 50k-row, 15-field dataset), more work than the un-cached lazy scan, which early-exits at the first matching field (~26 ms) and is the faster choice for a one-shot search. Every subsequent search over the same rows is then ~2.2× faster (~11.6 ms vs ~25.7 ms warm at 50k) with near-zero GC (vs ~140 minor GCs / 200 searches). Because the build only amortizes once several searches share a data load, _searchRowsInThread() stays on the lazy early-exit _rowMatches() path for the first IN_THREAD_HAYSTACK_CACHE_AFTER searches over a given (datasetVersion, searchFields) signature, and only builds + reuses the cache from that search on — so the first/one-shot search never pays the build, while a sustained interactive session still gets the fast cached path. The signature is tracked in _inThreadSearchSignature/_inThreadSearchCount; a setRows()/setSearchFields() change flips it, resetting both the counter and the cache (no setter has to clear it explicitly). The whole mechanism only applies below workerThreshold — above it the Worker owns extraction.
StaticDataManager Web Worker offload for filter/search
Above dataLoading.workerThreshold rows (forwarded to the attached StaticDataManager via its setWorkerOptions() method), StaticDataManager's filter/search stages (§ pipeline above) evaluate in a persistent Web Worker (StaticDataManager._workerSource) rather than a disposable one created fresh per call. Replies with a Uint32Array of matching row indices, reconstructed into row objects on the main thread. Falls back to the in-thread path (_getFilteredRowsSync() — the combined search+column reference implementation, also the worker-parity oracle in tests/node/data-loading-worker.test.js) when the runtime lacks Worker/Blob support, useWorker is disabled, or the row count is below the threshold — filtering results are identical either way.
Extraction is cached, not redone on every call. A naive "extract everything, ship it, discard it" design would re-scan the whole dataset and re-clone the result on every search keystroke or filter-value tweak — the actual scan itself was measured at ~31 seconds for a 5,000,000-row, 21-column dataset with no searchFields configured, and cloning that scan's result once was enough to hit an out-of-memory DataCloneError in isolation. Instead, _filterViaWorker() computes two independent signatures — _computeHaystackSignature(): (datasetVersion, searchFields); _computeColumnSignatures(): a Map<columnKey, signature>, one entry per active filter column — each independent of the search needle or filter condition values — and only (re)extracts and (re)sends haystacks/column values when the relevant signature changes. The two are tracked separately, not as one combined signature, because they're needed on unrelated schedules (a search-term-only edit never touches column shape and vice versa) and have wildly different per-row costs (see the chunking paragraph below):
datasetVersionis bumped only bysetRows(). A search-term-only change (typing) or a filter-value-only change (adjusting an existing filter's operator/value) never changes either signature, so the Worker's already-resident data from the previous call is reused; only the tiny needle/condition payload is sent.- Haystacks are gated on more than just the signature — they also require an active search term.
needsHaystack = hasSearch && haystackSignature !== this._workerHaystackSignature. A pure column-filter call with no search term (hasSearch === false) never builds or sends haystacks at all, regardless of whether the signature changed — building a multi-field haystack for every row costs roughly two orders of magnitude more per row than a column read, and the resulting payload'spostMessage()clone cost (measured ~215ms for a 250,000-row, 21-column dataset) is itself a synchronous, un-chunkable main-thread stall, so paying it for a call that will never search the result would defeat the offload's purpose. The worker source already tolerates this:needle !== nullis checked beforehaystackis dereferenced, so a column-only message withhaystack: nullnever touches it. - Column payloads are tracked and resent PER COLUMN, not as one all-or-nothing set. The Worker's own column cache is per key (
workerState.cols[key], reused whenever a message sendspacked: nullfor that column), so_filterViaWorker()re-extracts only the columns whose signature actually changed. Adding a third filter column therefore ships one column's values, not three — with a single combined signature it re-extracted and re-cloned every active column's dataset-sized payload on every add/remove, which for boxed (string/boolean/fan-out) columns is the expensive one-object-per-row clone shape. Residency is recorded by adopting the whole new Map after each non-subset call, which also prunes the columns the Worker just garbage-collected (gcColumns: true), so removing and re-adding a filter column correctly re-sends it instead of claiming residency the Worker dropped. - A column's signature covers every input that determines its packed representation — exactly the arguments of
_packColumnValuesChunked(rows, type, getter, fields, fanOut, forceBoxed):datasetVersion, thefields(or the getter's identity, not merely "has a getter" — see_getterToken()), the columntype, andforceBoxed(whether any condition uses a type-agnostic unary operator). The last three matter because they are derived from the filter conditions and the getters while condition values deliberately invalidate nothing: omitting them let a numeric-packed (Float64Array) column survive a switch toisEmptyand evaluate againstNaNs — empty values becameNaN, which can never satisfyv === null || v === undefined || v === ''— silently dropping rows with no error; likewise a swappedfilterValueGetterwould answer with values computed by the previous getter. Everything else about a condition (operator identity beyond that flag, value, valueTo, combinator) is still correctly excluded, so ordinary filter-value tweaks remain free. - The Worker persists this resident state (
workerState) for its own lifetime, keyed bydatasetVersion; a signature change forces a resend of that signature's payload, which the Worker treats as authoritative and replaces its cache with. - If a message claims a column's values (or a survivor-index tag) are already cached but the Worker has no entry for it (e.g. the Worker was silently recreated after an earlier error, desyncing it from the main thread's bookkeeping), the Worker throws rather than silently filtering against missing data — the caller's existing try/catch falls back to the in-thread path for that call and forces a full resend next time (
_workerHaystackSignature/_workerSubsetHaystackSignature/_workerSurvivorIndicesTagreset tonull,_workerColumnSignaturescleared).
Worker-resident haystacks: index-based subset search. The Worker holds THREE independently-resident slots, not one, so a survivor-subset search never evicts (or is evicted by) the full-dataset haystack:
haystack— the FULL-dataset haystack. The durable assetprewarmSearchIndex()invests in; resident across filter changes and survivor-subset searches alike, tracked by_workerHaystackSignature((datasetVersion, searchFields)).haystackSubset— a POSITIONALLY-packed survivor-subset haystack, used only as the fallback whenhaystackisn't yet resident for the current domain (no prewarm, no prior full-dataset search) — tracked by_workerSubsetHaystackSignature(folds in_survivorSetVersion). Exactly today's pre-this-feature behavior for hosts that never prewarm — no regression.survivorIndices/a tag — GLOBAL row indices into the residenthaystack, used by the index-based path oncehaystackis resident: the subset search ships{ needle, survivorIndices }(no haystack payload) and the Worker slices survivor haystacks from the full copy by index. Tracked by_workerSurvivorIndicesTag— deliberately keyed on(datasetVersion, _survivorSetVersion)only, notsearchFields: which rows survived the column filter doesn't depend on which fields are searched, so asetSearchFields()change doesn't invalidate the (unchanged) survivor indices, only the full haystack they're sliced from.
Before this feature, subset searches reused the single haystack slot positionally, which meant every filter change re-extracted the survivor haystacks from scratch (repacking strings the Worker might already hold resident inside its full copy) and a survivor-subset search evicted the full-dataset haystack — clearing the filter while a search stayed active then re-extracted the whole dataset again, undoing whatever prewarmSearchIndex() had invested. With three independent slots, a filter tweak with an active search now ships only the needle (or the tiny index list) once the full haystack is resident, and clearing filters with a search still active is a needle-only cache hit.
The payload itself is compact, not a boxed array per row. Search haystacks are packed into a handful of moderately-sized "chunk blob" strings (one per FILTER_EXTRACTION_CHUNK_SIZE-row batch, e.g. ~2,500 for 5,000,000 rows) plus two Uint32Arrays of per-row start offsets/lengths within their chunk (_packHaystacksChunked()), instead of an Array<string> with one boxed string object per row — postMessage()'s structured-clone cost is dominated by per-object bookkeeping, not raw byte count, so a multi-million-entry Array<string> clones far worse than a few thousand larger strings plus two transferable typed arrays. Deliberately an array of chunk blobs rather than one single joined string for the whole dataset: joining a 5,000,000-row, 21-column dataset's combined haystack content into one string was measured to throw RangeError: Invalid string length (V8's single-string size ceiling), even though the identical content split across chunk blobs builds and clones without issue. Column filter values for number/uid/date/datetime/time types (the common, non-fan-out case) are similarly packed as a transferable Float64Array (_packColumnValuesChunked()) rather than a boxed Array<number> — except when a condition uses a type-agnostic unary operator (isEmpty/isNotEmpty/isTrue/isFalse), which tests the raw value's null/undefined/'' identity; those columns keep the boxed representation so an originally-empty value doesn't get silently converted to NaN and misread as non-empty. String/boolean columns and genuine multi-field fan-outs keep the original boxed-array shape (lower priority to compact, since the caching above already means they're only sent once per signature change, not once per keystroke).
The one unavoidable (re)extraction pass is chunked, not one synchronous sweep — at two different granularities. When the signature does change, _packHaystacksChunked()/_packColumnValuesChunked() process the dataset in batches, yielding to the event loop (_yieldToMainThread()) between batches — so even a first-ever search/filter over a multi-million-row dataset keeps the page responsive throughout, rather than blocking for the pass's whole duration in one synchronous sweep. The two extraction kinds do not share a batch size: building a multi-field search haystack costs roughly two orders of magnitude more per row than reading (or getter-calling) a single column's value, so a batch size sized for one starves the other. FILTER_EXTRACTION_CHUNK_SIZE (2,000 rows — measured to keep a single haystack-building batch under ~7ms on reference hardware, well inside a 60fps frame budget) governs _packHaystacksChunked(); FILTER_COLUMN_CHUNK_SIZE (100,000 rows — the cheap per-row cost keeps even this larger batch under ~6ms) governs _packColumnValuesChunked(). A shared 100,000-row batch size for both was the original (Phase 1) design; it was measured to let a single haystack-building batch run ~365ms synchronously on a 250,000-row dataset — worse than the in-thread (non-worker) filter path it was meant to outperform — which is what motivated the split.
How the pass yields matters as much as how it chunks. _yieldToMainThread() delegates to the shared VanillaGridYield singleton (src/vanilla-grid/main-thread-yield.js) — the one main-thread yield primitive in the component, also used by the sort Worker's per-column extraction pass (features/sorting-worker.feature.js), so both chunked passes schedule identically. StaticDataManager reads the global lazily and falls back to a plain macrotask when it is absent (partial bundle, or a standalone node test that requires only the data manager). The module resolves on the fastest available cooperative-yield channel — scheduler.yield() where the runtime has it, else a module-scoped MessageChannel macrotask, else setTimeout(…, 0) — and deliberately not on requestAnimationFrame. rAF resolves at the next frame boundary, which caps the pass at one batch per frame no matter how short the batch is: a ~5ms batch followed by a ~11ms wait ran the extraction at a ~30% duty cycle, so roughly two thirds of a large warm's wall clock was the main thread sitting idle. Measured on a 300,000-row, 21-field pack: 2.49s wall for 0.75s of work under rAF, versus 1.07s at a ~98% duty cycle through the fast macrotask — the same work, the same batch length, ~2.3× less elapsed time (extrapolating to ~42s → ~18s at 5,000,000 rows). The browser still interleaves rendering and input between batches, so responsiveness is preserved; what is reclaimed is pure idle time. Two smaller consequences of the same choice: a warm now continues in a backgrounded tab (rAF stops firing when a tab is hidden, which used to stall a warm indefinitely), and the fallback avoids the nested-setTimeout 4ms clamp. rAF remains the right primitive for work that must align with a paint, which is why dom-scheduler.js keeps it. Node is excluded from the MessageChannel branch (the module's internal yieldPort() returns null there): node's ports are worker_threads handles that keep the event loop alive and hang the test runner, and unref()ing them would risk exiting with a pack half-finished. The ladder and that guard are covered by tests/node/main-thread-yield.test.js.
Batch size is a row count; a slice is also capped by time. A row count is only a proxy for cost — the same 2,000 rows take ~5ms on a typical row and far longer on a very wide one or one behind an expensive filterValueGetter (the ~365ms slice above is exactly that failure). Both packers therefore end a slice at whichever comes first: the row cap, or SLICE_BUDGET_MS (5ms), tested every SLICE_PROBE_ROWS (256) rows. The budget only ever makes yields more frequent — the row caps remain the upper bound — and costs nothing measurable (~98% duty cycle either way). Measured on a pathological 4,000-row × 300-field dataset: one 116ms blocking slice before, fifteen slices with a 14.1ms worst case after. In _packHaystacksChunked() a budget-triggered yield does not close the current blob: the Worker addresses row i as blobs[Math.floor(i / chunkSize)], so blobs must stay exactly FILTER_EXTRACTION_CHUNK_SIZE rows long — yield points and blob boundaries are deliberately decoupled, and a short blob would silently misaddress every row after it (guarded by tests/node/static-search-warm-latency.test.js). _packColumnValuesChunked() has no such addressing to preserve, so there a budget-triggered yield simply ends the slice early.
- Option:
dataLoading.workerThreshold(element-level, forwarded to the manager) oruseWorker/workerThresholdpassed directly to theStaticDataManagerconstructor. Same parse/clamp rule. Default 50000, matchingsorting.workerThreshold. - Eligibility (
_isFilterWorkerEligible()) is gated on the size of the full underlying dataset (this._rows.length) — simpler than sorting's worker gate, since there is no custom-predicate escape hatch to check:filterValueGettersis a per-row value extractor (runs on the main thread, before the boundary), never an arbitrary whole-row predicate function that would itself need to cross the boundary. The same gate is applied at three sizes:_rows.lengthfor Stage 1 (column filters) and the no-column Stage-2 search, andsurvivors.lengthfor the Stage-2 survivor-subset search (so a small filtered set searches in-thread and only a large one pays the Worker round trip — see the pipeline subsection above). - Not applicable to
ODataDataManager— its filter/search cost is network latency, not a blocking main-thread scan. - Remaining limit: this fixes the repeated-call cost and the main-thread blocking, but a dataset large enough that the raw row objects plus the derived search structures together approach the browser tab's heap ceiling will still be slow (severe GC pressure, not a crash) regardless of chunking — chunking bounds how long the main thread is blocked per slice, not the total memory a search index requires. Verified scaling cleanly (extraction time roughly linear, no memory blowup) up to several million rows on real hardware; datasets that large relative to the browser's per-tab heap limit are a capacity question, not something this fix (or any client-side chunking strategy) can fully remove.
See tests/node/data-loading-worker.test.js and tests/playwright/search-worker-performance.spec.js for the caching/cache-miss/compact-encoding/responsiveness test coverage; tests/node/static-staged-search.test.js for the two-stage pipeline's result-equality and Stage-1-reuse guarantees; tests/node/static-incremental-narrowing.test.js for the strict-narrowing classifier's soundness and the base-subset scan's correctness; tests/node/static-indexed-survivor-search.test.js for the index-based/packed-fallback subset dispatch, the no-eviction (thrashing) guard, the fused round trip, and _columnFilteredIndices invalidation; tests/node/static-column-payload-residency.test.js for per-column payload residency and the packing-determinant signature (type/forceBoxed/getter-identity) regressions; and tests/playwright/staged-search-survivor-worker.spec.js / tests/playwright/staged-search-clear-filters.spec.js for the survivor-subset Worker search and the domain switch on clear over a real browser Worker.
5.3 Lowering client-side memory (large offline datasets)
The remaining limit above is a memory ceiling, not a time one, and it is reached without any cross-origin isolation being available. Two host-controllable levers reduce it on a single thread (no SharedArrayBuffer, no COOP/COEP):
Scope the search with
searchFields. With nosearchFields, both the in-thread haystacks and the Worker's resident haystacks cover every field of every row — a full second lowercased copy of nearly all textual content, plus the one-time build the docs measure at ~31 s for a 5M×21 dataset. Restricting to the handful of columns users actually search cuts that resident-haystack memory and the first-search build time roughly in proportion to the fields dropped (~5–6× for 21 → 3–4). All-fields search on a multi-million-row dataset is the expensive default, not a free convenience. When a search runs over a dataset at/above a fixed 50,000-row advisory threshold (independent ofworkerThreshold— a host that disables the Worker offload still pays the all-fields cost in-thread, which is worse, not better, so the advisory must not go silent just because the offload is off) with nosearchFieldsset,StaticDataManageremits a one-shot-per-datasetconsole.warnadvisory (_maybeWarnLargeAllFieldSearch()); it is silent for small datasets and wheneversearchFieldsis configured. The same advisory also fires whenprewarmSearchIndex()begins a real build (§5.4): with pre-warming the all-fields extraction happens at load rather than on the first keystroke, so a search-only advisory would arrive after the cost was already sunk — or never, for a host whose users don't search. It is emitted below the already-resident fast paths, so a repeated idle warm stays silent, and the one-shot latch keeps a host that both warms and searches to exactly one message per dataset version.grid-minimal-jssetssearchFields(id,firstName,lastName,email,country,city,department,role,manager,notes— 10 of its 21 columns, omitting the numeric/date/boolean columns nobody free-text-searches) and so never triggers it; column filters are unaffected by the scoping — every column stays independently filterable.Intern low-cardinality string columns (
internStringColumns). Columns like department/city/country/role repeat a few dozen distinct values across millions of rows, yet each cell is typically a distinct string instance. PassinginternStringColumns: ['department', 'city', …]deduplicates those columns' values on ingestion (construction andsetRows()) via a transient pool (_internRows()), so every equal cell shares one instance — reclaiming a large amount of memory with no data-model change (rows stay plain objects). Top-level field names only (dotted paths are not supported). By default the manager interns in place (it assumes ownership of the rows it is given); passcopyOnIntern: trueto intern into shallow row copies instead, leaving the caller's objects untouched. Search/filter/sort results are unchanged — interning only affects identity/memory, not content.The "distinct string instance per row" clause above is a precondition, not an aside — a column must satisfy it as well as being low-cardinality, or the pass is pure overhead. It is satisfied when rows come from a parser or from per-row computation:
JSON.parse()allocates a fresh string for every cell it decodes, as does any concatenation/toLowerCase()/template-literal construction of a field. It is not satisfied when a field is assigned straight out of a shared lookup array or from a string literal — V8 stores a pointer, so every row already references one shared (and, for literals, internalized) instance and_internRows()writes back the pointer it just read. Cardinality alone is therefore the wrong selection criterion.This matters for placement:
internStringColumnsexists onStaticDataManagerand not on the server-paged managers precisely becauseStaticDataManageris the one holding an entire dataset resident, and the realistic way a host obtains a dataset that large is a fetch — hencesetRows()interning on every ingestion, not just the constructor.ODataDataManager/GraphQLDataManagerhold roughly one page at a time and re-fetch on scroll, so they have nothing worth deduplicating. "Static" describes where filtering/sorting/paging happen (client-side, over resident rows), not where the rows came from.Measured on 500,000 rows of the
grid-minimal-jsshape (10 string columns), retained heap after forced GC:internStringColumnsRows via JSON.parse()Rows synthesized in-page (none) 207.8 MB 139.4 MB 7 low-cardinality columns ( country,city,department,role,firstName,lastName,active)124.8 MB (−40%, ~270 ms) 139.4 MB (−0%, ~160 ms wasted) manageronly (concatenated, ~572 distinct values)192.7 MB 123.3 MB (−12%) both sets 109.7 MB (−47%) 123.3 MB The two table columns hold byte-identical data and differ only in how the rows were produced, and that alone determines the payoff — note the baseline row already differs by 68 MB, which is the duplication interning has to work with. The in-page column is why
grid-minimal-jsinterns onlymanager: its generator picks every other string field out of a literal array, so those columns are already shared instances (see that app'sinternStringColumnscomment).
Both are opt-in and additive; neither changes the default { rows: [objects] } behavior. Guarded by tests/node/static-intern-and-advisory.test.js. A larger, still-isolation-free columnar/dictionary-encoded backing store (moving the ceiling ~5–10×) is a separate, deferred effort.
5.4 Pre-warming the search index (prewarmSearchIndex())
The first search on a large dataset is slow (§5.2: it pays the one-time haystack extraction) while every later search is instant (the haystacks are resident). That first-search cost lands on the user's first keystroke. StaticDataManager.prewarmSearchIndex() moves that extraction to a moment the host controls, so the first real search is already a cache hit. It is opt-in (never warms by default): warming spends CPU and memory up front, which is wasted on users who never open the search box, so the decision belongs to the host. The host either calls the method itself, or sets prewarmSearchIndex: true and lets the manager schedule it (below) — in both cases nothing warms unless asked.
Behavior. Best-effort and idempotent: resolves immediately when the index is already resident (or the dataset is empty), coalesces concurrent calls for the same domain (
_prewarmPromise/_prewarmSignature), and never rejects — a worker failure just leaves the first real search to build the index lazily, exactly as without pre-warming. It warms the base full-dataset index for the currentsearchFields; a search run while a column filter is active still extracts the survivor subset lazily.Dispatch. Same size gate as search itself. Above
workerThreshold→_prewarmViaWorker(): extract haystacks (_packHaystacksChunked, chunked) and ship them to the persistent Worker with aprimemessage (the worker caches them and acks without searching — see themsg.primeshort-circuit in_workerSource), then mark_workerHaystackSignatureresident. Below it →_prewarmInThread(): eagerly build the per-row haystack WeakMap (chunked/yielding), bypassing the in-thread reuse gate (IN_THREAD_HAYSTACK_CACHE_AFTER) — an explicit host call is a stronger "reuse is coming" signal than the gate's speculative counter.Invalidation & races. Generation-guarded on
_computeHaystackSignature(): asetRows()/setSearchFields()during extraction or in flight discards the warm rather than marking a stale signature resident._prewarmInThread()claims the gate up front so a search racing the build shares the same (partially-populated) cache instead of resetting it. A real search fired while a worker warm is still in flight for the same haystack domain awaits that warm instead of launching its own extraction (_filterViaWorker()awaits_prewarmPromisewhen_prewarmSignaturematches the haystack signature it needs, before decidingneedsHaystack). This matters: both extractions are chunked on the single main thread, so running two at once roughly doubles the wall-clock and the haystack allocation/GC — the pathological "type a search before the ~5 s prewarm finishes" case. Awaiting instead makes the search resolve in ≈(remaining prewarm time) + a needle-only cache hit. (The in-thread path needs no such coordination — a racing search and_prewarmInThread()share the same per-row WeakMap and fill it cooperatively.)A survivor-SUBSET search only waits when waiting is actually cheaper (
_shouldAwaitPrewarmForSubset()). Waiting is right when the two costs are comparable — the subset then resolves as a needle-only hit against the resident full haystack, extracting nothing itself. It is wrong when they are not: a selective filter's survivors can be a small fraction of the dataset, and packing them costs a correspondingly small fraction of the warm the search would otherwise inherit in full. Both sides are the same per-row extraction, so the per-row constant cancels and the decision reduces to a row-count ratio: a subset below1/PREWARM_AWAIT_SUBSET_RATIO(currently 1/8) of the dataset packs its own positional haystack immediately instead of waiting. On 5,000,000 rows that covers any survivor set under 625,000 — a sub-second self-pack against a warm that may still have many seconds to run. A subset with no GLOBAL indices never waits at all, since it could not use the index-based path even once the warm landed. Note Stage 2 already routes survivors belowworkerThresholdin-thread, so this governs only the band "large enough for the Worker, still far smaller than the dataset". Declining to wait is a path choice, never a correctness one — the positional-subset packing it falls through to is the documented no-prewarm path, it writes an independent Worker slot (_workerSubsetHaystackSignature) so it cannot evict the full haystack, and the pre-warm is not token-governed, so it still completes and still serves every later search._filterAndSearchViaWorker()is unaffected: it is always a full-dataset call, so its await stays unconditional.Dev-mode instrumentation. With the global
window.VanillaGridDevMode === true,prewarmSearchIndex()emits twoconsole.infomessages on theVanillaGrid:channel — one when a build starts and one with its elapsed time (_devInfo()/_searchIndexBuildLabel()), each naming the row count, the searched fields and the path (worker/in-thread). Timed withperformance.now()where available. Deliberately emitted only when real work begins, never on the already-resident fast paths, so a "started" line can be trusted to mean the index is being built right now — which is what makes it usable to verify pre-warm timing; a rejected build logs its own outcome so a start line is never left dangling. The flag is opt-in and off by default because the library has no build-time dev/prod split (build.jsminifies but does not stripconsole.*), so a runtime flag is the only honest way to keep the messages out of production. Only the pre-warm is instrumented — an index built lazily by a first search is not. Emitted throughconsole.inforather than the grid logger because managers hold no logger reference (same rationale as thesearchFieldsadvisory in §5.3), inside thetypeof consoleguard that thebuild.jsconsole audit requires.Not applicable to server managers.
ODataDataManager/GraphQLDataManagerhold no local index to warm — they search server-side — so neither the method nor theprewarmSearchIndexoption exists on them.Automatic scheduling (
prewarmSearchIndex: true). Rather than each host re-implementing the samevn-grid-loaded+requestIdleCallbackwiring, the manager can schedule the warm itself. It runs on every row ingestion — construction withrows, and eachsetRows()— via_scheduleSearchIndexPrewarm(), and always at idle:requestIdleCallbackwith a 2,000 ms timeout so a never-idle page still warms,setTimeout(300)whererequestIdleCallbackis unavailable. Idle rather than immediate because the extraction is chunked and yielded on the same main thread as the initial render, so running it eagerly would steal frames from first paint. Re-scheduling is safe: the warm is idempotent and domain-coalesced, so only a genuine data/searchFieldschange costs anything; a pending latch stops back-to-back ingestions from queuing redundant idle callbacks, and an empty dataset schedules nothing. The option is a strict boolean (=== true), so a truthy-but-wrong value can't silently enable it.grid-minimal-jssets it once on the manager it keeps for the page's lifetime, and its row-count selector re-ingests throughsetRows()— so every dataset size it switches to (up to 1,000,000 rows) is re-warmed on these same terms, with no host wiring per switch.The option lives on the manager, not on
<vn-grid>. The index is a StaticDataManager asset — a server-backed manager searches remotely and has nothing to warm — so a grid-level option would be dead config onODataDataManager/GraphQLDataManager, and it would be unavailable to a manager used standalone with no grid attached. It also sits next tosearchFields, which defines what the index covers. (Note this differs fromuseWorker/workerThreshold, which are ALSO StaticDataManager-only yet additionally exposed on the element'sdataLoadingbag and forwarded viasetWorkerOptions()— an older dual-surface shape that this deliberately does not copy.)It is a boolean, not a policy enum: an earlier draft offered
'off' | 'idle' | 'eager', but'eager'(warm immediately, competing with first paint) bought only a few hundred milliseconds of earlier readiness — measured at ~600 ms earlier start on a 200k-row dataset — and was a foot-gun on exactly the small datasets where the word invites use. With'idle'the only sensible non-off value, two states are better expressed as a boolean.
Guarded by tests/node/static-search-prewarm.test.js (in-thread path + the dev-mode messages), tests/node/static-prewarm-search-index-option.test.js (the prewarmSearchIndex option's scheduling), tests/node/static-search-warm-latency.test.js (the subset-await ratio guard and the pre-warm-time advisory), tests/playwright/search-prewarm.spec.js (the real Worker prime path), tests/playwright/search-index-auto-prewarm.spec.js (the option end-to-end over a real Worker) and tests/playwright/search-warm-latency.spec.js (the browser yield primitive's duty cycle, slice length, and mid-warm search parity).
5.5 Cancelling superseded searches (cancel())
VanillaGridElement.loadRowsAsync() calls dm.cancel() before every load and discards stale results with a last-wins generation guard — but that guard runs after await dm.fetchRows(), so it prevents a stale render, not the stale work. For StaticDataManager the expensive work is the O(matches) main-thread result build in _filterViaWorker() (filtered[i] = rows[indices[i]]), which for an intermediate short term (e.g. typing/backspacing through "b" / "a") can be 250k–500k rows — built and then thrown away. Unlike ODataDataManager (which overrides cancel() to abort its fetch()), StaticDataManager has no request to abort; the cost is CPU.
So StaticDataManager overrides cancel() to bump a cooperative cancellation token (_loadToken). Every fetchRows() captures a fresh token (++_loadToken); cancel() — and the next fetchRows() — bump it. The pipeline checks the token at each await boundary and short-circuits with a FETCH_CANCELLED sentinel when superseded:
- The result build is skipped.
_filterViaWorker()checks the token in the Worker reply handler before thenew Array(...)+ copy loop; when stale it returns the sentinel and builds nothing. The Worker's scan itself already ran (FIFO — a posted message can't be un-posted), but the costly main-thread build — the actual cost — is skipped. A supersede is not a failure, so the resident_workerHaystackSignatureis left intact (distinct from the error.catch, which nulls it). - The extraction is aborted (the pre-resident /
setSearchFieldscase):_packHaystacksChunked()takes an optional between-slicesshouldContinuepredicate (() => token === this._loadToken) and returnsnullwhen it goes false, so a superseded first-search stops building a haystack nobody will use. (The pre-warm passes no predicate, so a search token never aborts a pre-warm.) The slice budget (§5.2) adds abort checkpoints inside a blob as well as at blob boundaries — the predicate's contract is unchanged, it is simply consulted at every yield, so a superseded pass now bails out sooner on expensive rows rather than finishing the blob it was in. - No stale memo / no stale count.
_getFilteredRowsAsync()skips the_filteredRows/_columnFilteredRowsmemo write on a stale token (also closing a latent race where two overlapping computations both write the memo), andfetchRows()returns[]without firing_fireTotalRowCountChanged(no stale "Loaded N") when superseded — the element's generation guard discards that[]anyway. FETCH_CANCELLED≠null. Stage 1's worker call already usesnullto mean "worker failed, fall back in-thread"; cancellation is a distinct sentinel so it propagates up (no fallback scan) rather than triggering the in-thread path.- The pre-warm is not token-governed — a search's
cancel()must not abort an in-flight pre-warm (that would leave the index un-resident and force re-extraction). The in-thread below-threshold scan is bounded and runs to completion (not chunked), but its memo write / count-fire are still token-guarded so no stale result escapes.fetchMoreRows()reads (doesn't bump) the token — a new search supersedes a pending load-more, but a load-more never supersedes an in-flight search.
Guarded by tests/node/static-search-cancel.test.js (token mechanics, in-thread) and tests/playwright/search-cancel.spec.js (the Worker-path build-skip: a superseded search builds nothing).
6. ODataDataManager Internals
ODataDataManager extends base contract for OData-compatible endpoints.
6.1 Configuration
Constructor options include:
baseUrl,countUrlpageSizeheaders(function or static object)defaultOrderBy,defaultFilter- hooks:
onSortChanged,onRowsLoaded,onFetchResponse,onBeforeFetch,onFetchError. The fetch hooks are pure app hooks —onBeforeFetch()(e.g. clear a status line) andonFetchResponse(response)(raw-Responseinspection: headers, status) fire once per user-visible load (fetchRows()), never perfetchMoreRows()page;onFetchError(error)(error UI) fires for load-more failures too. None of them is needed for loading state — the grid element brackets every load withsetLoadingitself (§5.2).
6.2 Request construction
_buildUrl(baseUrl, skip, top) serializes OData query options onto the URL. $skip and $top are only appended when non-null; passing null for either omits that parameter (used when loading all rows at once with infinite scroll disabled). Other clauses:
$orderby— from_query.$orderby(set byhandleSort)$filter— from_query.$filter$select— serialized from_query.$select(string or array)$expand— recursively serialized from_query.$expand(object; nested levels use OData V4 parenthesis syntax with;separators)$count— appended astruewhen_query.$count === true
buildRequestHeaders(context) resolves from:
- dynamic header function
- static header object merged with
Accept - default
Accept
6.3 Fetch behaviors
fetchRows(): wheninfiniteScrollis true, fetches page 0 with$top=pageSize. When false andmaxRowsis set, uses$top=maxRows. When false andmaxRowsis null, omits both$topand$skipso the server returns all matching rows in a single response. Each call creates a freshAbortController(stored in_fetchController); its signal is passed tofetch()so the request can be cancelled bycancel(). Also bumps_queryVersionand fires_refreshTotalRowCount(version)(fire-and-forget, concurrently with the row fetch below).fetchMoreRows(skip, top): paged fetch for infinite scroll loading; always sends explicit$skipand$top. Also uses anAbortControllersignal via_fetchController._refreshTotalRowCount(version)(private): fetches the count endpoint, sendstext/plainin the Accept header (required by some OData V4 servers), normalizes the response to a non-negative integer ornull, and calls_fireTotalRowCountChanged()with the result — but only ifversionstill matches_queryVersionwhen the request settles. A superseded request (a laterfetchRows()already bumped the version) is discarded silently, whatever way it resolved (aborted, network error, or a late real response) — it never calls_fireTotalRowCountChangedat all. A non-superseded failure still calls_fireTotalRowCountChanged(null)so the UI reflects "unknown" (the discard-on-supersede behavior specifically targets the stale-request case, not genuine backend errors). Uses its ownAbortController(_countController, separate from_fetchController) since it runs concurrently with the row fetch. A dedicated controller avoids one request's completion clearing the other's cancellation slot; both are aborted bycancel(). Per-manager, per-request versioning (rather than a grid-level generation counter) is what makes two concurrent reloads for the same grid resolve correctly: each reload bumps its own manager's_queryVersionand starts a fresh count request captured at that version, so whichever reload is actually current always gets an honest, un-discarded attempt regardless of which of the two in-flight count requests happens to settle first.cancel(): aborts both theAbortControllercreated for the most recentfetchRows()/fetchMoreRows()call and the one created for an in-flight_refreshTotalRowCount()count request. Safe to call when nothing is in flight (both are null). Called automatically byVanillaGridElement.loadRowsAsync()before each new load. The_queryVersioncheck inside_refreshTotalRowCount()is what makes the abort's aftermath safe — a count that resolves after being superseded is discarded regardless of whether the network request itself was aborted.reset(): delegates tocancel()so any in-flight fetch (rows or count) is aborted before the grid initiates a fresh load viareloadDataManager().
6.4 Sort delegation
handleSort(column, direction, sortState) builds an OData $orderby expression and stores it in _query.$orderby, emits the notification callback onSortChanged(orderBy, column, direction, sortState), and then fires _fireConfigChanged('handleSort') — sort is a query mutation like $filter/$search, so it goes through the same config-changed channel. A grid with setAutoReloadOnConfigChange(true) therefore re-fetches page 0 on sort automatically; onSortChanged is notification-only and no longer needs to trigger the reload.
When a multi-column sort chain is present (sortState.sortColumns), all entries are processed in order. For each entry, if the column has a sortFields array, each path is expanded into a separate $orderby clause with the same direction — enabling composite-field sorting via navigation properties. Dot notation is automatically converted to OData slash notation (Employee.FirstName → Employee/FirstName).
Example result for a column declared with sort-fields="Employee.FirstName,Employee.LastName":
$orderby=Employee/FirstName asc, Employee/LastName asc
Columns without sortFields generate a single clause from their key.
This is the key bridge for server-driven sorting. See Sorting §7.4 for the client-side counterpart.
6.5 GraphQLDataManager Internals
GraphQLDataManager extends the base contract for GraphQL endpoints. Where ODataDataManager can own the whole serialization pipeline (OData is a standardized query grammar, so contains(Name,'x') and Price gt 5 parses identically on every compliant service), GraphQL standardizes only transport and envelope: a POST of { query, operationName?, variables? } returning 200 OK with { data?, errors? } — a GraphQL error is an HTTP 200 with a populated errors array, not a 4xx/5xx. Everything a grid cares about (pagination shape, sort/filter/search argument names, where the rows live in data, whether a total exists) is schema-specific with no cross-server grammar to hardcode.
So the manager owns everything universal — transport, envelope/errors handling, cancellation, last-wins version guarding, total-count push, config-changed events, response unwrapping via a declared dot-path — and the host supplies the query document plus small builder hooks that translate grid state into the schema's variables. This is the extension pattern of §9, shipped as a well-behaved default. No VanillaGrid / <vn-grid> core changes are required — the grid already talks only to the DataManager interface.
6.5.1 Two-tier configuration
- Simple tier (declarative) — the host declares a
querydocument,dataPath(where the rows array lives indata),totalPath/pageInfoPath, and avariableNamesmap ({ skip, top, page, perPage, first, after, search, sort, filter }). The manager assemblesvariablesitself from those mappings plus the grid's current sort/filter/search/pagination state, emitting only the keys whose source value is non-null. - Escape-hatch tier (imperative) —
buildVariables(state)fully owns variable assembly (the declarative mapping is bypassed; its result is merged over the static basevariables, builder wins). The smallerbuildSort/buildFilter/buildSearchhooks each shape one slice. This mirrors how OData exposes both structured setters and a rawsetQuery().
The manager always thinks in grid-native skip/top (matching the fetchMoreRows(skip, top) contract). The state object passed to the builders carries offset conveniences (page = floor(skip/top)+1, perPage = top) and cursor conveniences (after = stored endCursor, first = top) derived from them, so the host maps grid offsets to the schema's real pagination in one honest boundary.
6.5.2 Configuration
Constructor options include: endpoint (required), headers, query (required), operationName, variables; paginationStyle ('offset' | 'cursor'), pageSize, infiniteScroll, maxRows; dataPath, totalPath, pageInfoPath; countQuery, countPath, countVariables; variableNames, caseInsensitive; the builder hooks buildVariables / buildSort / buildFilter / buildSearch; and hooks onSortChanged, onRowsLoaded, onFetchResponse, onBeforeFetch, onFetchError, onGraphQLErrors.
6.5.3 Fetch behaviors
buildRequestHeaders(): resolves dynamic-fn → static-object → default, then forcesContent-Type: application/json(the POST body is always JSON).fetchRows(): guards onendpoint/query(return []), bumps_queryVersion, firesonBeforeFetch, computes the first-pageskip/top(cursor mode also resets_endCursor/_hasNextPage), assemblesvariables, creates a fresh_fetchController, and starts the total-count strategy (_refreshTotalRowCount(version), concurrently). It POSTs (firingonFetchResponseon a successful response —fetchRows()only, neverfetchMoreRows(), matching the OData timing), updates cursor state, pushes the inline total (whentotalPathis set), and returns the unwrapped rows.fetchMoreRows(skip, top): offset mode assembles variables fromskip/top(→page/perPage). Cursor mode ignoresskip(opaque-cursor pattern, exactly like an opaque continue-token manager): returns[]when!_hasNextPage, else sendsafter = _endCursor,first = top, and updates_endCursor/_hasNextPagefrom the response'spageInfo._refreshTotalRowCount(version)(private): three strategies. Inline (totalPathset) — no second request; the value is read from the row response by_pushInlineTotal(). SeparatecountQuery— POSTed concurrently on its own_countController, version-guarded exactly like OData (a superseded result is discarded silently; a non-superseded failure orerrorsarray pushesnull). Neither — pushesnull("unknown"). Count variables come fromcountVariables(object or(state) => object), defaulting to the same assembled variables as the row query.- Envelope handling (
_postGraphQL): a non-AbortErrorfetch failure callsonFetchErrorand rethrows (initial and load-more calls alike);!response.ok(rare for GraphQL — e.g. a 429 rate-limit) throws an HTTP error.onFetchResponsefires only when the caller isfetchRows()(thenotifyResponseparameter). A populatedjson.errorsarray routes toonGraphQLErrors, whose default throws aGraphQLErrorcarrying.graphQLErrors. The partial-data policy is throw-by-default even whendatais present; a hostonGraphQLErrorsthat returns without throwing tolerates the partialdata. - Response unwrapping (
_unwrapRows): reads the node atdataPath; in cursor mode, if that node is a Relay connection ({ edges, pageInfo }) it unwrapsedges[].node, andpageInfois read frompageInfoPathor the connection's sibling. cancel(): aborts both_fetchControllerand_countController(separate controllers, same rationale as §6.3).reset():cancel()+ clears the cursor state (_endCursor/_hasNextPage); sort/filter/search persist (same contract as OData).
6.5.4 Sort / filter / search delegation
handleSort(column, direction, sortState) stores buildSort(column, direction, sortState) in _orderBy (or a default "key direction" string when no buildSort is given; multi-column chains read sortState.sortColumns), emits the notification callback onSortChanged(_orderBy, column, direction, sortState), then fires _fireConfigChanged('handleSort') for auto-reload. setSearchTerm recomputes _search = buildSearch(term) (default term || null); setColumnFilters (and the single-column / clear variants) normalize the model via window.VanillaGridFilterModel and recompute _filter = buildFilter(model, { caseInsensitive }) — without a buildFilter hook there is no universal GraphQL filter grammar, so _filter stays null. setSearchTerm/setColumnFilters fire _fireConfigChanged(...) with no-op change detection; handleSort fires it unconditionally (a header interaction is always a real change).
6.5.5 GraphQLError
GraphQLDataManager.GraphQLError is a small Error subclass carrying .graphQLErrors (the raw array) so hosts / onFetchError can inspect individual messages / paths. Thrown by the default onGraphQLErrors.
7. StaticDataManager Internals
StaticDataManager provides in-memory paging over a fixed array.
State:
_rows_pageSize_useWorker,_workerThreshold— Web Worker offload config for filter/search (see §5.2).
Methods:
fetchRows(context): applies the active search term and column filters (_getFilteredRowsAsync()— in-thread or Web Worker, see §5.2), calls_fireTotalRowCountChanged()with the filtered row count (no staleness/version concern — resolves without a real network round trip even on the worker path), then returns the first page (infinite mode) or all matching rows.fetchMoreRows(skip, top): returns a slice of the filtered set (reuses the memoized_filteredRowscomputed by the precedingfetchRows()call — no second scan/worker dispatch).setRows(rows),getRows(),getPageSize().setSearchTerm(term)/getSearchTerm(),setSearchFields(fields)/getSearchFields()— see §11.setWorkerOptions({ useWorker, workerThreshold })— reconfigure the filter/search worker gate; called automatically byinitializeGrid()fromdataLoading.useWorker/workerThreshold(see §5.2). Only keys present in the argument are applied.prewarmSearchIndex()— build the search index ahead of the user's first search; opt-in, host-driven, best-effort (see §5.4).cancel()— bump the cooperative cancellation token so a superseded fetch pipeline short-circuits (see §5.5). Called automatically byloadRowsAsync().destroy()— terminate the filter/search worker (if one was created) and revoke its Blob URL. Not called automatically; hosts that dispose of a manager explicitly may call it for deterministic cleanup.
No network calls are involved. The full dataset stays in _rows; search + column filters produce a memoized derived array (_filteredRows), invalidated by setRows(), setSearchTerm(), setSearchFields(), setColumnFilters(s)/clearColumnFilters(), and setFilterValueGetters().
8. Sort + Infinite Scroll Composition
DataManager and grid collaborate as follows:
- Grid owns interactive sort state and emits
onSort. - DataManager optionally translates sort state to backend query format.
- Grid infinite loading calls
onLoadMore, which<vn-grid>can auto-map todm.fetchMoreRows(...). - Total-row-count state is pushed, not pulled: the manager calls
_fireTotalRowCountChanged(count)whenever it knows the total for its current query (typically from inside its ownfetchRows());setDataManager()wires this straight intogrid.setTotalRowCount(count).
This creates a consistent flow for both remote and in-memory data sources.
9. Extension Pattern
To implement a custom DataManager:
- Extend
DataManager. - Override at least
fetchRows(...). - Add
fetchMoreRows(...)for infinite mode. - Call
this._fireTotalRowCountChanged(count)fromfetchRows(...)(or wherever the total becomes known) if known totals are needed — see §8. If the total arrives asynchronously via a separate network round trip, guard against a superseded call the wayODataDataManager._queryVersiondoes (§6.3). - Implement
handleSort(...)for server-sort integration — update your sort clause, then callthis._fireConfigChanged('handleSort')so a grid withsetAutoReloadOnConfigChange(true)re-fetches page 0 on sort (mirrorsODataDataManager/GraphQLDataManager). The manager never reloads itself; the reload is the grid's job. - Optionally use
transformRows(...)and lifecycle hooks. - Override
cancel()if your manager makes asynchronous network calls — create anAbortControllerper request, store it, and call.abort()incancel(). This enables last-wins load semantics (see §5.1).
Because the contract is small and context-rich, custom backends can be integrated without changing VanillaGrid internals. GraphQLDataManager (§6.5) is a shipped, well-behaved default implementation of exactly this pattern for GraphQL endpoints: the host supplies the query document and builder hooks; the manager owns transport, envelope/errors handling, cancellation, version-guarded total-count push, and response unwrapping.
10. Example Wiring
const dm = new ODataDataManager({
baseUrl: '/api/items',
countUrl: '/api/items/$count',
pageSize: 500
});
const el = document.getElementById('myGrid');
el.setDataManager(dm);
el.initializeGrid({ infiniteScroll: { enabled: true }, sorting: { serverSide: true } });
await el.loadRowsAsync();
This setup enables:
- server-side sort delegation via DataManager
- infinite page loading via
fetchMoreRows - total row count pushed via
_fireTotalRowCountChangedfrom insidefetchRows(), read back viagridElement.getTotalRowCount()
The GraphQL equivalent supplies a query document plus builder hooks instead of a structured query object (offset paging with an inline total, no separate count request):
const dm = new GraphQLDataManager({
endpoint: 'https://graphql.anilist.co',
query: ANILIST_QUERY,
paginationStyle: 'offset',
pageSize: 50,
infiniteScroll: true,
dataPath: 'Page.media',
totalPath: 'Page.pageInfo.total', // inline total — no second request
buildVariables: (state) => ({
page: state.page, perPage: state.perPage,
search: state.search, sort: state.orderBy, ...state.filter,
}),
buildSort: (column, direction) => [mapToMediaSort(column, direction)],
buildFilter: (model) => mapToAniListArgs(model),
});
el.setDataManager(dm);
el.initializeGrid({ infiniteScroll: { enabled: true }, sorting: { serverSide: true } });
await el.loadRowsAsync();
11. Generic Search
Search is a source-agnostic contract on the DataManager. Each manager interprets
a free-text term in its own way, but hosts use a single API. The term reduces
the rows the grid loads/displays; it composes with sort and (for OData) $filter.
11.1 Contract
dm.setSearchTerm('john'); // narrow rows; fires onConfigChanged('setSearchTerm')
dm.getSearchTerm(); // => 'john'
dm.setSearchTerm(''); // clear
Base DataManager.setSearchTerm() is a no-op, so custom managers without
search support ignore the term. Both built-in managers override it. The mutator
follows the same _fireConfigChanged convention as setFilter/setOrderBy, so
it integrates with auto-reload. Setting the same term twice is a no-op (no
spurious reload).
A manager may additionally restrict the term to a subset of fields:
dm.getSearchFields(); // string[] when restricted, or null when unrestricted
null means "unrestricted" — the base DataManager.getSearchFields()
implementation, and the value returned whenever the field list isn't actually
being enforced (see §11.4's searchMode caveat). <vn-grid>.getSearchFields()
delegates to this, and <vn-grid-toolbar-search> consumes it to show a
hover/focus tooltip listing the active fields — see
vanilla-grid-toolbar's implementation doc.
11.2 Triggering a search from <vn-grid>
await gridElement.search('john'); // setSearchTerm + reloadDataManager
search() delegates to dm.setSearchTerm(term) then reloads:
- Explicit mode (default): calls
reloadDataManager()and resolves with the rows. - Auto-reload mode (
setAutoReloadOnConfigChange(true)):setSearchTerm()already schedules a single coalesced reload viaonConfigChanged, sosearch()does not reload again (no double fetch) and resolves with[].
reloadDataManager() runs reset() → _resetInfiniteScrollState() →
loadRowsAsync(), so the filtered set loads from page 0 under last-wins semantics.
Persistence (opt-in). When persistence.searchTerm.enabled is true (default
false), search() also writes the term to the storage provider. On the next
load the grid parks the stored term and <vn-grid> re-applies it after the first
data load — composed with any restored filter model in a single reload. See
Local Storage Settings §4.5.
11.3 StaticDataManager — client-side search
Case-insensitive substring match against the raw field values (not the rendered/localized cell text — the manager has no column or formatter knowledge).
Field scope: by default every own scalar field (string, number, boolean, bigint,
Date) of each row is searched. Nested objects/arrays are skipped. Restrict the scope withsearchFields:new StaticDataManager({ rows, searchFields: ['name', 'address.city'] }); // or at runtime: dm.setSearchFields(['name', 'address.city']); // null/[] → all scalar fieldsField names may be dotted paths; missing segments yield no match.
Filtered set is authoritative:
fetchRowsandfetchMoreRowsboth read a memoized_filteredRowsarray — andfetchRowspushes its.lengthvia_fireTotalRowCountChanged()— so paging and the total-rows indicator stay correct under infinite scroll._rowsis never mutated.
11.4 ODataDataManager — server-side search
Two modes, selected via searchMode:
'search'(default): emits$search="<term>". The term is quoted as a literal phrase (embedded"backslash-escaped);$searchboolean operators in user input are not interpreted.$searchand$filtercombine with logical AND server-side, so an existing$filteris left untouched.new ODataDataManager({ baseUrl, searchMode: 'search' });'filter': for servers without$search. Builds acontains()disjunction acrosssearchFieldsand AND-merges it with any existing$filter:new ODataDataManager({ baseUrl, searchMode: 'filter', searchFields: ['Name', 'City'] }); // → $filter=(<existing>) and (contains(Name,'foo') or contains(City,'foo'))Single quotes in the term are escaped (
''); dotted field paths are converted to OData slash notation (Address.City→Address/City).
Runtime accessors: setSearchTerm/getSearchTerm, setSearchMode/getSearchMode,
setSearchFields/getSearchFields.
getSearchFields() is mode-aware. It returns the configured list only
while searchMode: 'filter' is active and the list is non-empty; in the
default searchMode: 'search' the list is stored but never serialized (the
server owns $search's field scope), so getSearchFields() reports null
there even if searchFields was passed at construction — consumers (e.g. the
toolbar's search-fields tooltip, §11.1) must not treat a configured-but-inert
list as an active restriction.
Count correctness: _refreshTotalRowCount() appends $filter and the
serialized search term to countUrl, so the total reflects the constrained set.
(Previously the count endpoint received no query clauses.)
11.5 Search-Match Highlighting Is a Rendering-Only Re-Match
<vn-grid highlightSearchMatches> (§1.17 of the
virtualization doc)
wraps matched text in <mark> purely by re-checking the active search term
against each cell's already-formatted display text at render time — it has
no access to, and does not consult, whichever matching semantics a manager
actually used to decide row inclusion above.
This is a deliberate decoupling, not an oversight: StaticDataManager matches
against raw field values (§11.3), which can differ from the rendered text
a date/number/currency-typed column shows; ODataDataManager and
GraphQLDataManager push the term to the server and receive back only
already-filtered rows, with no signal at all about which field(s) matched or
why (server-side full-text ranking, stemming, and fuzzy matching are opaque to
the client).
Consequence, and expected behavior: a row can appear in the result set
with no cell highlighted — it matched a hidden/non-displayed field,
matched its raw value where the formatted value differs, or matched via
server-side relevance ranking a plain case-insensitive substring check can't
reproduce. This is not a bug to fix; it is the accepted tradeoff for a purely
visual affordance that needs zero manager-specific plumbing and works
identically across every DataManager, including infinite-scroll pages
landing after the initial load.
12. Column Filters
Structured, type-aware per-column filtering. Same architectural spine as search
(config setter → onConfigChanged → reload), composing with search and the base
$filter. Full reference: 07-column-filters-implementation.md.
12.1 Contract
dm.setColumnFilters({ salary: { type: 'number', conditions: [ { operator: 'between', value: 30000, valueTo: 50000 } ] } });
dm.getColumnFilters(); // → normalized clone
dm.setColumnFilter('country', { type: 'string', conditions: [ { operator: 'equals', value: 'Italy' } ] });
dm.clearColumnFilters();
All four are no-ops on the base class. Setters store the model normalized via
window.VanillaGridFilterModel.normalizeFilterModel(), fire
onConfigChanged('setColumnFilters'), and are no-ops when the normalized model is
unchanged. The model persists across reset() (like search and $filter).
12.2 StaticDataManager — client-side predicate
Compiles the model into a memoized (row) => boolean (cross-column AND, per-column
combinator) applied after search in _getFilteredRowsAsync()'s in-thread path
(_getFilteredRowsSync()) — so paging and count use the filtered set. Above
workerThreshold rows, the same search+filter logic is instead evaluated in a Web
Worker (see §5.2) — results are identical either way. Options: caseInsensitive
(default true), columnFilters, filterValueGetters ({ key: (row) => value })
for computed columns, and useWorker/workerThreshold for the worker offload.
12.3 ODataDataManager — server-side $filter
_composeEffectiveFilter() AND-merges base $filter + column filters +
'filter'-mode search disjunction; both _buildUrl() and _refreshTotalRowCount()
use it. Type-aware literal formatting (strings quoted/escaped, numbers/dates/booleans
bare). Options: caseInsensitive, columnFilters.
12.4 From <vn-grid>
grid.setColumnFilters(model) / setColumnFilter(key, cf) / clearColumnFilters()
delegate then reload (with the same auto-reload guard as search()). The element
drops non-filterable columns (filtering.enabled === false or
column.filterable === false) and enriches kept entries with field/type from
the column def. See 07-column-filters-implementation.md.