diff --git a/.changeset/guard-range-filter-tuple.md b/.changeset/guard-range-filter-tuple.md new file mode 100644 index 0000000000..5ae01d88a5 --- /dev/null +++ b/.changeset/guard-range-filter-tuple.md @@ -0,0 +1,5 @@ +--- +'@tanstack/table-core': patch +--- + +Guard `resolveFilterValue` in `inNumberRange` and `inDateRange` against filter values that are not arrays. Previously a string was destructured per character (`'30'` became the range `[0, 3]`, filtering silently wrong) and a number, boolean or `Date` threw `TypeError: val is not iterable`. Such values now leave the range fully open and warn in development. diff --git a/packages/table-core/src/features/column-filtering/filterFns.ts b/packages/table-core/src/features/column-filtering/filterFns.ts index fc94971170..0ed3a7b91a 100644 --- a/packages/table-core/src/features/column-filtering/filterFns.ts +++ b/packages/table-core/src/features/column-filtering/filterFns.ts @@ -280,7 +280,9 @@ export const filterFn_betweenInclusive = constructFilterFn({ * Filter values are normalized so blank endpoints become open-ended and * reversed endpoints are swapped. Only real numbers can fall inside the * range: non-numeric row values (`null`, `undefined`, strings, booleans) - * never match. + * never match. A filter value that is not an array leaves the range fully + * open and warns in development, instead of being split per character or + * throwing. */ export const filterFn_inNumberRange = constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { @@ -294,6 +296,10 @@ export const filterFn_inNumberRange = constructFilterFn({ return dataValue >= min && dataValue <= max }, resolveFilterValue: (val: [any, any]) => { + if (!isRangeArray(val, 'inNumberRange')) { + return [-Infinity, Infinity] as const + } + const [unsafeMin, unsafeMax] = val const parsedMin = @@ -326,7 +332,9 @@ export const filterFn_inNumberRange = constructFilterFn({ * * Row values and range endpoints may be `Date` objects, timestamps, or * parseable date strings. Blank or invalid endpoints become open-ended and - * reversed endpoints are swapped. Rows without a valid date never match. + * reversed endpoints are swapped. Rows without a valid date never match. A + * filter value that is not an array leaves the range fully open and warns in + * development, instead of being split per character or throwing. */ export const filterFn_inDateRange = constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { @@ -334,6 +342,10 @@ export const filterFn_inDateRange = constructFilterFn({ return dataValue >= min && dataValue <= max }, resolveFilterValue: (val: [any, any]) => { + if (!isRangeArray(val, 'inDateRange')) { + return [-Infinity, Infinity] as const + } + const [unsafeMin, unsafeMax] = val const parsedMin = toDateTimestamp(unsafeMin) @@ -468,6 +480,31 @@ function testFalsy(val: any) { return val === undefined || val === null || val === '' } +/** + * Guards a range filter value before it is destructured. + * + * `[any, any]` only exists at compile time: at runtime `setFilterValue()` can + * be handed anything. Destructuring a string splits it per character (`'30'` + * becomes `'3'` and `'0'`, a range nothing asked for), and destructuring a + * number, boolean or `Date` throws. `autoRemove` does not catch either case, + * since it only drops falsy values and fully blank tuples. Arrays of any + * length pass through to the existing endpoint handling unchanged. + */ +function isRangeArray(val: any, filterFnName: string): val is Array { + if (Array.isArray(val)) { + return true + } + + if (process.env.NODE_ENV === 'development') { + console.warn( + `filterFn '${filterFnName}' expects a [min, max] tuple, received:`, + val, + ) + } + + return false +} + function testValueEmpty(dataValue: any) { return dataValue == null || String(dataValue).trim() === '' } diff --git a/packages/table-core/tests/unit/fns/filterFns.test.ts b/packages/table-core/tests/unit/fns/filterFns.test.ts index 8c0c359a4e..76caab38bc 100644 --- a/packages/table-core/tests/unit/fns/filterFns.test.ts +++ b/packages/table-core/tests/unit/fns/filterFns.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { columnFilteringFeature, constructFilterFn, @@ -917,6 +917,64 @@ describe('Number Range Filters', () => { ]) }) + describe('filterFn_inNumberRange.resolveFilterValue non-array guard', () => { + const resolve = filterFn_inNumberRange.resolveFilterValue! + + it('should leave the range open for a string instead of splitting it per character', () => { + // Destructuring `'30'` used to yield min `'3'`, max `'0'`, i.e. the + // range [0, 3] — a `