From 59e8367977224855eedde9e47eaf8cddb52004e9 Mon Sep 17 00:00:00 2001 From: Dmitrii Kartashev Date: Wed, 5 Aug 2026 17:20:12 -0400 Subject: [PATCH] fix(table-core): don't fire onExpandedChange when resetExpanded is a no-op table_resetExpanded built a fresh state object and always routed it through onExpandedChange, without comparing against the current expanded state. Its siblings all compare first: table_resetPageIndex and table_resetPageSize early-return when the value already matches, and row_toggleExpanded and table_toggleAllRowsExpanded gained the same guard in #6501. Since #6499 wired the expansion auto-reset into createCoreRowModel, that unguarded write fires on every data reference change. For a controlled table whose data is not referentially stable, the new-but-equal map re-renders the consumer, which produces another new data reference, and the cycle repeats without bound. Compare the target state against table.atoms.expanded?.get() and return early when they match, for both defaultState branches. Expanded-all compares by identity, maps key-by-key. --- .changeset/reset-expanded-noop.md | 5 + .../functions/row_getCanExpand.md | 2 +- .../functions/row_getIsAllParentsExpanded.md | 2 +- .../functions/row_getIsExpanded.md | 2 +- .../functions/row_getToggleExpandedHandler.md | 2 +- .../functions/row_toggleExpanded.md | 2 +- .../functions/table_getCanSomeRowsExpand.md | 2 +- .../functions/table_getExpandedDepth.md | 2 +- .../functions/table_getIsAllRowsExpanded.md | 2 +- .../functions/table_getIsSomeRowsExpanded.md | 2 +- .../table_getToggleAllRowsExpandedHandler.md | 2 +- .../functions/table_resetExpanded.md | 6 +- .../rowExpandingFeature.utils.ts | 43 ++++++--- .../rowExpandingFeature.utils.test.ts | 93 ++++++++++++++++++- 14 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 .changeset/reset-expanded-noop.md diff --git a/.changeset/reset-expanded-noop.md b/.changeset/reset-expanded-noop.md new file mode 100644 index 0000000000..8b704abc0f --- /dev/null +++ b/.changeset/reset-expanded-noop.md @@ -0,0 +1,5 @@ +--- +'@tanstack/table-core': patch +--- + +Make `table.resetExpanded()` a no-op when the target state already matches the current expanded state, so it no longer fires `onExpandedChange` with a new-but-equal map. `row.toggleExpanded()` and `table.toggleAllRowsExpanded()` already early-return this way. Because the core row model auto-resets `expanded` on every `data` reference change, the unguarded write could drive a controlled table with an unstable `data` reference into an unbounded render loop. diff --git a/docs/reference/static-functions/functions/row_getCanExpand.md b/docs/reference/static-functions/functions/row_getCanExpand.md index eb5e84e379..468ec351ea 100644 --- a/docs/reference/static-functions/functions/row_getCanExpand.md +++ b/docs/reference/static-functions/functions/row_getCanExpand.md @@ -9,7 +9,7 @@ title: row_getCanExpand function row_getCanExpand(row): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:378](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L378) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:399](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L399) Checks whether this row can be expanded. diff --git a/docs/reference/static-functions/functions/row_getIsAllParentsExpanded.md b/docs/reference/static-functions/functions/row_getIsAllParentsExpanded.md index e9d9add8f8..92b59b8bc5 100644 --- a/docs/reference/static-functions/functions/row_getIsAllParentsExpanded.md +++ b/docs/reference/static-functions/functions/row_getIsAllParentsExpanded.md @@ -9,7 +9,7 @@ title: row_getIsAllParentsExpanded function row_getIsAllParentsExpanded(row): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:398](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L398) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:419](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L419) Checks whether every ancestor of this row is expanded. diff --git a/docs/reference/static-functions/functions/row_getIsExpanded.md b/docs/reference/static-functions/functions/row_getIsExpanded.md index b54f1e7ebc..e197a7103a 100644 --- a/docs/reference/static-functions/functions/row_getIsExpanded.md +++ b/docs/reference/static-functions/functions/row_getIsExpanded.md @@ -9,7 +9,7 @@ title: row_getIsExpanded function row_getIsExpanded(row): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:343](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L343) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:364](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L364) Checks whether this row is expanded. diff --git a/docs/reference/static-functions/functions/row_getToggleExpandedHandler.md b/docs/reference/static-functions/functions/row_getToggleExpandedHandler.md index 22537d3c1f..effeb23262 100644 --- a/docs/reference/static-functions/functions/row_getToggleExpandedHandler.md +++ b/docs/reference/static-functions/functions/row_getToggleExpandedHandler.md @@ -9,7 +9,7 @@ title: row_getToggleExpandedHandler function row_getToggleExpandedHandler(row): () => void; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:423](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L423) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:444](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L444) Creates a row control handler that toggles this row's expanded state. diff --git a/docs/reference/static-functions/functions/row_toggleExpanded.md b/docs/reference/static-functions/functions/row_toggleExpanded.md index 2a262152d8..a52109d070 100644 --- a/docs/reference/static-functions/functions/row_toggleExpanded.md +++ b/docs/reference/static-functions/functions/row_toggleExpanded.md @@ -9,7 +9,7 @@ title: row_toggleExpanded function row_toggleExpanded(row, expanded?): void; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:284](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L284) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:305](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L305) Expands or collapses this row. diff --git a/docs/reference/static-functions/functions/table_getCanSomeRowsExpand.md b/docs/reference/static-functions/functions/table_getCanSomeRowsExpand.md index 8d8555ce3d..70277c6180 100644 --- a/docs/reference/static-functions/functions/table_getCanSomeRowsExpand.md +++ b/docs/reference/static-functions/functions/table_getCanSomeRowsExpand.md @@ -9,7 +9,7 @@ title: table_getCanSomeRowsExpand function table_getCanSomeRowsExpand(table): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:145](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L145) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:166](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L166) Checks whether at least one pre-paginated row can expand. diff --git a/docs/reference/static-functions/functions/table_getExpandedDepth.md b/docs/reference/static-functions/functions/table_getExpandedDepth.md index b65b28ec64..bf62fd7183 100644 --- a/docs/reference/static-functions/functions/table_getExpandedDepth.md +++ b/docs/reference/static-functions/functions/table_getExpandedDepth.md @@ -9,7 +9,7 @@ title: table_getExpandedDepth function table_getExpandedDepth(table): number; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:245](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L245) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:266](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L266) Computes the deepest expanded row id depth. diff --git a/docs/reference/static-functions/functions/table_getIsAllRowsExpanded.md b/docs/reference/static-functions/functions/table_getIsAllRowsExpanded.md index a0b395cf8d..f78bd6338b 100644 --- a/docs/reference/static-functions/functions/table_getIsAllRowsExpanded.md +++ b/docs/reference/static-functions/functions/table_getIsAllRowsExpanded.md @@ -9,7 +9,7 @@ title: table_getIsAllRowsExpanded function table_getIsAllRowsExpanded(table): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:202](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L202) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:223](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L223) Checks whether every expandable row in the current row model is expanded. diff --git a/docs/reference/static-functions/functions/table_getIsSomeRowsExpanded.md b/docs/reference/static-functions/functions/table_getIsSomeRowsExpanded.md index a65d9d6348..d2658f53cd 100644 --- a/docs/reference/static-functions/functions/table_getIsSomeRowsExpanded.md +++ b/docs/reference/static-functions/functions/table_getIsSomeRowsExpanded.md @@ -9,7 +9,7 @@ title: table_getIsSomeRowsExpanded function table_getIsSomeRowsExpanded(table): boolean; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:181](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L181) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:202](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L202) Checks whether any row is expanded. diff --git a/docs/reference/static-functions/functions/table_getToggleAllRowsExpandedHandler.md b/docs/reference/static-functions/functions/table_getToggleAllRowsExpandedHandler.md index 0d25c3b2b2..6a35d3877d 100644 --- a/docs/reference/static-functions/functions/table_getToggleAllRowsExpandedHandler.md +++ b/docs/reference/static-functions/functions/table_getToggleAllRowsExpandedHandler.md @@ -9,7 +9,7 @@ title: table_getToggleAllRowsExpandedHandler function table_getToggleAllRowsExpandedHandler(table): (_e) => void; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:162](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L162) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:183](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L183) Creates an event handler that toggles all rows expanded. diff --git a/docs/reference/static-functions/functions/table_resetExpanded.md b/docs/reference/static-functions/functions/table_resetExpanded.md index f90d997ebc..e878740d5d 100644 --- a/docs/reference/static-functions/functions/table_resetExpanded.md +++ b/docs/reference/static-functions/functions/table_resetExpanded.md @@ -9,13 +9,17 @@ title: table_resetExpanded function table_resetExpanded(table, defaultState?): void; ``` -Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:116](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L116) +Defined in: [features/row-expanding/rowExpandingFeature.utils.ts:120](https://github.com/TanStack/table/blob/main/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts#L120) Resets `expanded` to the configured initial state or feature default. With no argument, the reset clones `table.initialState.expanded` when it exists. Passing `true` ignores initial state and resets to `{}`. +The call is a no-op (no `onExpandedChange`) when the target state already +matches the current state, so an auto-reset on a table with nothing expanded +does not publish a new-but-equal map. + ## Type Parameters ### TFeatures diff --git a/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts b/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts index 832d98138f..aa17e450be 100644 --- a/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts +++ b/packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts @@ -107,6 +107,10 @@ export function table_toggleAllRowsExpanded< * With no argument, the reset clones `table.initialState.expanded` when it * exists. Passing `true` ignores initial state and resets to `{}`. * + * The call is a no-op (no `onExpandedChange`) when the target state already + * matches the current state, so an auto-reset on a table with nothing expanded + * does not publish a new-but-equal map. + * * @example * ```ts * table_resetExpanded(table) @@ -117,18 +121,35 @@ export function table_resetExpanded< TFeatures extends TableFeatures, TData extends RowData, >(table: Table_Internal, defaultState?: boolean) { + const currentExpanded = table.atoms.expanded?.get() ?? {} const initialExpanded = table.initialState.expanded - table_setExpanded( - table, - defaultState - ? makeObjectMap() - : initialExpanded === true - ? true - : Object.assign( - makeObjectMap(), - cloneState(initialExpanded ?? {}), - ), - ) + const newExpanded: ExpandedState = defaultState + ? makeObjectMap() + : initialExpanded === true + ? true + : Object.assign( + makeObjectMap(), + cloneState(initialExpanded ?? {}), + ) + + if (isSameExpandedState(currentExpanded, newExpanded)) return + + table_setExpanded(table, newExpanded) +} + +function isSameExpandedState(a: ExpandedState, b: ExpandedState): boolean { + if (a === true || b === true) return a === b + + const aKeys = Object.keys(a) + + if (aKeys.length !== Object.keys(b).length) return false + + for (let i = 0; i < aKeys.length; i++) { + const key = aKeys[i]! + if (!hasOwn(b, key) || a[key] !== b[key]) return false + } + + return true } /** diff --git a/packages/table-core/tests/unit/features/row-expanding/rowExpandingFeature.utils.test.ts b/packages/table-core/tests/unit/features/row-expanding/rowExpandingFeature.utils.test.ts index edf1aeeb5b..067393c44b 100644 --- a/packages/table-core/tests/unit/features/row-expanding/rowExpandingFeature.utils.test.ts +++ b/packages/table-core/tests/unit/features/row-expanding/rowExpandingFeature.utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { constructTable, createExpandedRowModel, + functionalUpdate, rowExpandingFeature, } from '../../../../src' import { @@ -25,7 +26,12 @@ import { testFeatures } from '../../../fixtures/features' import { generateTestColumnDefs } from '../../../fixtures/data/generateTestColumnDefs' import { generateTestData } from '../../../fixtures/data/generateTestData' import { getUpdaterResult } from '../../../helpers/testUtils' -import type { ExpandedState, Table, TableOptions } from '../../../../src' +import type { + ExpandedState, + Table, + TableOptions, + Updater, +} from '../../../../src' import type { Person } from '../../../fixtures/data/types' const features = testFeatures({ @@ -88,6 +94,7 @@ describe('table_resetExpanded', () => { onExpandedChange, initialState: { expanded: { '0': true } }, }) + table.baseAtoms.expanded.set({ '0': true, '1': true }) table_resetExpanded(table) @@ -100,11 +107,67 @@ describe('table_resetExpanded', () => { onExpandedChange, initialState: { expanded: true }, }) + table.baseAtoms.expanded.set({ '0': true }) table_resetExpanded(table) expect(onExpandedChange).toHaveBeenCalledWith(true) }) + + it('should reset when the expanded ids differ at the same count', () => { + const onExpandedChange = vi.fn() + const table = makeTable({ + onExpandedChange, + initialState: { expanded: { '0': true } }, + }) + table.baseAtoms.expanded.set({ '1': true }) + + table_resetExpanded(table) + + expect(onExpandedChange).toHaveBeenCalledWith({ '0': true }) + }) + + it('should be a no-op when nothing is expanded', () => { + const onExpandedChange = vi.fn() + const table = makeTable({ onExpandedChange }) + + table_resetExpanded(table) + + expect(onExpandedChange).not.toHaveBeenCalled() + }) + + it('should be a no-op when the expanded map is already at the target', () => { + const onExpandedChange = vi.fn() + const table = makeTable({ + onExpandedChange, + initialState: { expanded: { '0': true } }, + }) + + table_resetExpanded(table) + + expect(onExpandedChange).not.toHaveBeenCalled() + }) + + it('should be a no-op when already in the expanded-all initial state', () => { + const onExpandedChange = vi.fn() + const table = makeTable({ + onExpandedChange, + initialState: { expanded: true }, + }) + + table_resetExpanded(table) + + expect(onExpandedChange).not.toHaveBeenCalled() + }) + + it('should be a no-op when defaultState is true and nothing is expanded', () => { + const onExpandedChange = vi.fn() + const table = makeTable({ onExpandedChange }) + + table_resetExpanded(table, true) + + expect(onExpandedChange).not.toHaveBeenCalled() + }) }) describe('table_toggleAllRowsExpanded', () => { @@ -538,4 +601,32 @@ describe('table_autoResetExpanded', () => { expect(onExpandedChange).not.toHaveBeenCalled() }) + + it('should not loop when a data identity change resets controlled state that already matches', async () => { + // Stands in for a framework render loop: the auto-reset publishes expanded + // state, the consumer re-renders with a fresh `data` reference, and the core + // row model recomputes and auto-resets again + let expanded: ExpandedState = {} + let table: Table + const onExpandedChange = vi.fn((updater: Updater) => { + expanded = functionalUpdate(updater, expanded) + // stop feeding the loop so a regression fails the assertion below + // instead of hanging the suite + if (onExpandedChange.mock.calls.length > 5) return + table.setOptions((prev) => ({ + ...prev, + data: [...prev.data], + state: { ...prev.state, expanded }, + })) + table.getCoreRowModel() + }) + table = makeTable({ state: { expanded }, onExpandedChange }) + table.getCoreRowModel() + + table.setOptions((prev) => ({ ...prev, data: [...prev.data] })) + table.getCoreRowModel() + await flushMicrotasks() + + expect(onExpandedChange).not.toHaveBeenCalled() + }) })