From 177eeb8d92c3322f728c1eff0ec70a8238caf6c6 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 28 Aug 2026 15:03:15 +0700 Subject: [PATCH 1/2] fix(filtering): stop trusting the snapshot a filter model came back in `hydrate` cast the state slice straight to `FilterModel`, which is a promise to the compiler and not a check. A condition whose operator belonged to another kind, a `set` whose `values` was not a list, or a condition missing the value its operator needs all reached the predicate builders as a shape they never tested, and threw while the pipeline's `$derived` was reading them. That read happens inside the body's `{#each grid.nodes}`, so the throw took the render pass rather than one column. Six of the seven shapes measured against 1.3.0 brought the grid down, and every path that reaches `hydrate` is untrusted: share links, `localStorage`, and anything handed back to `setState`. `sanitizeFilterModel` now rebuilds the model from the part that can be read and drops the rest. A column left with no readable condition stops filtering, which shows more rows rather than none. That is the deliberate call: for a column behind a value gate it is not strictly failing safe, and a grid that will not render is worse. The predicates carry a second layer for a condition arriving some other way, `applyFilterModel` included: an unknown operator, a missing value, a `set` whose values are not a list and a kind nothing knows now pass every row instead of throwing. Closes #41 --- CHANGELOG.md | 19 ++ .../features/filtering/filter-predicates.ts | 50 +++- .../filtering/filter-sanitize.test.ts | 224 ++++++++++++++++++ src/lib/features/filtering/filter-sanitize.ts | 159 +++++++++++++ .../features/filtering/filtering.svelte.ts | 6 +- src/lib/features/filtering/index.ts | 1 + 6 files changed, 447 insertions(+), 12 deletions(-) create mode 100644 src/lib/features/filtering/filter-sanitize.test.ts create mode 100644 src/lib/features/filtering/filter-sanitize.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a23ac..20af1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- A malformed filter snapshot no longer brings the grid down. `filtering`'s + `hydrate` cast the slice straight to `FilterModel`, so an operator that + belonged to another kind, a `set` whose `values` was not a list, or a + condition missing the value its operator needs all threw while the pipeline + was reading them - a throw inside a `$derived` costs the render pass, not one + column. Six of the seven shapes measured against 1.3.0 threw. The slice is + now sanitized at the boundary: what cannot be read is dropped, and a column + left with nothing stops filtering, which shows more rows rather than none. +- The filter predicates no longer assume the condition handed to them is well + formed. An unknown operator, a missing value, a `set` whose values are not a + list and a kind nothing knows now pass every row instead of throwing. This is + the layer that covers `applyFilterModel`, which an app can call with a model + it read back from its own storage. +- `sorting`'s `hydrate` checked `Array.isArray` and then cast, so a null entry + in the array threw on `columnId`. Entries that do not name a column and a + direction are now dropped. + ## [1.3.0] - 2026-08-24 ### Added diff --git a/src/lib/features/filtering/filter-predicates.ts b/src/lib/features/filtering/filter-predicates.ts index 1923082..49d3a56 100644 --- a/src/lib/features/filtering/filter-predicates.ts +++ b/src/lib/features/filtering/filter-predicates.ts @@ -13,6 +13,14 @@ import { getCellValue, isBlank } from '../../core/utils/index.js' import { setKeyOf } from './distinct-values.js' import { normalizeFilterEntry } from './filter-model.js' +/** + * What an unreadable condition does: nothing. `sanitizeFilterModel` is the + * boundary that should have dropped it, and this is the layer that keeps a + * condition arriving some other way out of the pipeline's `$derived`, where a + * throw costs the whole render pass rather than one column. + */ +const PASSES = (): boolean => true + export function filterTypeOf(def: ColumnDef): FilterType | null { if (def.filter === false || def.filter === undefined) return null return typeof def.filter === 'string' ? def.filter : def.filter.type @@ -24,18 +32,27 @@ function customPredicateOf( return typeof def.filter === 'object' ? def.filter.predicate : undefined } +/** Folding once here keeps `toLowerCase` out of the per-row loop. */ +function foldedQuery(filter: Extract): { + query: string + read: (value: unknown) => string +} { + const fold = filter.caseSensitive + ? (text: string) => text + : (text: string) => text.toLowerCase() + return { + query: fold(String(filter.value ?? '').trim()), + read: (value: unknown) => fold(String(value)) + } +} + function textPredicate( filter: Extract ): (value: unknown) => boolean { if (filter.op === 'blank') return (value) => isBlank(value) if (filter.op === 'notBlank') return (value) => !isBlank(value) - // Folding once here keeps `toLowerCase` out of the per-row loop. - const fold = filter.caseSensitive - ? (text: string) => text - : (text: string) => text.toLowerCase() - const query = fold(filter.value.trim()) - const read = (value: unknown) => fold(String(value)) + const { query, read } = foldedQuery(filter) switch (filter.op) { case 'equals': @@ -52,12 +69,16 @@ function textPredicate( return (value) => !isBlank(value) && read(value).includes(query) case 'notContains': return (value) => isBlank(value) || !read(value).includes(query) + default: + return PASSES } } +type NumberComparator = (value: number, target: number) => boolean + const numberComparators: Record< Exclude, - (value: number, target: number) => boolean + NumberComparator > = { eq: (value, target) => value === target, neq: (value, target) => value !== target, @@ -83,7 +104,12 @@ function numberPredicate( return numeric >= target && numeric <= to } } - const compare = numberComparators[filter.op] + // Widened on purpose: the key is exhaustive by type, and an operator that + // reached here from outside the type system is exactly what this catches. + const compare = (numberComparators as Partial>)[ + filter.op + ] + if (!compare) return PASSES return (value) => !isBlank(value) && compare(Number(value), target) } @@ -134,10 +160,13 @@ function datePredicate( const day = toEpochDay(value) return day >= target && day <= to } + default: + return PASSES } } function setPredicate(filter: Extract): (value: unknown) => boolean { + if (!Array.isArray(filter.values)) return PASSES // Keyed on both sides by the same function the value list is built with, or // the cell holding a Date is never the entry the user picked, and a filter // that came back through a snapshot is never the one that went in. @@ -163,6 +192,8 @@ export function valuePredicateFor(filter: ColumnFilter): (value: unknown) => boo return setPredicate(filter) case 'boolean': return booleanPredicate(filter) + default: + return PASSES } } @@ -174,7 +205,8 @@ function entryPredicate( const { join, conditions } = normalizeFilterEntry(entry) const custom = customPredicateOf(def) - const tests = conditions.map((condition) => { + const listed = Array.isArray(conditions) ? conditions : [] + const tests = listed.map((condition) => { if (custom) return (value: unknown, row: TRow) => custom(value, row, condition) const predicate = valuePredicateFor(condition) return (value: unknown) => predicate(value) diff --git a/src/lib/features/filtering/filter-sanitize.test.ts b/src/lib/features/filtering/filter-sanitize.test.ts new file mode 100644 index 0000000..2e51889 --- /dev/null +++ b/src/lib/features/filtering/filter-sanitize.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest' +import { createDataGrid } from '../../core/grid/index.js' +import type { ColumnDef, ColumnFilter } from '../../core/types/index.js' +import { filtering, getFiltering, sanitizeFilterModel } from './index.js' + +interface Row { + id: number + total: number + name: string + when: string + ok: boolean +} + +const columns: ColumnDef[] = [ + { id: 'total', header: 'Total', filter: 'number' }, + { id: 'name', header: 'Name', filter: 'text' }, + { id: 'when', header: 'When', filter: 'date' }, + { id: 'ok', header: 'Ok', filter: 'boolean' } +] + +const data: Row[] = [ + { id: 1, total: 10, name: 'alpha', when: '2026-01-02', ok: true }, + { id: 2, total: 20, name: 'beta', when: '2026-02-02', ok: false } +] + +function gridWith(columnId: string, filter: unknown) { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [filtering()] + }) + grid.setState({ + version: 1, + features: { filtering: { quick: '', columns: { [columnId]: filter } } } + } as never) + return grid +} + +describe('sanitizeFilterModel', () => { + it('keeps a model the editor could have built', () => { + const model = sanitizeFilterModel({ + quick: 'ph', + columns: { + total: { kind: 'number', op: 'gt', value: 15 }, + name: { kind: 'text', op: 'contains', value: 'al', caseSensitive: true } + } + }) + + expect(model).toEqual({ + quick: 'ph', + columns: { + total: { kind: 'number', op: 'gt', value: 15 }, + name: { kind: 'text', op: 'contains', value: 'al', caseSensitive: true } + } + }) + }) + + it('reads a group, dropping only the conditions that are broken', () => { + const model = sanitizeFilterModel({ + quick: '', + columns: { + name: { + kind: 'group', + join: 'or', + conditions: [ + { kind: 'text', op: 'contains', value: 'al' }, + { kind: 'text', op: 'eq', value: 'beta' } + ] + } + } + }) + + expect(model?.columns.name).toEqual({ + kind: 'group', + join: 'or', + conditions: [{ kind: 'text', op: 'contains', value: 'al' }] + }) + }) + + it('drops a group whose conditions are not a list, and one left with none', () => { + const model = sanitizeFilterModel({ + columns: { + name: { kind: 'group', join: 'and', conditions: 'nope' }, + when: { kind: 'group', join: 'and', conditions: [{ kind: 'date', op: 'gt' }] } + } + }) + + expect(model).toEqual({ quick: '', columns: {} }) + }) + + it('takes a presence operator with no value, in either spelling', () => { + const model = sanitizeFilterModel({ + columns: { + name: { kind: 'text', op: 'blank' }, + total: { kind: 'number', op: 'notBlank' } + } + }) + + expect(model?.columns).toEqual({ + name: { kind: 'text', op: 'blank', value: '' }, + total: { kind: 'number', op: 'notBlank' } + }) + }) + + it('rejects a slice that is not an object', () => { + expect(sanitizeFilterModel(null)).toBeNull() + expect(sanitizeFilterModel('filtered')).toBeNull() + expect(sanitizeFilterModel([])).toBeNull() + }) + + it('falls back to an empty quick filter rather than carrying junk', () => { + expect(sanitizeFilterModel({ quick: 42, columns: 'nope' })).toEqual({ + quick: '', + columns: {} + }) + }) +}) + +describe('hydrating a malformed filter snapshot', () => { + // One case per row of the table measured on 1.3.0, where six of the seven + // threw while the pipeline was reading them. + const cases: [string, string, unknown][] = [ + [ + 'a number operator spelled as the text one', + 'total', + { + kind: 'number', + op: 'equals', + value: 10 + } + ], + [ + 'a text operator spelled as the number one', + 'name', + { + kind: 'text', + op: 'eq', + value: 'alpha' + } + ], + [ + 'a date operator spelled as the number one', + 'when', + { + kind: 'date', + op: 'gt', + value: '2026-01-01' + } + ], + ['a set whose values are not a list', 'name', { kind: 'set', values: {} }], + ['a kind nothing knows', 'name', { kind: 'nonsense', op: 'eq' }], + ['a text condition with no value', 'name', { kind: 'text', op: 'contains' }], + ['a boolean value that is not a boolean', 'ok', { kind: 'boolean', value: 'yes' }] + ] + + for (const [label, columnId, filter] of cases) { + it(`reads every row and does not throw: ${label}`, () => { + const grid = gridWith(columnId, filter) + + expect(() => grid.nodes).not.toThrow() + expect(grid.nodes).toHaveLength(2) + expect(getFiltering(grid)?.columnFilters).toEqual({}) + }) + } + + it('keeps the readable column when another is dropped', () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [filtering()] + }) + grid.setState({ + version: 1, + features: { + filtering: { + quick: '', + columns: { + total: { kind: 'number', op: 'gt', value: 15 }, + name: { kind: 'text', op: 'eq', value: 'alpha' } + } + } + } + } as never) + + expect(grid.nodes).toHaveLength(1) + expect(grid.nodes[0]?.row.name).toBe('beta') + }) +}) + +describe('a broken condition set through the public API', () => { + // `applyFilterModel` does not sanitize: it is typed, and an app calling it + // has said what the model is. The predicate layer is what keeps a model + // that lied from reaching the pipeline as a throw. + const conditions: [string, string, ColumnFilter][] = [ + ['an operator from another kind', 'total', { kind: 'number', op: 'equals' } as never], + ['a text operator from another kind', 'name', { kind: 'text', op: 'eq' } as never], + ['a date operator from another kind', 'when', { kind: 'date', op: 'gt' } as never], + ['a set whose values are not a list', 'name', { kind: 'set', values: {} } as never], + ['a kind nothing knows', 'name', { kind: 'nonsense' } as never], + ['a text condition with no value', 'name', { kind: 'text', op: 'contains' } as never], + [ + 'a group whose conditions are not a list', + 'name', + { kind: 'group', join: 'and', conditions: 'nope' } as never + ] + ] + + for (const [label, columnId, condition] of conditions) { + it(`passes every row rather than throwing: ${label}`, () => { + const grid = createDataGrid({ + columns, + data, + getRowId: (row) => String(row.id), + features: [filtering()] + }) + getFiltering(grid)?.applyFilterModel({ quick: '', columns: { [columnId]: condition } }) + + expect(() => grid.nodes).not.toThrow() + expect(grid.nodes).toHaveLength(2) + }) + } +}) diff --git a/src/lib/features/filtering/filter-sanitize.ts b/src/lib/features/filtering/filter-sanitize.ts new file mode 100644 index 0000000..97d3226 --- /dev/null +++ b/src/lib/features/filtering/filter-sanitize.ts @@ -0,0 +1,159 @@ +import type { + ColumnFilter, + ColumnFilterEntry, + DateFilterOp, + FilterModel, + NumberFilterOp, + SetFilterValue, + TextFilterOp +} from '../../core/types/index.js' + +/** + * A snapshot is not a `FilterModel` just because it was cast to one. Share + * links, `localStorage` and anything handed back to `setState` have all been + * outside the grid, and a filter whose operator or value does not match its + * kind reaches the predicate builders as a shape they never check. That threw + * inside the pipeline's `$derived`, which takes down the render pass rather + * than one column. + * + * So the boundary drops what it cannot read. A condition that fails to + * sanitize is left out; a column left with nothing is left out; a model with + * no readable column filters nothing, which is what an empty model already + * does. That shows more rows than the snapshot asked for, deliberately: it is + * the honest reading of a filter nobody can reconstruct, and the alternative + * measured here was a grid that would not render at all. + */ + +const TEXT_OPS = new Set([ + 'contains', + 'notContains', + 'equals', + 'notEqual', + 'startsWith', + 'endsWith', + 'blank', + 'notBlank' +]) +const NUMBER_OPS = new Set([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'blank', + 'notBlank' +]) +const DATE_OPS = new Set(['equals', 'before', 'after', 'between', 'blank', 'notBlank']) +const PRESENCE_OPS = new Set(['blank', 'notBlank']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function opOf(raw: Record, allowed: Set): string | null { + const { op } = raw + return typeof op === 'string' && allowed.has(op) ? op : null +} + +function isSetValue(value: unknown): value is SetFilterValue { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) +} + +function sanitizeText(raw: Record): ColumnFilter | null { + const op = opOf(raw, TEXT_OPS) + if (op === null) return null + // The presence operators carry no value, and the editor writes them with + // an empty one, so both spellings have to hydrate. + if (PRESENCE_OPS.has(op)) return { kind: 'text', op: op as TextFilterOp, value: '' } + if (typeof raw.value !== 'string') return null + const filter: Extract = { + kind: 'text', + op: op as TextFilterOp, + value: raw.value + } + if (raw.caseSensitive === true) filter.caseSensitive = true + return filter +} + +function sanitizeNumber(raw: Record): ColumnFilter | null { + const op = opOf(raw, NUMBER_OPS) + if (op === null) return null + if (PRESENCE_OPS.has(op)) return { kind: 'number', op: op as NumberFilterOp } + if (typeof raw.value !== 'number' || !Number.isFinite(raw.value)) return null + if (op === 'between') { + if (typeof raw.to !== 'number' || !Number.isFinite(raw.to)) return null + return { kind: 'number', op: 'between', value: raw.value, to: raw.to } + } + return { kind: 'number', op: op as NumberFilterOp, value: raw.value } +} + +function sanitizeDate(raw: Record): ColumnFilter | null { + const op = opOf(raw, DATE_OPS) + if (op === null) return null + if (PRESENCE_OPS.has(op)) return { kind: 'date', op: op as DateFilterOp } + if (typeof raw.value !== 'string' || raw.value === '') return null + if (op === 'between') { + if (typeof raw.to !== 'string' || raw.to === '') return null + return { kind: 'date', op: 'between', value: raw.value, to: raw.to } + } + return { kind: 'date', op: op as DateFilterOp, value: raw.value } +} + +function sanitizeSet(raw: Record): ColumnFilter | null { + if (!Array.isArray(raw.values)) return null + const values = raw.values.filter(isSetValue) + // The editor never builds an empty selection, so an empty one here is the + // remains of a broken list rather than a request to match nothing. + return values.length > 0 ? { kind: 'set', values } : null +} + +function sanitizeCondition(raw: unknown): ColumnFilter | null { + if (!isRecord(raw)) return null + switch (raw.kind) { + case 'text': + return sanitizeText(raw) + case 'number': + return sanitizeNumber(raw) + case 'date': + return sanitizeDate(raw) + case 'set': + return sanitizeSet(raw) + case 'boolean': + return typeof raw.value === 'boolean' ? { kind: 'boolean', value: raw.value } : null + default: + return null + } +} + +function sanitizeEntry(raw: unknown): ColumnFilterEntry | null { + if (!isRecord(raw)) return null + if (raw.kind !== 'group') return sanitizeCondition(raw) + if (!Array.isArray(raw.conditions)) return null + const conditions = raw.conditions + .map((condition) => sanitizeCondition(condition)) + .filter((condition): condition is ColumnFilter => condition !== null) + if (conditions.length === 0) return null + return { kind: 'group', join: raw.join === 'or' ? 'or' : 'and', conditions } +} + +/** A model built only from what the snapshot got right; null if it got nothing right. */ +export function sanitizeFilterModel(slice: unknown): FilterModel | null { + if (!isRecord(slice)) return null + + const columns: Record = {} + if (isRecord(slice.columns)) { + for (const [columnId, entry] of Object.entries(slice.columns)) { + const clean = sanitizeEntry(entry) + if (clean !== null) columns[columnId] = clean + } + } + + return { quick: typeof slice.quick === 'string' ? slice.quick : '', columns } +} diff --git a/src/lib/features/filtering/filtering.svelte.ts b/src/lib/features/filtering/filtering.svelte.ts index ce8a1a9..fcd5e1e 100644 --- a/src/lib/features/filtering/filtering.svelte.ts +++ b/src/lib/features/filtering/filtering.svelte.ts @@ -3,6 +3,7 @@ import type { ColumnFilterEntry, FilterModel, GridFeature } from '../../core/typ import { mutator } from '../../core/utils/index.js' import { distinctValuesCached } from './distinct-values.js' import { compileColumnFilters } from './filter-predicates.js' +import { sanitizeFilterModel } from './filter-sanitize.js' import { quickFilterNodes } from './quick-filter.js' export const FILTERING = 'filtering' @@ -112,9 +113,8 @@ export function filtering(options: FilteringOptions = {}): GridFeature { - if (slice && typeof slice === 'object') { - getFiltering(grid)?.applyFilterModel(slice as FilterModel) - } + const model = sanitizeFilterModel(slice) + if (model !== null) getFiltering(grid)?.applyFilterModel(model) }, pipelineStage: { order: PIPELINE_ORDER.filter, diff --git a/src/lib/features/filtering/index.ts b/src/lib/features/filtering/index.ts index 3846786..fe5cbf0 100644 --- a/src/lib/features/filtering/index.ts +++ b/src/lib/features/filtering/index.ts @@ -15,6 +15,7 @@ export { normalizeFilterEntry, toFilterRequest } from './filter-model.js' +export { sanitizeFilterModel } from './filter-sanitize.js' export { filterUnitScaleOf, toDisplayUnit, toModelUnit } from './filter-units.js' export { floatingCellOf, type FloatingCell } from './floating-filter.js' export { From e422a6d027591c0487519c0088240b5e5575f986 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Fri, 28 Aug 2026 15:03:22 +0700 Subject: [PATCH 2/2] fix(sorting): drop the sort entries a snapshot could not describe `hydrate` checked `Array.isArray` and then cast, which reads as a check and is not one: a null entry in that array threw on `columnId` while the pipeline was sorting. The same untrusted path as the filter model, one layer thinner. `sanitizeSortState` keeps only the entries naming a column and a direction. An unknown direction, a missing `columnId` and a plain string entry already degraded quietly; they are now dropped rather than carried. --- src/lib/features/sorting/cycle.test.ts | 13 +++++++ src/lib/features/sorting/index.ts | 2 +- src/lib/features/sorting/sort-model.test.ts | 39 ++++++++++++++++++++- src/lib/features/sorting/sort-model.ts | 17 +++++++++ src/lib/features/sorting/sorting.svelte.ts | 4 ++- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/lib/features/sorting/cycle.test.ts b/src/lib/features/sorting/cycle.test.ts index c39e678..6785509 100644 --- a/src/lib/features/sorting/cycle.test.ts +++ b/src/lib/features/sorting/cycle.test.ts @@ -68,3 +68,16 @@ describe('sort cycle', () => { expect(sort.sort).toEqual([{ columnId: 'name', direction: 'asc' }]) }) }) + +describe('hydrating a malformed sort snapshot', () => { + it('reads every row and keeps only the entry it could rebuild', () => { + const target = grid() + target.setState({ + version: 1, + features: { sorting: [null, { columnId: 'name', direction: 'asc' }] } + } as never) + + expect(() => target.nodes).not.toThrow() + expect(getSorting(target)?.sort).toEqual([{ columnId: 'name', direction: 'asc' }]) + }) +}) diff --git a/src/lib/features/sorting/index.ts b/src/lib/features/sorting/index.ts index ed37c3d..e339e2f 100644 --- a/src/lib/features/sorting/index.ts +++ b/src/lib/features/sorting/index.ts @@ -1,5 +1,5 @@ export { sortNodes, type SortNulls } from './sort.js' -export { toSortRequest } from './sort-model.js' +export { sanitizeSortState, toSortRequest } from './sort-model.js' export { getSorting, Sorting, diff --git a/src/lib/features/sorting/sort-model.test.ts b/src/lib/features/sorting/sort-model.test.ts index 153e168..71520ad 100644 --- a/src/lib/features/sorting/sort-model.test.ts +++ b/src/lib/features/sorting/sort-model.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildRowNodes } from '../../core/grid/index.js' import type { ColumnDef } from '../../core/types/index.js' -import { toSortRequest } from './sort-model.js' +import { sanitizeSortState, toSortRequest } from './sort-model.js' import { sortNodes } from './sort.js' interface Person { @@ -113,3 +113,40 @@ describe('toSortRequest', () => { expect(toSortRequest([], columns)).toEqual([]) }) }) + +describe('sanitizeSortState', () => { + it('keeps the entries that name a column and a direction', () => { + expect( + sanitizeSortState([ + { columnId: 'lastName', direction: 'asc' }, + { columnId: 'age', direction: 'desc' } + ]) + ).toEqual([ + { columnId: 'lastName', direction: 'asc' }, + { columnId: 'age', direction: 'desc' } + ]) + }) + + it('drops a null entry rather than reading through it', () => { + expect(sanitizeSortState([null, { columnId: 'age', direction: 'asc' }])).toEqual([ + { columnId: 'age', direction: 'asc' } + ]) + }) + + it('drops what a sort cannot be built from', () => { + expect( + sanitizeSortState([ + { columnId: 'age', direction: 'sideways' }, + { direction: 'asc' }, + { columnId: '', direction: 'asc' }, + 'age', + 42 + ]) + ).toEqual([]) + }) + + it('rejects a slice that is not a list', () => { + expect(sanitizeSortState({ columnId: 'age', direction: 'asc' })).toBeNull() + expect(sanitizeSortState(null)).toBeNull() + }) +}) diff --git a/src/lib/features/sorting/sort-model.ts b/src/lib/features/sorting/sort-model.ts index 1f946a8..10caf27 100644 --- a/src/lib/features/sorting/sort-model.ts +++ b/src/lib/features/sorting/sort-model.ts @@ -37,3 +37,20 @@ export function toSortRequest( function flip(nulls: SortNulls): SortNulls { return nulls === 'first' ? 'last' : 'first' } + +/** + * A sort read back from a snapshot, keeping only the entries that name a + * column and a direction. The same reasoning as the filter model: what came + * through storage is not a `SortState[]` because it was cast to one, and a + * null entry in that array threw while the pipeline was reading it. + */ +export function sanitizeSortState(slice: unknown): SortState[] | null { + if (!Array.isArray(slice)) return null + return slice.flatMap((entry) => { + if (typeof entry !== 'object' || entry === null) return [] + const { columnId, direction } = entry as Record + if (typeof columnId !== 'string' || columnId === '') return [] + if (direction !== 'asc' && direction !== 'desc') return [] + return [{ columnId, direction }] + }) +} diff --git a/src/lib/features/sorting/sorting.svelte.ts b/src/lib/features/sorting/sorting.svelte.ts index 7eb600f..27e5dec 100644 --- a/src/lib/features/sorting/sorting.svelte.ts +++ b/src/lib/features/sorting/sorting.svelte.ts @@ -3,6 +3,7 @@ import { type GridState, PIPELINE_ORDER } from '../../core/grid/index.js' import type { GridFeature, Keybinding, SortDirection, SortState } from '../../core/types/index.js' import { mutator } from '../../core/utils/index.js' import { sortNodes, type SortNulls } from './sort.js' +import { sanitizeSortState } from './sort-model.js' export const SORTING = 'sorting' @@ -116,7 +117,8 @@ export function sorting(options: SortingOptions = {}): GridFeature { return sort.length > 0 ? sort : undefined }, hydrate: (slice, grid) => { - if (Array.isArray(slice)) getSorting(grid)?.setSort(slice as SortState[]) + const sort = sanitizeSortState(slice) + if (sort !== null) getSorting(grid)?.setSort(sort) }, pipelineStage: { order: PIPELINE_ORDER.sort,