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() + }) })