From eed3da9c6f9ce36bac8e76b4d4b3647a6d581138 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sun, 2 Aug 2026 22:56:47 -0500 Subject: [PATCH 1/2] fix(table-core): correct number-range, filter-depth, autoRemove, and custom faceting semantics Four filtering-semantics bugs from the beta triage. filterFn_inNumberRange matched non-numeric values: JavaScript's loose relational coercion let null, '', and booleans slip into a numeric range (null >= 0 && null <= 20 is true), so the auto-selected number filter leaked empty rows into a [0, max] range on nullable numeric columns. The filter now only matches real numbers. Numeric strings also stop matching; `between`/`betweenInclusive` remain the hybrid string/number range filters. When maxLeafRowFilterDepth stopped the root-down filter recursion, the kept rows' unfiltered descendants stayed visible through row.subRows but never entered flatRows or rowsById, so facet counts and other flat-representation consumers under-counted after filtering. Truncated subtrees now join both. Scoped to the root-down path; the leaf-up path drops truncated subRows entirely (pre-existing behavior) and is left as a possible follow-up. shouldAutoRemoveFilter ORed a hardcoded empty-string check over a custom autoRemove, so a filter function that wanted to keep '' as a filter value could not. A provided autoRemove is now authoritative for defined values; undefined always clears, since it is the universal setFilterValue(undefined) sentinel. Built-ins are unaffected: they all test falsy values themselves. Custom facetedUniqueValues/facetedMinMaxValues/facetedRowModel factories were frozen by a redundant memoization layer in columnFacetingFeature. The stock factories already memoize internally with the same dependencies, so the API-layer memoDeps only served to cache custom implementations against inputs they do not depend on. The API layer is now a plain pass-through like every other row model: stock results stay referentially stable and compute once per invalidation (pinned by Map-identity tests), the no-factory fallback keeps a stable empty Map, and custom factories own their memoization (tableMemo is exported for this). The faceting guides now document the contract and their server-side examples read live data through table.options.meta instead of stale closures. Co-Authored-By: Claude Fable 5 --- beta-window-triage.md | 16 +++-- .../framework/alpine/guide/column-faceting.md | 2 + .../alpine/guide/column-filtering.md | 2 +- .../angular/guide/column-faceting.md | 2 + .../angular/guide/column-filtering.md | 2 +- docs/framework/ember/guide/column-faceting.md | 2 + .../framework/ember/guide/column-filtering.md | 2 +- docs/framework/lit/guide/column-faceting.md | 2 + docs/framework/lit/guide/column-filtering.md | 2 +- .../framework/octane/guide/column-faceting.md | 2 + .../octane/guide/column-filtering.md | 2 +- .../framework/preact/guide/column-faceting.md | 17 +++-- .../preact/guide/column-filtering.md | 2 +- docs/framework/react/guide/column-faceting.md | 17 +++-- .../framework/react/guide/column-filtering.md | 2 +- docs/framework/solid/guide/column-faceting.md | 19 +++-- .../framework/solid/guide/column-filtering.md | 2 +- .../framework/svelte/guide/column-faceting.md | 19 +++-- .../svelte/guide/column-filtering.md | 2 +- docs/framework/vue/guide/column-faceting.md | 17 +++-- docs/framework/vue/guide/column-filtering.md | 2 +- .../column-faceting/columnFacetingFeature.ts | 54 ++------------ .../columnFacetingFeature.utils.ts | 12 +++- .../columnFilteringFeature.types.ts | 5 ++ .../columnFilteringFeature.utils.ts | 23 +++--- .../features/column-filtering/filterFns.ts | 10 ++- .../column-filtering/filterRowsUtils.ts | 30 ++++++++ .../createFacetedRowModels.test.ts | 69 ++++++++++++++++++ .../createFilteredRowModel.test.ts | 38 ++++++++++ .../columnFilteringFeature.utils.test.ts | 44 ++++++++++++ .../tests/unit/fns/filterFns.test.ts | 70 +++++++++++++++++++ 31 files changed, 380 insertions(+), 110 deletions(-) diff --git a/beta-window-triage.md b/beta-window-triage.md index 861ccab112..1b4eecfeaf 100644 --- a/beta-window-triage.md +++ b/beta-window-triage.md @@ -51,12 +51,14 @@ All four implemented on beta in one coordinated change (`rowExpandingFeature.uti - **[#5147](https://github.com/TanStack/table/issues/5147) + [#5832](https://github.com/TanStack/table/issues/5832) (high, same root cause)** — `column_getAutoSortDir` samples only `flatRows[0]` (lines 178-192); leading null or manual-sorting data swap flips the toggle cycle and silently drops a state. Fix: sample first N rows for first non-nullish value (match `column_getAutoSortFn`'s N=10). Close [#5832](https://github.com/TanStack/table/issues/5832) as duplicate when the fix lands. - **[#5653](https://github.com/TanStack/table/issues/5653) (optional semantic half)** — default `sortUndefined: 1` short-circuits before the user's `sortFn` (`createSortedRowModel.ts:104-124`). Current behavior defensible; decide during beta whether to change. The docs half (sorting.md wrongly describes `sortUndefined: false`) is ANYTIME. -### Cluster 5: Filtering semantics +### Cluster 5: Filtering semantics — FIX IMPLEMENTED 2026-08-02 (pending PR) -- **[#6007](https://github.com/TanStack/table/issues/6007) (high)** — `filterFn_inNumberRange` has no numeric guard (`filterFns.ts:283-287`); `null`/`''`/`false` coerce to 0 and pass zero-spanning ranges on the auto-selected default number filter. **Vehicle: PR [#6313](https://github.com/TanStack/table/pull/6313) (needs rebase).** -- **[#5987](https://github.com/TanStack/table/issues/5987) (med)** — with `maxLeafRowFilterDepth: 0`, kept parents' visible descendants never enter `flatRows`/`rowsById` (`filterRowsUtils.ts:130-146`), under-counting facets. **Vehicle: PR [#6361](https://github.com/TanStack/table/pull/6361) (Test check failing, stale since June).** -- **[#6101](https://github.com/TanStack/table/issues/6101) (med)** — `shouldAutoRemoveFilter` ORs a hardcoded empty-string check over a custom `autoRemove` (`columnFilteringFeature.utils.ts:347-353`); redefine the contract so a provided `autoRemove` is authoritative. Built-ins unaffected (all already `testFalsy`). No PR yet (old v8 PRs [#6178](https://github.com/TanStack/table/pull/6178)/[#6195](https://github.com/TanStack/table/pull/6195)). -- **[#6081](https://github.com/TanStack/table/issues/6081) (med)** — custom `getFacetedUniqueValues` frozen by two cache layers (per-column factory cache `columnFacetingFeature.utils.ts:97-101` + stock `flatRows` memoDeps applied to custom impls `columnFacetingFeature.ts:42-52`). Fix requires deciding the caching contract for custom row-model factories — a public extension-point semantics decision. Old fix PR [#6417](https://github.com/TanStack/table/pull/6417) was closed by its author; needs fresh beta design. +All four implemented on beta in one coordinated change, with new tests, all-frameworks guide updates (column-filtering `autoRemove` contract + column-faceting custom-factory contract and reworked server-side examples), and changeset `filtering-semantics-fixes`: + +- **[#6007](https://github.com/TanStack/table/issues/6007)** — `filterFn_inNumberRange.filter` now guards `typeof dataValue !== 'number' || Number.isNaN(dataValue)` (mirrors vehicle PR [#6313](https://github.com/TanStack/table/pull/6313), which can be closed as superseded). Note: numeric strings also stop matching, slightly beyond the PR's stated scope; called out in the changeset. +- **[#5987](https://github.com/TanStack/table/issues/5987)** — root-down filter path now pushes depth-truncated descendants of kept rows into `flatRows`/`rowsById` (ports vehicle PR [#6361](https://github.com/TanStack/table/pull/6361), which can be closed as superseded). Scoped to `filterRowModelFromRoot` like the PR; the leaf-up path drops truncated subRows entirely (pre-existing v8 behavior) and was left alone as a possible follow-up. +- **[#6101](https://github.com/TanStack/table/issues/6101)** — contract redefined: a provided `autoRemove` is authoritative for defined values (empty strings can now be kept); `undefined` always clears (universal sentinel). Built-ins unaffected. +- **[#6081](https://github.com/TanStack/table/issues/6081)** — caching contract decided: custom row-model factories resolve once per table/column (consistent with every other row model) and their returned functions run on every read; the redundant API-layer memoDeps in `columnFacetingFeature.ts` (which froze custom impls) are removed. Stock factories still memoize internally, so reference stability is preserved. Docs examples reworked to read live data via `table.options.meta` (react/vue/preact/solid/svelte were stale-closure patterns; angular/lit/alpine/ember/octane were already fine). ### Cluster 6: Group-column second-class citizenship @@ -109,9 +111,9 @@ All four implemented on beta in one coordinated change (`rowExpandingFeature.uti 1. **[#5968](https://github.com/TanStack/table/issues/5968)** first-run auto-reset guard + `resetPageIndex` honoring `initialState` (unblocks [#6443](https://github.com/TanStack/table/pull/6443)). 2. **[#6442](https://github.com/TanStack/table/pull/6442)** undraft + merge (closes [#5770](https://github.com/TanStack/table/issues/5770)/[#5497](https://github.com/TanStack/table/issues/5497)); close [#4919](https://github.com/TanStack/table/issues/4919), [#5138](https://github.com/TanStack/table/issues/5138), [#5202](https://github.com/TanStack/table/issues/5202), and superseded PRs [#5823](https://github.com/TanStack/table/pull/5823)/[#6177](https://github.com/TanStack/table/pull/6177). 3. Row-selection coordinated PR ([#6049](https://github.com/TanStack/table/issues/6049) + [#5116](https://github.com/TanStack/table/issues/5116) + [#5398](https://github.com/TanStack/table/issues/5398)). -4. **[#6313](https://github.com/TanStack/table/pull/6313)** rebase + merge ([#6007](https://github.com/TanStack/table/issues/6007)); **[#6361](https://github.com/TanStack/table/pull/6361)** fix CI + merge ([#5987](https://github.com/TanStack/table/issues/5987)); **[#6443](https://github.com/TanStack/table/pull/6443)** rebase + merge ([#5801](https://github.com/TanStack/table/issues/5801)). +4. **[#6443](https://github.com/TanStack/table/pull/6443)** rebase + merge ([#5801](https://github.com/TanStack/table/issues/5801)). (Cluster 5 — [#6007](https://github.com/TanStack/table/issues/6007), [#5987](https://github.com/TanStack/table/issues/5987), [#6101](https://github.com/TanStack/table/issues/6101), [#6081](https://github.com/TanStack/table/issues/6081) — implemented 2026-08-02, pending PR; close [#6313](https://github.com/TanStack/table/pull/6313)/[#6361](https://github.com/TanStack/table/pull/6361) as superseded when it lands.) 5. `_valuesCache`/`defaultColumn` invalidation pair ([#5363](https://github.com/TanStack/table/issues/5363)/[#4485](https://github.com/TanStack/table/issues/4485) + [#5275](https://github.com/TanStack/table/issues/5275)). 6. Sorting defaults ([#4946](https://github.com/TanStack/table/issues/4946) one-liner; [#5147](https://github.com/TanStack/table/issues/5147)/[#5832](https://github.com/TanStack/table/issues/5832) auto-dir sampling). -7. Remaining semantics decisions: [#5909](https://github.com/TanStack/table/issues/5909) (undefined = uncontrolled), [#5778](https://github.com/TanStack/table/issues/5778), [#6101](https://github.com/TanStack/table/issues/6101), [#6081](https://github.com/TanStack/table/issues/6081). (Cluster 3 — [#6115](https://github.com/TanStack/table/issues/6115), [#5833](https://github.com/TanStack/table/issues/5833), [#4939](https://github.com/TanStack/table/issues/4939), [#6136](https://github.com/TanStack/table/issues/6136) — implemented 2026-08-02, pending PR.) +7. Remaining semantics decisions: [#5909](https://github.com/TanStack/table/issues/5909) (undefined = uncontrolled), [#5778](https://github.com/TanStack/table/issues/5778). (Cluster 3 — [#6115](https://github.com/TanStack/table/issues/6115), [#5833](https://github.com/TanStack/table/issues/5833), [#4939](https://github.com/TanStack/table/issues/4939), [#6136](https://github.com/TanStack/table/issues/6136) — implemented 2026-08-02, pending PR.) 8. Type changes batch: [#5908](https://github.com/TanStack/table/issues/5908), [#5971](https://github.com/TanStack/table/issues/5971), [#6302](https://github.com/TanStack/table/issues/6302) (TS-perf gate). 9. [#6078](https://github.com/TanStack/table/issues/6078) via corrected [#6445](https://github.com/TanStack/table/pull/6445) (urgent but non-breaking — do not let it slip just because it is "anytime"). diff --git a/docs/framework/alpine/guide/column-faceting.md b/docs/framework/alpine/guide/column-faceting.md index 338d46f454..9362f2e131 100644 --- a/docs/framework/alpine/guide/column-faceting.md +++ b/docs/framework/alpine/guide/column-faceting.md @@ -283,6 +283,8 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const serverFacets = await fetch('/api/faceting').then((res) => res.json()) diff --git a/docs/framework/alpine/guide/column-filtering.md b/docs/framework/alpine/guide/column-filtering.md index f1b4f5348a..d90ba03c97 100644 --- a/docs/framework/alpine/guide/column-filtering.md +++ b/docs/framework/alpine/guide/column-filtering.md @@ -343,7 +343,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/angular/guide/column-faceting.md b/docs/framework/angular/guide/column-faceting.md index 5911045f2a..9306d0b36c 100644 --- a/docs/framework/angular/guide/column-faceting.md +++ b/docs/framework/angular/guide/column-faceting.md @@ -279,6 +279,8 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const serverFacets = signal(initialServerFacets) diff --git a/docs/framework/angular/guide/column-filtering.md b/docs/framework/angular/guide/column-filtering.md index 748cd0c4ac..fb3c854508 100644 --- a/docs/framework/angular/guide/column-filtering.md +++ b/docs/framework/angular/guide/column-filtering.md @@ -330,7 +330,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/ember/guide/column-faceting.md b/docs/framework/ember/guide/column-faceting.md index 0f0b2dd912..3a60f46622 100644 --- a/docs/framework/ember/guide/column-faceting.md +++ b/docs/framework/ember/guide/column-faceting.md @@ -304,6 +304,8 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const serverFacets = await fetch('/api/faceting').then((res) => res.json()) diff --git a/docs/framework/ember/guide/column-filtering.md b/docs/framework/ember/guide/column-filtering.md index a1975b0d7f..8496547c02 100644 --- a/docs/framework/ember/guide/column-filtering.md +++ b/docs/framework/ember/guide/column-filtering.md @@ -330,7 +330,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/lit/guide/column-faceting.md b/docs/framework/lit/guide/column-faceting.md index 2c335c929e..3496e5c5a4 100644 --- a/docs/framework/lit/guide/column-faceting.md +++ b/docs/framework/lit/guide/column-faceting.md @@ -287,6 +287,8 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const serverFacets = await fetch('/api/faceting').then((res) => res.json()) diff --git a/docs/framework/lit/guide/column-filtering.md b/docs/framework/lit/guide/column-filtering.md index 6e72e47fd3..6752d07030 100644 --- a/docs/framework/lit/guide/column-filtering.md +++ b/docs/framework/lit/guide/column-filtering.md @@ -342,7 +342,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/octane/guide/column-faceting.md b/docs/framework/octane/guide/column-faceting.md index bc9422a97a..25d5f21034 100644 --- a/docs/framework/octane/guide/column-faceting.md +++ b/docs/framework/octane/guide/column-faceting.md @@ -265,6 +265,8 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingData = await fetchFacets(activeFilters) diff --git a/docs/framework/octane/guide/column-filtering.md b/docs/framework/octane/guide/column-filtering.md index df4a18a71b..a4ba880068 100644 --- a/docs/framework/octane/guide/column-filtering.md +++ b/docs/framework/octane/guide/column-filtering.md @@ -326,7 +326,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/preact/guide/column-faceting.md b/docs/framework/preact/guide/column-faceting.md index 90b19a4213..c8286718ba 100644 --- a/docs/framework/preact/guide/column-faceting.md +++ b/docs/framework/preact/guide/column-faceting.md @@ -259,25 +259,28 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingQuery = useQuery() const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingQuery data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingQuery data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = useTable({ features, columns, + meta: { serverFacets: facetingQuery.data }, data, }) ``` diff --git a/docs/framework/preact/guide/column-filtering.md b/docs/framework/preact/guide/column-filtering.md index f412b144ad..22345147df 100644 --- a/docs/framework/preact/guide/column-filtering.md +++ b/docs/framework/preact/guide/column-filtering.md @@ -326,7 +326,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/react/guide/column-faceting.md b/docs/framework/react/guide/column-faceting.md index 9728e93877..82b0bb9342 100644 --- a/docs/framework/react/guide/column-faceting.md +++ b/docs/framework/react/guide/column-faceting.md @@ -257,25 +257,28 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingQuery = useQuery() const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingQuery data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingQuery data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = useTable({ features, columns, + meta: { serverFacets: facetingQuery.data }, data, }) ``` diff --git a/docs/framework/react/guide/column-filtering.md b/docs/framework/react/guide/column-filtering.md index 8900e55902..ba8defa89f 100644 --- a/docs/framework/react/guide/column-filtering.md +++ b/docs/framework/react/guide/column-filtering.md @@ -326,7 +326,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/solid/guide/column-faceting.md b/docs/framework/solid/guide/column-faceting.md index 1355e7349a..51f93225a4 100644 --- a/docs/framework/solid/guide/column-faceting.md +++ b/docs/framework/solid/guide/column-faceting.md @@ -264,25 +264,30 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingQuery = createQuery() const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingQuery data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingQuery data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = createTable({ features, columns, + get meta() { + return { serverFacets: facetingQuery.data } + }, get data() { return data() }, diff --git a/docs/framework/solid/guide/column-filtering.md b/docs/framework/solid/guide/column-filtering.md index 6f777cd08f..33a12b4bcf 100644 --- a/docs/framework/solid/guide/column-filtering.md +++ b/docs/framework/solid/guide/column-filtering.md @@ -325,7 +325,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/svelte/guide/column-faceting.md b/docs/framework/svelte/guide/column-faceting.md index 99ff6c56e2..9f134444a6 100644 --- a/docs/framework/svelte/guide/column-faceting.md +++ b/docs/framework/svelte/guide/column-faceting.md @@ -255,25 +255,30 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingQuery = createQuery() const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingQuery data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingQuery data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = createTable({ features, columns, + get meta() { + return { serverFacets: facetingQuery.data } + }, get data() { return data }, diff --git a/docs/framework/svelte/guide/column-filtering.md b/docs/framework/svelte/guide/column-filtering.md index 1972fb7c90..d0727d906f 100644 --- a/docs/framework/svelte/guide/column-filtering.md +++ b/docs/framework/svelte/guide/column-filtering.md @@ -337,7 +337,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/docs/framework/vue/guide/column-faceting.md b/docs/framework/vue/guide/column-faceting.md index e118e4e195..455cf6a41c 100644 --- a/docs/framework/vue/guide/column-faceting.md +++ b/docs/framework/vue/guide/column-faceting.md @@ -270,25 +270,28 @@ When filtering is performed on the server, the rows loaded into the browser may Each factory receives the table and a column ID, then returns a function that resolves the faceted result. The regular column APIs will return the server-provided values. +Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. + ```ts const facetingQuery = useQuery() const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingQuery data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingQuery data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = useTable({ features, columns, + meta: { serverFacets: facetingQuery.data }, data, }) ``` diff --git a/docs/framework/vue/guide/column-filtering.md b/docs/framework/vue/guide/column-filtering.md index 204365b5f5..c95f830110 100644 --- a/docs/framework/vue/guide/column-filtering.md +++ b/docs/framework/vue/guide/column-filtering.md @@ -330,7 +330,7 @@ You can attach a few other properties to filter functions to customize their beh - `filterFn.resolveDataValue` - This optional "hanging" method normalizes each row's value before it is compared against the filter value. It is honored by every filter function built with the `constructFilterFn` helper, which includes all built-in filter functions. -- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. +- `filterFn.autoRemove` - This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. When provided, this test is authoritative: values it keeps stay in filter state even when they are empty strings, which the default heuristic would otherwise remove. An `undefined` filter value always clears the filter regardless. The `constructFilterFn` helper builds a filter function from a value-level comparator plus those optional resolvers: diff --git a/packages/table-core/src/features/column-faceting/columnFacetingFeature.ts b/packages/table-core/src/features/column-faceting/columnFacetingFeature.ts index 193d1066dc..e125313a91 100644 --- a/packages/table-core/src/features/column-faceting/columnFacetingFeature.ts +++ b/packages/table-core/src/features/column-faceting/columnFacetingFeature.ts @@ -1,8 +1,4 @@ -import { - assignPrototypeAPIs, - assignTableAPIs, - callMemoOrStaticFn, -} from '../../utils' +import { assignPrototypeAPIs, assignTableAPIs } from '../../utils' import { column_getFacetedMinMaxValues, column_getFacetedRowModel, @@ -15,39 +11,23 @@ import type { TableFeature } from '../../types/TableFeatures' /** * Feature that derives faceted row models, unique values, and min/max values for filters. + * + * These APIs are deliberately not memoized at this layer: the stock + * `createFaceted*` factories memoize internally (like every other stock row + * model), and an extra memo layer here would freeze custom factories whose + * data changes independently of the faceted row model. Custom factories own + * their memoization. */ export const columnFacetingFeature: TableFeature = { assignColumnPrototype: (prototype, table) => { assignPrototypeAPIs('columnFacetingFeature', prototype, table, { column_getFacetedRowModel: { - memoDeps: () => [ - table.getPreFilteredRowModel().rows, - table.atoms.columnFilters?.get(), - table.atoms.globalFilter?.get(), - table.getFilteredRowModel().rows, - ], fn: (column) => column_getFacetedRowModel(column, column.table), }, column_getFacetedMinMaxValues: { - memoDeps: (column) => [ - callMemoOrStaticFn( - column, - 'getFacetedRowModel', - column_getFacetedRowModel, - column.table, - ).flatRows, - ], fn: (column) => column_getFacetedMinMaxValues(column, column.table), }, column_getFacetedUniqueValues: { - memoDeps: (column) => [ - callMemoOrStaticFn( - column, - 'getFacetedRowModel', - column_getFacetedRowModel, - column.table, - ).flatRows, - ], fn: (column) => column_getFacetedUniqueValues(column, column.table), }, }) @@ -56,32 +36,12 @@ export const columnFacetingFeature: TableFeature = { constructTableAPIs: (table) => { assignTableAPIs('columnFacetingFeature', table, { table_getGlobalFacetedRowModel: { - memoDeps: () => [ - table.getPreFilteredRowModel().rows, - table.atoms.columnFilters?.get(), - table.atoms.globalFilter?.get(), - table.getFilteredRowModel().rows, - ], fn: () => table_getGlobalFacetedRowModel(table), }, table_getGlobalFacetedMinMaxValues: { - memoDeps: () => [ - callMemoOrStaticFn( - table, - 'getGlobalFacetedRowModel', - table_getGlobalFacetedRowModel, - ).flatRows, - ], fn: () => table_getGlobalFacetedMinMaxValues(table), }, table_getGlobalFacetedUniqueValues: { - memoDeps: () => [ - callMemoOrStaticFn( - table, - 'getGlobalFacetedRowModel', - table_getGlobalFacetedRowModel, - ).flatRows, - ], fn: () => table_getGlobalFacetedUniqueValues(table), }, }) diff --git a/packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts b/packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts index c2e3d39cc5..22cdb31c8d 100644 --- a/packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts +++ b/packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts @@ -97,12 +97,20 @@ export function column_getFacetedUniqueValues< if (!facetedUniqueValuesFn) { facetedUniqueValuesFn = facetedUniqueValues[column.id] = table.options.features.facetedUniqueValues?.(table, column.id) ?? - (() => new Map()) + createStableEmptyMapFn() } return facetedUniqueValuesFn() } +// The no-factory fallback must return the same Map instance on every read so +// consumers can rely on referential stability, matching the stock factories' +// internally memoized results +function createStableEmptyMapFn(): () => Map { + const emptyMap = new Map() + return () => emptyMap +} + /** * Computes min and max numeric facet values for the global filter context. * @@ -172,7 +180,7 @@ export function table_getGlobalFacetedUniqueValues< if (!table._rowModels.globalFacetedUniqueValues) { table._rowModels.globalFacetedUniqueValues = table.options.features.facetedUniqueValues?.(table, '__global__') ?? - (() => new Map()) + createStableEmptyMapFn() } const facetedUniqueValuesFn = table._rowModels.globalFacetedUniqueValues diff --git a/packages/table-core/src/features/column-filtering/columnFilteringFeature.types.ts b/packages/table-core/src/features/column-filtering/columnFilteringFeature.types.ts index 096ee6ed70..9965a28480 100644 --- a/packages/table-core/src/features/column-filtering/columnFilteringFeature.types.ts +++ b/packages/table-core/src/features/column-filtering/columnFilteringFeature.types.ts @@ -70,6 +70,11 @@ export interface FilterFn< /** * Removes the filter from `state.columnFilters` when the filter value fails * this test (e.g. empty strings for text filters). + * + * When provided, this test is authoritative: values it keeps stay in filter + * state even when they are empty strings, which the default heuristic would + * otherwise remove. An `undefined` filter value always clears the filter + * regardless. */ autoRemove?: ColumnFilterAutoRemoveTestFn /** diff --git a/packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts b/packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts index ee0ccde565..c0ea454943 100644 --- a/packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts +++ b/packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts @@ -328,7 +328,12 @@ export function table_resetColumnFilters< /** * Returns whether a filter value should be removed from filter state. * - * This checks the filter function's `autoRemove` hook and built-in empty-value rules. + * `undefined` always removes: it is the universal "clear this filter" + * sentinel used by `setFilterValue(undefined)` and functional updaters. For + * any other value, a filter function's `autoRemove` hook is authoritative + * when provided, so custom filter functions can keep values (such as empty + * strings) that the default heuristic would drop. Without an `autoRemove` + * hook, empty strings are removed. * * @example * ```ts @@ -344,11 +349,13 @@ export function shouldAutoRemoveFilter< value?: any, column?: Column_Internal, ) { - return ( - (filterFn && filterFn.autoRemove - ? filterFn.autoRemove(value, column as any) - : false) || - typeof value === 'undefined' || - (typeof value === 'string' && !value) - ) + if (typeof value === 'undefined') { + return true + } + + if (filterFn?.autoRemove) { + return !!filterFn.autoRemove(value, column as any) + } + + return typeof value === 'string' && !value } diff --git a/packages/table-core/src/features/column-filtering/filterFns.ts b/packages/table-core/src/features/column-filtering/filterFns.ts index 2951f24dd0..fc94971170 100644 --- a/packages/table-core/src/features/column-filtering/filterFns.ts +++ b/packages/table-core/src/features/column-filtering/filterFns.ts @@ -278,10 +278,18 @@ export const filterFn_betweenInclusive = constructFilterFn({ * Keeps rows whose numeric value is inside an inclusive `[min, max]` range. * * Filter values are normalized so blank endpoints become open-ended and - * reversed endpoints are swapped. + * reversed endpoints are swapped. Only real numbers can fall inside the + * range: non-numeric row values (`null`, `undefined`, strings, booleans) + * never match. */ export const filterFn_inNumberRange = constructFilterFn({ filter: (dataValue: number, filterValue: [number, number]) => { + // Guard against non-numeric values: JavaScript's loose relational + // coercion would otherwise let values such as `null`, `''` and booleans + // slip into a numeric range (`null >= 0 && null <= 20` is true) + if (typeof dataValue !== 'number' || Number.isNaN(dataValue)) { + return false + } const [min, max] = filterValue return dataValue >= min && dataValue <= max }, diff --git a/packages/table-core/src/features/column-filtering/filterRowsUtils.ts b/packages/table-core/src/features/column-filtering/filterRowsUtils.ts index e8a701a7d6..e8f3e09336 100644 --- a/packages/table-core/src/features/column-filtering/filterRowsUtils.ts +++ b/packages/table-core/src/features/column-filtering/filterRowsUtils.ts @@ -144,6 +144,19 @@ function filterRowModelFromRoot< filteredRows.push(row) newFilteredFlatRows.push(row) newFilteredRowsById[row.id] = row + + // When maxLeafRowFilterDepth stops the recursion, the kept row's + // subtree stays visible through row.subRows, so those descendants + // must still enter flatRows and rowsById to keep the flat + // representation (and anything derived from it, like facet counts) + // consistent with the visible tree + if (row.subRows.length && depth >= maxDepth) { + addSubRowsToFlatArrays( + row.subRows, + newFilteredFlatRows, + newFilteredRowsById, + ) + } } } @@ -156,3 +169,20 @@ function filterRowModelFromRoot< rowsById: newFilteredRowsById, } } + +function addSubRowsToFlatArrays< + TFeatures extends TableFeatures, + TData extends RowData, +>( + subRows: Array>, + flatRows: Array>, + rowsById: Record>, +): void { + for (const subRow of subRows) { + flatRows.push(subRow) + rowsById[subRow.id] = subRow + if (subRow.subRows.length) { + addSubRowsToFlatArrays(subRow.subRows, flatRows, rowsById) + } + } +} diff --git a/packages/table-core/tests/implementation/features/column-faceting/createFacetedRowModels.test.ts b/packages/table-core/tests/implementation/features/column-faceting/createFacetedRowModels.test.ts index 0667bc5bc2..58e57726a7 100644 --- a/packages/table-core/tests/implementation/features/column-faceting/createFacetedRowModels.test.ts +++ b/packages/table-core/tests/implementation/features/column-faceting/createFacetedRowModels.test.ts @@ -303,5 +303,74 @@ describe('no-factory fallbacks', () => { expect(ageColumn.getFacetedUniqueValues()).toEqual(new Map()) expect(ageColumn.getFacetedUniqueValues().size).toBe(0) expect(ageColumn.getFacetedMinMaxValues()).toBeUndefined() + + // The fallback empty Map is referentially stable across reads, like the + // stock factories' internally memoized results + expect(ageColumn.getFacetedUniqueValues()).toBe( + ageColumn.getFacetedUniqueValues(), + ) + }) +}) + +describe('custom faceted row-model factories', () => { + it('should call a custom facetedUniqueValues factory live on every read', () => { + // Custom factories own their memoization: the feature layer must not + // cache their results, or data that changes independently of the faceted + // row model (e.g. server-side facets) would be frozen at first read + let currentValues = new Map([['placeholder', 1]]) + + const customFeatures = testFeatures({ + columnFacetingFeature, + columnFilteringFeature, + facetedUniqueValues: () => () => currentValues, + filteredRowModel: createFilteredRowModel(), + filterFns, + }) + + const customColumns: Array> = + [{ accessorKey: 'team', id: 'team' }] + + const table = constructTable({ + data, + columns: customColumns, + features: customFeatures, + }) + + expect(table.getColumn('team')!.getFacetedUniqueValues()).toBe( + currentValues, + ) + + currentValues = new Map([ + ['value1', 1], + ['value2', 1], + ]) + + expect(table.getColumn('team')!.getFacetedUniqueValues()).toBe( + currentValues, + ) + }) + + it('should keep the stock facetedUniqueValues reference stable until its inputs change', () => { + const table = makeTable() + const teamColumn = table.getColumn('team')! + + const first = teamColumn.getFacetedUniqueValues() + + // The stock factory memoizes internally, so repeated reads with + // unchanged inputs return the same Map instance without recomputing + expect(teamColumn.getFacetedUniqueValues()).toBe(first) + expect(teamColumn.getFacetedRowModel()).toBe( + teamColumn.getFacetedRowModel(), + ) + expect(teamColumn.getFacetedMinMaxValues()).toBe( + teamColumn.getFacetedMinMaxValues(), + ) + + // Filtering another column changes this column's faceted inputs, so a + // new Map is computed exactly once and is then stable again + table.setColumnFilters([{ id: 'name', value: 'Alice' }]) + const second = teamColumn.getFacetedUniqueValues() + expect(second).not.toBe(first) + expect(teamColumn.getFacetedUniqueValues()).toBe(second) }) }) diff --git a/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts b/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts index 146063fa2d..67637123f7 100644 --- a/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts +++ b/packages/table-core/tests/implementation/features/column-filtering/createFilteredRowModel.test.ts @@ -298,6 +298,44 @@ describe('createFilteredRowModel', () => { expect(rowNames(rows)).toEqual(['keep-a', 'keep-c', 'keep-d']) }) + it('should include unfiltered descendants of kept rows in flatRows and rowsById (from root, depth 0)', () => { + const table = makeNestedTable({ maxLeafRowFilterDepth: 0 }) + const model = table.getFilteredRowModel() + + // keep-a, keep-c and keep-d pass at depth 0 and their subtrees stay + // visible unfiltered, so flatRows must contain every visible row, not + // just the depth-0 rows (facet counts derive from flatRows) + expect(rowNames(model.flatRows)).toEqual([ + 'keep-a', + 'keep-a1', + 'drop-a1a', + 'drop-a2', + 'keep-c', + 'keep-d', + 'drop-d1', + ]) + + const keepA1 = model.rows[0]!.subRows[0]! + expect(model.rowsById[keepA1.id]).toBe(keepA1) + }) + + it('should include kept-as-is grandchildren in flatRows when maxLeafRowFilterDepth is 1 (from root)', () => { + const table = makeNestedTable({ maxLeafRowFilterDepth: 1 }) + const model = table.getFilteredRowModel() + + // Depth-1 children are still filtered (drop-a2 removed), while the + // depth-2 subtree of keep-a1 is kept as-is and joins flatRows. The + // pre-existing flatRows order pushes recursed children before their + // parent. + expect(rowNames(model.flatRows)).toEqual([ + 'keep-a1', + 'drop-a1a', + 'keep-a', + 'keep-c', + 'keep-d', + ]) + }) + it('should stop filtering below depth 1 when maxLeafRowFilterDepth is 1 (from root)', () => { const table = makeNestedTable({ maxLeafRowFilterDepth: 1 }) const keepA = table.getFilteredRowModel().rows[0]! diff --git a/packages/table-core/tests/unit/features/column-filtering/columnFilteringFeature.utils.test.ts b/packages/table-core/tests/unit/features/column-filtering/columnFilteringFeature.utils.test.ts index b944d5f6c6..0746af098b 100644 --- a/packages/table-core/tests/unit/features/column-filtering/columnFilteringFeature.utils.test.ts +++ b/packages/table-core/tests/unit/features/column-filtering/columnFilteringFeature.utils.test.ts @@ -287,6 +287,32 @@ describe('column_setFilterValue', () => { ]), ).toEqual([{ id: 'lastName', value: 'b' }]) }) + + it('should keep an empty-string filter when a custom autoRemove allows it', () => { + const onColumnFiltersChange = vi.fn() + const emptyAwareFilterFn: any = (row: any, columnId: string, value: any) => + row.getValue(columnId) === value + emptyAwareFilterFn.autoRemove = (val: any) => val === undefined + + const table = constructTable({ + data: people, + columns: [ + { + accessorKey: 'firstName', + id: 'firstName', + filterFn: emptyAwareFilterFn, + }, + ], + features, + onColumnFiltersChange, + }) + + column_setFilterValue(table.getColumn('firstName')!, '') + + expect(getUpdaterResult(onColumnFiltersChange, [])).toEqual([ + { id: 'firstName', value: '' }, + ]) + }) }) describe('table_setColumnFilters', () => { @@ -360,4 +386,22 @@ describe('shouldAutoRemoveFilter', () => { expect(shouldAutoRemoveFilter(filterFn_arrIncludes, [])).toBe(true) expect(shouldAutoRemoveFilter(filterFn_arrIncludes, ['a'])).toBe(false) }) + + it('should treat a provided autoRemove as authoritative for defined values', () => { + const customFilterFn: any = () => true + customFilterFn.autoRemove = (val: any) => val === null + + // A custom autoRemove that keeps empty strings wins over the default + // empty-string heuristic, so filtering for '' becomes possible + expect(shouldAutoRemoveFilter(customFilterFn, '')).toBe(false) + expect(shouldAutoRemoveFilter(customFilterFn, null)).toBe(true) + expect(shouldAutoRemoveFilter(customFilterFn, 'x')).toBe(false) + }) + + it('should always remove undefined even when autoRemove would keep it', () => { + const customFilterFn: any = () => true + customFilterFn.autoRemove = () => false + + expect(shouldAutoRemoveFilter(customFilterFn, undefined)).toBe(true) + }) }) diff --git a/packages/table-core/tests/unit/fns/filterFns.test.ts b/packages/table-core/tests/unit/fns/filterFns.test.ts index 3d4feade7d..8c0c359a4e 100644 --- a/packages/table-core/tests/unit/fns/filterFns.test.ts +++ b/packages/table-core/tests/unit/fns/filterFns.test.ts @@ -682,6 +682,76 @@ describe('Filter Functions', () => { expect(autoRemove('' as any)).toBe(true) }) }) + + describe('filterFn_inNumberRange', () => { + // Mirror the real filtering pipeline: the raw `[min, max]` the user + // sets is run through `resolveFilterValue` before the filter fn sees it + const resolve = (val: [any, any]) => + filterFn_inNumberRange.resolveFilterValue!(val) + const makeRow = (value: unknown) => ({ getValue: () => value }) as any + + it('should match numbers inside the range and on the inclusive boundaries', () => { + expect( + filterFn_inNumberRange(makeRow(15), 'age', resolve([0, 20])), + ).toBe(true) + expect( + filterFn_inNumberRange(makeRow(0), 'age', resolve([0, 20])), + ).toBe(true) + expect( + filterFn_inNumberRange(makeRow(20), 'age', resolve([0, 20])), + ).toBe(true) + }) + + it('should not match numbers outside the range', () => { + expect( + filterFn_inNumberRange(makeRow(25), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(-5), 'age', resolve([0, 20])), + ).toBe(false) + }) + + it('should not match nullish or blank values in a zero-spanning range', () => { + // `null >= 0 && null <= 20` coerces to true in JS, so nullable + // numeric columns used to leak empty rows into a [0, max] range + expect( + filterFn_inNumberRange(makeRow(null), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(undefined), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(''), 'age', resolve([0, 20])), + ).toBe(false) + }) + + it('should not match booleans, numeric strings, or NaN', () => { + expect( + filterFn_inNumberRange(makeRow(true), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(false), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow('15'), 'age', resolve([0, 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(NaN), 'age', resolve([0, 20])), + ).toBe(false) + }) + + it('should keep matching real numbers in open-ended ranges while excluding empty values', () => { + expect( + filterFn_inNumberRange(makeRow(-5), 'age', resolve(['', 20])), + ).toBe(true) + expect( + filterFn_inNumberRange(makeRow(null), 'age', resolve(['', 20])), + ).toBe(false) + expect( + filterFn_inNumberRange(makeRow(500), 'age', resolve([0, ''])), + ).toBe(true) + }) + }) }) describe('Array Filters', () => { From e4ea7225bdbdc3f387de9223b5d3c58c0fe43f39 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sun, 2 Aug 2026 23:05:57 -0500 Subject: [PATCH 2/2] fix(table-core): read live facet data in remaining server-side faceting examples The alpine, ember, lit, and octane guides captured a one-time `await fetch` result in the factory closure, so later server responses could never reach either faceting API. They now read through `table.options.meta` inside the returned functions like the other framework guides, using each framework's own reactive idiom. Co-Authored-By: Claude Fable 5 --- .../framework/alpine/guide/column-faceting.md | 22 ++++++++++++------- docs/framework/ember/guide/column-faceting.md | 19 ++++++++-------- docs/framework/lit/guide/column-faceting.md | 19 ++++++++-------- .../framework/octane/guide/column-faceting.md | 18 ++++++++------- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/docs/framework/alpine/guide/column-faceting.md b/docs/framework/alpine/guide/column-faceting.md index 9362f2e131..1b2698342a 100644 --- a/docs/framework/alpine/guide/column-faceting.md +++ b/docs/framework/alpine/guide/column-faceting.md @@ -286,24 +286,30 @@ Each factory receives the table and a column ID, then returns a function that re Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. ```ts -const serverFacets = await fetch('/api/faceting').then((res) => res.json()) +// `local` is your Alpine.reactive state, refreshed when facets arrive +async function loadFacets() { + local.serverFacets = await fetch('/api/faceting').then((res) => res.json()) +} const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from serverFacets data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from serverFacets data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) const table = createTable({ features, columns, + get meta() { + return { serverFacets: local.serverFacets } + }, get data() { return local.data }, diff --git a/docs/framework/ember/guide/column-faceting.md b/docs/framework/ember/guide/column-faceting.md index 3a60f46622..093ac1ac2d 100644 --- a/docs/framework/ember/guide/column-faceting.md +++ b/docs/framework/ember/guide/column-faceting.md @@ -307,18 +307,18 @@ Each factory receives the table and a column ID, then returns a function that re Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. ```ts -const serverFacets = await fetch('/api/faceting').then((res) => res.json()) - +// `this.serverFacets` is a @tracked field on your component, set when +// the facet request resolves const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from serverFacets data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from serverFacets data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) @@ -326,6 +326,7 @@ const features = tableFeatures({ table = useTable(() => ({ features, columns, + meta: { serverFacets: this.serverFacets }, data: this.data, })) ``` diff --git a/docs/framework/lit/guide/column-faceting.md b/docs/framework/lit/guide/column-faceting.md index 3496e5c5a4..b66cf653b7 100644 --- a/docs/framework/lit/guide/column-faceting.md +++ b/docs/framework/lit/guide/column-faceting.md @@ -290,18 +290,18 @@ Each factory receives the table and a column ID, then returns a function that re Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. ```ts -const serverFacets = await fetch('/api/faceting').then((res) => res.json()) - +// `this.serverFacets` is a reactive property on your Lit element, set +// when the facet request resolves const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from serverFacets data for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const serverFacets = table.options.meta?.serverFacets + return new Map(serverFacets?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from serverFacets data for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.serverFacets?.minMaxValues[columnId] }, }) @@ -309,6 +309,7 @@ const features = tableFeatures({ const table = this.tableController.table({ features, columns, + meta: { serverFacets: this.serverFacets }, data: this.data, }) ``` diff --git a/docs/framework/octane/guide/column-faceting.md b/docs/framework/octane/guide/column-faceting.md index 25d5f21034..7520f8e515 100644 --- a/docs/framework/octane/guide/column-faceting.md +++ b/docs/framework/octane/guide/column-faceting.md @@ -268,24 +268,26 @@ Each factory receives the table and a column ID, then returns a function that re Factories are resolved once per table and column, but the function each factory returns runs on every read; the table does not cache its result. Read live values inside that returned function (from a signal, store, or `table.options.meta`) so updated server facets show up immediately, and memoize inside the factory if the calculation is expensive. ```ts -const facetingData = await fetchFacets(activeFilters) +// Refreshed whenever the facet request resolves; read live below +const facets = { data: await fetchFacets(activeFilters) } const features = tableFeatures({ columnFacetingFeature, - facetedUniqueValues: (_table, columnId) => () => { - const uniqueValueMap = new Map() - // Populate the map from facetingData for columnId. - return uniqueValueMap + // The returned functions run on every read and table.options stays in + // sync with the latest render, so read live data through options.meta + facetedUniqueValues: (table, columnId) => () => { + const facetingData = table.options.meta?.facetingData + return new Map(facetingData?.uniqueValues[columnId] ?? []) }, - facetedMinMaxValues: (_table, columnId) => () => { - // Read the range from facetingData for columnId. - return [min, max] + facetedMinMaxValues: (table, columnId) => () => { + return table.options.meta?.facetingData?.minMaxValues[columnId] }, }) const table = useTable({ features, columns, + meta: { facetingData: facets.data }, data, }) ```