From 537dff1f69d81dc1ae5b3d4fc1a5f28a93cb4339 Mon Sep 17 00:00:00 2001 From: hnton Date: Mon, 28 Sep 2026 22:36:46 +0700 Subject: [PATCH 1/3] fix(table-core): guard range filter values that are not [min, max] tuples --- .changeset/guard-range-filter-tuple.md | 5 + .../features/column-filtering/filterFns.ts | 44 ++++++- .../tests/unit/fns/filterFns.test.ts | 113 +++++++++++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 .changeset/guard-range-filter-tuple.md diff --git a/.changeset/guard-range-filter-tuple.md b/.changeset/guard-range-filter-tuple.md new file mode 100644 index 0000000000..6ad6891b53 --- /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 `[min, max]` tuples. 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..68f4426b0f 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 a `[min, max]` tuple leaves the + * range fully open (and warns in development) rather than filtering on a + * range read out of its characters. */ 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 (!isRangeTuple(val, 'inNumberRange')) { + return [-Infinity, Infinity] as const + } + const [unsafeMin, unsafeMax] = val const parsedMin = @@ -326,7 +332,10 @@ 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 a `[min, max]` tuple leaves the range fully open + * (and warns in development) rather than filtering on a range read out of its + * characters. */ export const filterFn_inDateRange = constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { @@ -334,6 +343,10 @@ export const filterFn_inDateRange = constructFilterFn({ return dataValue >= min && dataValue <= max }, resolveFilterValue: (val: [any, any]) => { + if (!isRangeTuple(val, 'inDateRange')) { + return [-Infinity, Infinity] as const + } + const [unsafeMin, unsafeMax] = val const parsedMin = toDateTimestamp(unsafeMin) @@ -468,6 +481,33 @@ 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. + */ +function isRangeTuple(val: any, filterFnName: string): val is [any, any] { + if (Array.isArray(val)) { + return true + } + + if ( + typeof process !== 'undefined' && + 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..7660b2fcd8 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,58 @@ describe('Number Range Filters', () => { ]) }) + describe('filterFn_inNumberRange.resolveFilterValue non-tuple 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 `