Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-apptable-stale-context.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/framework/react/guide/react-compiler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<table.AppTable>`, `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.
Expand Down
51 changes: 45 additions & 6 deletions packages/preact-table/src/createTableHook.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<PreactTable<any, any> | null>(null)
const sharedCellContext = createContext<Cell<any, any, any> | null>(null)
const sharedHeaderContext = createContext<Header<any, any, any> | null>(null)
Expand Down Expand Up @@ -944,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<TFeatures, TData, TSelected>(
{ ...defaultTableOptions, ...tableOptions } as TableOptions<
TFeatures,
TData
>,
mergedTableOptions as TableOptions<TFeatures, TData>,
selector,
)

Expand All @@ -963,6 +978,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(
Expand All @@ -978,6 +1002,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 (
<TableContext.Provider value={currentTable}>
Expand Down
133 changes: 132 additions & 1 deletion packages/preact-table/tests/unit/createTableHook.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -216,6 +216,137 @@ 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<Person>()
renderCaptor()

return (
<output aria-label="Context row names">
{table
.getRowModel()
.rows.map((row) => row.original.name)
.join(',')}
</output>
)
}

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>
<RowNames />
</table.AppTable>
),
[table.AppTable],
)
const tree = cacheTree ? (
cachedTree
) : (
<table.AppTable>
<RowNames />
</table.AppTable>
)

return (
<>
{tree}
<button onClick={() => setData([{ id: '1', name: 'Grace' }])}>
Replace data
</button>
</>
)
}

render(<TableHarness />)

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('keeps the AppTable context stable when the owner re-renders with the same options', () => {
const renderCaptor = vi.fn()

function RowNames() {
const table = appTable.useTableContext<Person>()
renderCaptor()

return (
<output aria-label="Context row names">
{table
.getRowModel()
.rows.map((row) => row.original.name)
.join(',')}
</output>
)
}

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>
<RowNames />
</table.AppTable>
),
[table.AppTable],
)

return (
<>
{cachedTree}
<button onClick={() => setCount(count + 1)}>Render owner</button>
</>
)
}

render(<TableHarness />)

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<Person>()
Expand Down
52 changes: 44 additions & 8 deletions packages/react-table/src/createTableHook.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<ReactTable<any, any> | null>(null)
// eslint-disable-next-line @eslint-react/naming-convention-context-name
Expand Down Expand Up @@ -948,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<TFeatures, TData, TSelected>(
{ ...defaultTableOptions, ...tableOptions } as TableOptions<
TFeatures,
TData
>,
mergedTableOptions as TableOptions<TFeatures, TData>,
selector,
)

Expand All @@ -967,6 +983,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
Expand All @@ -979,7 +1010,12 @@ export function createTableHook<
| AppTablePropsWithSelector<TFeatures, TAppTableSelected>,
): ReactNode {
const { children, selector: appTableSelector } = props
const currentTable = tableRef.current
const getTable = () => tableRef.current
const currentTable = useSyncExternalStore(
subscribeToTable,
getTable,
getTable,
)

return (
<TableContext.Provider value={currentTable}>
Expand All @@ -996,7 +1032,7 @@ export function createTableHook<
)
}
return AppTableImpl as AppTableComponent<TFeatures>
}, [])
}, [subscribeToTable])

// AppCell - Wraps cell with context, pre-bound cellComponents, and optional Subscribe
const AppCell = useMemo(() => {
Expand Down
Loading
Loading