From b0a5c8b6cf5522de617811b0ce6886db147d4091 Mon Sep 17 00:00:00 2001 From: gorkemkaramolla Date: Sun, 11 Oct 2026 21:02:00 +0300 Subject: [PATCH 1/2] fix(react-table, preact-table): refresh AppTable context under cached elements useAppTable builds AppTable once and provides the table from a ref while it renders. When the owner's output is cached (React Compiler, useMemo, memo), the framework skips the stable AppTable element, so its provider keeps the table from an earlier render. Replacing data externally (state, query refetch, websocket) changes no state slice, so useTableContext() consumers kept showing stale rows and Subscribe could not catch it. AppTable now subscribes to the owner's commits and re-renders only when the table it last provided is outdated, so the uncached path renders no extra times. Its component identity stays stable to keep subtrees mounted. Refs #6601 --- .changeset/fix-apptable-stale-context.md | 6 ++ docs/framework/react/guide/react-compiler.md | 2 + packages/preact-table/src/createTableHook.tsx | 37 +++++++++- .../tests/unit/createTableHook.test.tsx | 72 ++++++++++++++++++- packages/react-table/src/createTableHook.tsx | 38 +++++++++- .../tests/createTableHook.test.tsx | 70 ++++++++++++++++++ 6 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-apptable-stale-context.md diff --git a/.changeset/fix-apptable-stale-context.md b/.changeset/fix-apptable-stale-context.md new file mode 100644 index 0000000000..45e5e7c607 --- /dev/null +++ b/.changeset/fix-apptable-stale-context.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-table': patch +'@tanstack/preact-table': patch +--- + +Refresh the `AppTable` context when the `useAppTable` owner renders with new options or state, so `useTableContext()` consumers no longer show stale rows after `data` is replaced under a cached (React Compiler, `useMemo`, `memo`) `AppTable` element. diff --git a/docs/framework/react/guide/react-compiler.md b/docs/framework/react/guide/react-compiler.md index 3542069609..41179e5aec 100644 --- a/docs/framework/react/guide/react-compiler.md +++ b/docs/framework/react/guide/react-compiler.md @@ -192,6 +192,8 @@ function TableBody({ table }) { The selector must include every state slice that can change the result of `table.getRowModel()` for this table. That may include filtering, sorting, grouping, expansion, or pagination, depending on the features in use. +State is not the only input to `table.getRowModel()`. Replacing `data` from outside the table, such as a `setState` call, a query refetch, or a WebSocket message, does not change any state slice, so a `Subscribe` selector cannot detect it. When the body reads the table from `useTableContext()` inside ``, `AppTable` provides a new table value whenever the owner of `useAppTable` renders with new options, so the body updates without extra wiring. When you pass `table` as a prop through components that the compiler may skip, also pass `data` (or the rows you render) as a prop so the body renders again when the data changes. + Use `table.Subscribe` when `table` is the React-facing value returned by `useTable`. In a cell or header render context, `table` is the core Table instead. Import the standalone `Subscribe` component and pass `table.store` or one of the values in `table.atoms` as its source. Do not add a subscription to every cell by default. Add one when a nested component reads state that is not represented by its props, or when profiling shows that moving an update lower in the tree would help. diff --git a/packages/preact-table/src/createTableHook.tsx b/packages/preact-table/src/createTableHook.tsx index 1702aa9b18..eec306dfc6 100644 --- a/packages/preact-table/src/createTableHook.tsx +++ b/packages/preact-table/src/createTableHook.tsx @@ -1,6 +1,14 @@ 'use client' import { createContext } from 'preact' -import { useContext, useMemo, useRef } from 'preact/hooks' +import { + useContext, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'preact/hooks' import { createColumnHelper as coreCreateColumnHelper } from '@tanstack/table-core' import { useTable } from './useTable' import { FlexRender } from './FlexRender' @@ -30,6 +38,9 @@ import type { import type { ComponentChildren, ComponentType, Context } from 'preact' import type { PreactTable } from './useTable' +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + const sharedTableContext = createContext | null>(null) const sharedCellContext = createContext | null>(null) const sharedHeaderContext = createContext | null>(null) @@ -963,6 +974,15 @@ export function createTableHook< const tableRef = useRef(table) tableRef.current = table + // When the owner's output is cached (`useMemo`, `memo`), Preact skips the + // stable `AppTable` vnode, so its provider would keep the table from an + // earlier render. AppTable subscribes to owner commits and re-renders only + // when the table it last provided is outdated. + const [tableListeners] = useState(() => new Set<() => void>()) + useIsomorphicLayoutEffect(() => { + tableListeners.forEach((listener) => listener()) + }, [table]) + // AppTable - Root wrapper that provides table context with optional Subscribe const AppTable = useMemo(() => { function AppTableImpl( @@ -978,6 +998,21 @@ export function createTableHook< ): ComponentChildren { const { children, selector: appTableSelector } = props as any const currentTable = tableRef.current + const providedTableRef = useRef(currentTable) + providedTableRef.current = currentTable + const [, forceRender] = useReducer((count: number) => count + 1, 0) + + useIsomorphicLayoutEffect(() => { + const listener = () => { + if (providedTableRef.current !== tableRef.current) { + forceRender(undefined) + } + } + tableListeners.add(listener) + return () => { + tableListeners.delete(listener) + } + }, []) return ( diff --git a/packages/preact-table/tests/unit/createTableHook.test.tsx b/packages/preact-table/tests/unit/createTableHook.test.tsx index fc4914a364..d573b94f1f 100644 --- a/packages/preact-table/tests/unit/createTableHook.test.tsx +++ b/packages/preact-table/tests/unit/createTableHook.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/preact' -import { useEffect, useState } from 'preact/hooks' +import { useEffect, useMemo, useState } from 'preact/hooks' import { act } from 'preact/test-utils' import { rowSelectionFeature, tableFeatures } from '@tanstack/table-core' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -216,6 +216,76 @@ describe('createTableHook runtime', () => { expect(unmountCaptor).not.toHaveBeenCalled() }) + test.each([ + ['cached', true], + ['uncached', false], + ])( + 'refreshes the AppTable context once when the owner replaces data under a %s element', + (_label, cacheTree) => { + const renderCaptor = vi.fn() + + function RowNames() { + const table = appTable.useTableContext() + renderCaptor() + + return ( + + {table + .getRowModel() + .rows.map((row) => row.original.name) + .join(',')} + + ) + } + + function TableHarness() { + const [data, setData] = useState([{ id: '1', name: 'Ada' }]) + const table = appTable.useAppTable( + { data, columns, getRowId: (row) => row.id }, + () => null, + ) + // The React Compiler caches this element because `table.AppTable` is + // stable, so React skips AppTable when only `data` changes. + const cachedTree = useMemo( + () => ( + + + + ), + [table.AppTable], + ) + const tree = cacheTree ? ( + cachedTree + ) : ( + + + + ) + + return ( + <> + {tree} + + + ) + } + + render() + + expect(outputText('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Replace data') + }) + + expect(outputText('Context row names')).toBe('Grace') + expect(renderCaptor).toHaveBeenCalledTimes(2) + }, + ) + test('throws focused errors when context hooks are used outside wrappers', () => { function MissingTableContext() { appTable.useTableContext() diff --git a/packages/react-table/src/createTableHook.tsx b/packages/react-table/src/createTableHook.tsx index cceefdb70a..624a8b0bf7 100644 --- a/packages/react-table/src/createTableHook.tsx +++ b/packages/react-table/src/createTableHook.tsx @@ -1,6 +1,15 @@ 'use client' /* eslint-disable @eslint-react/no-context-provider */ -import React, { createContext, useContext, useMemo, useRef } from 'react' +import React, { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' import { createColumnHelper as coreCreateColumnHelper } from '@tanstack/table-core' import { useTable } from './useTable' import { FlexRender } from './FlexRender' @@ -30,6 +39,9 @@ import type { import type { ComponentType, Context, ReactNode } from 'react' import type { ReactTable } from './useTable' +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + // eslint-disable-next-line @eslint-react/naming-convention-context-name const sharedTableContext = createContext | null>(null) // eslint-disable-next-line @eslint-react/naming-convention-context-name @@ -967,6 +979,21 @@ export function createTableHook< const tableRef = useRef(table) tableRef.current = table + // When the owner's output is cached (React Compiler, `useMemo`, `memo`), + // React skips the stable `AppTable` element, so its provider would keep + // the table from an earlier render. AppTable subscribes to owner commits + // and re-renders only when the table it last provided is outdated. + const [tableListeners] = useState(() => new Set<() => void>()) + const [subscribeToTable] = useState(() => (listener: () => void) => { + tableListeners.add(listener) + return () => { + tableListeners.delete(listener) + } + }) + useIsomorphicLayoutEffect(() => { + tableListeners.forEach((listener) => listener()) + }, [table]) + // AppTable - Root wrapper that provides table context with optional Subscribe const AppTable = useMemo(() => { function AppTableImpl(props: AppTablePropsWithoutSelector): ReactNode @@ -979,7 +1006,12 @@ export function createTableHook< | AppTablePropsWithSelector, ): ReactNode { const { children, selector: appTableSelector } = props - const currentTable = tableRef.current + const getTable = () => tableRef.current + const currentTable = useSyncExternalStore( + subscribeToTable, + getTable, + getTable, + ) return ( @@ -996,7 +1028,7 @@ export function createTableHook< ) } return AppTableImpl as AppTableComponent - }, []) + }, [subscribeToTable]) // AppCell - Wraps cell with context, pre-bound cellComponents, and optional Subscribe const AppCell = useMemo(() => { diff --git a/packages/react-table/tests/createTableHook.test.tsx b/packages/react-table/tests/createTableHook.test.tsx index d5f7fe966e..2ed2998dba 100644 --- a/packages/react-table/tests/createTableHook.test.tsx +++ b/packages/react-table/tests/createTableHook.test.tsx @@ -215,6 +215,76 @@ describe('createTableHook runtime', () => { expect(unmountCaptor).not.toHaveBeenCalled() }) + test.each([ + ['cached', true], + ['uncached', false], + ])( + 'refreshes the AppTable context once when the owner replaces data under a %s element', + (_label, cacheTree) => { + const renderCaptor = vi.fn() + + function RowNames() { + const table = appTable.useTableContext() + renderCaptor() + + return ( + + {table + .getRowModel() + .rows.map((row) => row.original.name) + .join(',')} + + ) + } + + function TableHarness() { + const [data, setData] = React.useState([{ id: '1', name: 'Ada' }]) + const table = appTable.useAppTable( + { data, columns, getRowId: (row) => row.id }, + () => null, + ) + // The React Compiler caches this element because `table.AppTable` is + // stable, so React skips AppTable when only `data` changes. + const cachedTree = React.useMemo( + () => ( + + + + ), + [table.AppTable], + ) + const tree = cacheTree ? ( + cachedTree + ) : ( + + + + ) + + return ( + <> + {tree} + + + ) + } + + render() + + expect(text('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Replace data') + }) + + expect(text('Context row names')).toBe('Grace') + expect(renderCaptor).toHaveBeenCalledTimes(2) + }, + ) + test('throws focused errors when context hooks are used outside wrappers', () => { function MissingTableContext() { appTable.useTableContext() From 564ab96ea70af7fbb8c3b74b6eacd47f99ed2bac Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Mon, 12 Oct 2026 10:07:19 -0500 Subject: [PATCH 2/2] fix(react-table, preact-table): memoize merged useAppTable options `useAppTable` spread `defaultTableOptions` and the caller's options into a new object on every render, so `useTable` returned a new table each time. With AppTable now refreshing its context whenever the table changes, every unrelated owner render re-rendered all `useTableContext()` consumers, even under a cached AppTable element with memoized options. Memoize the merged options on the caller's options so stable options keep the table, and the context, stable. Co-Authored-By: Claude Opus 5.5 --- packages/preact-table/src/createTableHook.tsx | 14 +++-- .../tests/unit/createTableHook.test.tsx | 61 +++++++++++++++++++ packages/react-table/src/createTableHook.tsx | 14 +++-- .../tests/createTableHook.test.tsx | 61 +++++++++++++++++++ 4 files changed, 140 insertions(+), 10 deletions(-) diff --git a/packages/preact-table/src/createTableHook.tsx b/packages/preact-table/src/createTableHook.tsx index eec306dfc6..bedce319ff 100644 --- a/packages/preact-table/src/createTableHook.tsx +++ b/packages/preact-table/src/createTableHook.tsx @@ -955,12 +955,16 @@ export function createTableHook< TCellComponents, THeaderComponents > { - // Merge default options with provided options (provided takes precedence) + // Merge default options with provided options (provided takes precedence). + // Memoize the merge so stable caller options keep `table` stable; otherwise + // every owner render would refresh the AppTable context and re-render all + // `useTableContext()` consumers, even under a cached AppTable element. + const mergedTableOptions = useMemo( + () => ({ ...defaultTableOptions, ...tableOptions }), + [tableOptions], + ) const table = useTable( - { ...defaultTableOptions, ...tableOptions } as TableOptions< - TFeatures, - TData - >, + mergedTableOptions as TableOptions, selector, ) diff --git a/packages/preact-table/tests/unit/createTableHook.test.tsx b/packages/preact-table/tests/unit/createTableHook.test.tsx index d573b94f1f..0864c53f82 100644 --- a/packages/preact-table/tests/unit/createTableHook.test.tsx +++ b/packages/preact-table/tests/unit/createTableHook.test.tsx @@ -286,6 +286,67 @@ describe('createTableHook runtime', () => { }, ) + test('keeps the AppTable context stable when the owner re-renders with the same options', () => { + const renderCaptor = vi.fn() + + function RowNames() { + const table = appTable.useTableContext() + renderCaptor() + + return ( + + {table + .getRowModel() + .rows.map((row) => row.original.name) + .join(',')} + + ) + } + + const data = [{ id: '1', name: 'Ada' }] + + function TableHarness() { + const [count, setCount] = useState(0) + // The React Compiler memoizes these options because their inputs are + // stable, so an unrelated owner render should not refresh the context. + const tableOptions = useMemo( + () => ({ data, columns, getRowId: (row: Person) => row.id }), + [], + ) + const table = appTable.useAppTable(tableOptions, () => null) + const cachedTree = useMemo( + () => ( + + + + ), + [table.AppTable], + ) + + return ( + <> + {cachedTree} + + + ) + } + + render() + + expect(outputText('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Render owner') + }) + act(() => { + click('Render owner') + }) + + expect(outputText('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + }) + test('throws focused errors when context hooks are used outside wrappers', () => { function MissingTableContext() { appTable.useTableContext() diff --git a/packages/react-table/src/createTableHook.tsx b/packages/react-table/src/createTableHook.tsx index 624a8b0bf7..2a6389edda 100644 --- a/packages/react-table/src/createTableHook.tsx +++ b/packages/react-table/src/createTableHook.tsx @@ -960,12 +960,16 @@ export function createTableHook< TCellComponents, THeaderComponents > { - // Merge default options with provided options (provided takes precedence) + // Merge default options with provided options (provided takes precedence). + // Memoize the merge so stable caller options keep `table` stable; otherwise + // every owner render would refresh the AppTable context and re-render all + // `useTableContext()` consumers, even under a cached AppTable element. + const mergedTableOptions = useMemo( + () => ({ ...defaultTableOptions, ...tableOptions }), + [tableOptions], + ) const table = useTable( - { ...defaultTableOptions, ...tableOptions } as TableOptions< - TFeatures, - TData - >, + mergedTableOptions as TableOptions, selector, ) diff --git a/packages/react-table/tests/createTableHook.test.tsx b/packages/react-table/tests/createTableHook.test.tsx index 2ed2998dba..bcfc05ca37 100644 --- a/packages/react-table/tests/createTableHook.test.tsx +++ b/packages/react-table/tests/createTableHook.test.tsx @@ -285,6 +285,67 @@ describe('createTableHook runtime', () => { }, ) + test('keeps the AppTable context stable when the owner re-renders with the same options', () => { + const renderCaptor = vi.fn() + + function RowNames() { + const table = appTable.useTableContext() + renderCaptor() + + return ( + + {table + .getRowModel() + .rows.map((row) => row.original.name) + .join(',')} + + ) + } + + const data = [{ id: '1', name: 'Ada' }] + + function TableHarness() { + const [count, setCount] = React.useState(0) + // The React Compiler memoizes these options because their inputs are + // stable, so an unrelated owner render should not refresh the context. + const tableOptions = React.useMemo( + () => ({ data, columns, getRowId: (row: Person) => row.id }), + [], + ) + const table = appTable.useAppTable(tableOptions, () => null) + const cachedTree = React.useMemo( + () => ( + + + + ), + [table.AppTable], + ) + + return ( + <> + {cachedTree} + + + ) + } + + render() + + expect(text('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Render owner') + }) + act(() => { + click('Render owner') + }) + + expect(text('Context row names')).toBe('Ada') + expect(renderCaptor).toHaveBeenCalledTimes(1) + }) + test('throws focused errors when context hooks are used outside wrappers', () => { function MissingTableContext() { appTable.useTableContext()