Skip to content

Commit bdce331

Browse files
committed
fix(data-table): a11y improvements, store hardening, and server hook race condition
- Replace use() with useContext() for DataTableRenderKeyContext to fix SSR context subscription bug where use() fails to register subscriptions after React Router SSR hydration, causing sort/pagination/page-size changes to not re-render components - Add _version monotonic counter to store state, incremented on every setState, powering DataTableRenderKeyContext for table-dependent hooks - Wrap providers (client + server) with DataTableRenderKeyContext - Rewrite useDataTableSelection, useDataTablePagination, useDataTableRows to use useRenderKey() + direct table reads instead of useSliceSelector - Fix pagination active button using disabled styling instead of full opacity (aria-disabled + pointer-events-none instead of disabled prop)
1 parent 2d60c15 commit bdce331

9 files changed

Lines changed: 64 additions & 50 deletions

File tree

packages/datum-ui/src/components/features/data-table/__tests__/server-provider.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/// <reference types="@testing-library/jest-dom/vitest" />
22
import { render, screen } from '@testing-library/react'
33
import { describe, expect, it, vi } from 'vitest'
4-
import { ServerProvider } from '../core/server-provider'
54
import { useDataTableStore } from '../core/data-table-context'
5+
import { ServerProvider } from '../core/server-provider'
66
import { useDataTableLoading } from '../hooks/use-selectors'
77

88
interface TestRow {

packages/datum-ui/src/components/features/data-table/__tests__/use-selectors.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,3 @@ describe('useDataTableLoading', () => {
104104
expect(renderCount.current).toBe(countBefore)
105105
})
106106
})
107-

packages/datum-ui/src/components/features/data-table/components/pagination.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,9 @@ export function DataTablePagination({
148148
key={page}
149149
theme={isActive ? 'solid' : 'outline'}
150150
size="small"
151-
className={cn('h-8 min-w-8 px-2', isActive && 'font-semibold')}
151+
className={cn('h-8 min-w-8 px-2', isActive && 'pointer-events-none font-semibold')}
152152
onClick={() => setPageIndex(page - 1)}
153-
disabled={isActive}
153+
aria-disabled={isActive || undefined}
154154
aria-label={`Page ${page}`}
155155
aria-current={isActive ? 'page' : undefined}
156156
>

packages/datum-ui/src/components/features/data-table/core/client-provider.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
44
import type { UseDataTableClientOptions } from '../hooks/use-data-table-client'
55
import { useDataTableClient } from '../hooks/use-data-table-client'
66
import { useIsClient } from '../hooks/use-is-client'
7-
import { DataTableStoreContext, TableInstanceContext } from './data-table-context'
7+
import { DataTableRenderKeyContext, DataTableStoreContext, TableInstanceContext } from './data-table-context'
88

99
export type DataTableClientProviderProps<TData> = UseDataTableClientOptions<TData> & {
1010
readonly className?: string
@@ -27,7 +27,9 @@ function ClientProviderInner<TData>({
2727
return (
2828
<DataTableStoreContext value={store}>
2929
<TableInstanceContext value={table}>
30-
<div className={className}>{children}</div>
30+
<DataTableRenderKeyContext value={store.getSnapshot()._version}>
31+
<div className={className}>{children}</div>
32+
</DataTableRenderKeyContext>
3133
</TableInstanceContext>
3234
</DataTableStoreContext>
3335
)

packages/datum-ui/src/components/features/data-table/core/data-table-context.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,30 @@
22

33
import type { Table } from '@tanstack/react-table'
44
import type { DataTableContextValue, DataTableStore } from '../types'
5-
import { createContext, use } from 'react'
5+
import { createContext, use, useContext } from 'react' // eslint-disable-line react/no-use-context -- useContext needed: use() breaks context subscriptions after SSR hydration
66

77
export const DataTableStoreContext = createContext<DataTableStore<any> | null>(null)
88
export const TableInstanceContext = createContext<Table<any> | null>(null)
99

10+
/**
11+
* Monotonic counter that increments on every store mutation.
12+
* Table-dependent hooks consume this context to force re-renders
13+
* through React's {children} composition boundary, since the
14+
* mutable table singleton (stable ref) cannot trigger context updates.
15+
*/
16+
export const DataTableRenderKeyContext = createContext<number>(0)
17+
18+
/**
19+
* Forces a re-render when the store changes. Used by table-dependent hooks.
20+
* Uses useContext (not use()) because use() does not reliably register
21+
* context subscriptions in SSR/hydration scenarios (React Router SSR),
22+
* preventing re-renders when the context value changes.
23+
*/
24+
export function useRenderKey(): number {
25+
// eslint-disable-next-line react/no-use-context -- use() does not register context subscriptions after SSR hydration
26+
return useContext(DataTableRenderKeyContext)
27+
}
28+
1029
// Legacy context — retained for backward compat until providers are migrated (Task 4)
1130
export const DataTableContext = createContext<DataTableContextValue<any> | null>(null)
1231

packages/datum-ui/src/components/features/data-table/core/server-provider.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
44
import type { UseDataTableServerOptions } from '../hooks/use-data-table-server'
55
import { useDataTableServer } from '../hooks/use-data-table-server'
66
import { useIsClient } from '../hooks/use-is-client'
7-
import { DataTableStoreContext, TableInstanceContext } from './data-table-context'
7+
import { DataTableRenderKeyContext, DataTableStoreContext, TableInstanceContext } from './data-table-context'
88

99
export type DataTableServerProviderProps<TResponse, TData> = UseDataTableServerOptions<TResponse, TData> & {
1010
readonly className?: string
@@ -27,7 +27,9 @@ function ServerProviderInner<TResponse, TData>({
2727
return (
2828
<DataTableStoreContext value={store}>
2929
<TableInstanceContext value={table}>
30-
<div className={className}>{children}</div>
30+
<DataTableRenderKeyContext value={store.getSnapshot()._version}>
31+
<div className={className}>{children}</div>
32+
</DataTableRenderKeyContext>
3133
</TableInstanceContext>
3234
</DataTableStoreContext>
3335
)

packages/datum-ui/src/components/features/data-table/core/store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function createDataTableStore<TData>(
4343
isLoading: false,
4444
error: null,
4545
inlineContents: [],
46+
_version: 0,
4647
}
4748

4849
// Apply default filters on init if any exist (pageIndex already 0)
@@ -55,7 +56,7 @@ export function createDataTableStore<TData>(
5556
}
5657

5758
function setState(next: DataTableStoreState<TData>) {
58-
state = next
59+
state = { ...next, _version: state._version + 1 }
5960
notify()
6061
}
6162

packages/datum-ui/src/components/features/data-table/hooks/use-selectors.ts

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
'use client'
22

3-
import type { HeaderGroup, Row, RowSelectionState } from '@tanstack/react-table'
43
import type { DataTableStoreState, InlineContentEntry } from '../types'
54
import { useCallback, useRef, useSyncExternalStore } from 'react'
6-
import { useDataTableStore, useTableInstance } from '../core/data-table-context'
5+
import { useDataTableStore, useRenderKey, useTableInstance } from '../core/data-table-context'
76

87
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
98
const keysA = Object.keys(a)
@@ -84,58 +83,49 @@ export function useDataTableSorting() {
8483
}
8584

8685
export function useDataTableSelection<TData>() {
86+
useRenderKey() // re-render when store changes (table is mutable singleton)
8787
const store = useDataTableStore<TData>()
8888
const table = useTableInstance<TData>()
89-
return useSliceSelector<TData, {
90-
rowSelection: RowSelectionState
91-
setRowSelection: (selection: RowSelectionState) => void
92-
selectedRows: TData[]
93-
}>(
94-
useCallback((state: DataTableStoreState<TData>) => ({
95-
rowSelection: state.rowSelection,
96-
setRowSelection: store.setRowSelection,
97-
selectedRows: table.getFilteredSelectedRowModel().rows.map(r => r.original),
98-
}), [store, table]),
99-
)
89+
const state = store.getSnapshot()
90+
return {
91+
rowSelection: state.rowSelection,
92+
setRowSelection: store.setRowSelection,
93+
selectedRows: table.getFilteredSelectedRowModel().rows.map(r => r.original),
94+
}
10095
}
10196

10297
export function useDataTablePagination() {
98+
useRenderKey() // re-render when store changes (table is mutable singleton)
10399
const store = useDataTableStore()
104100
const table = useTableInstance()
101+
const state = store.getSnapshot()
105102

106-
// Stable function references — do not recreate on every snapshot call
103+
// Stable function references (table ref is stable from useReactTable)
107104
const nextPage = useCallback(() => table.nextPage(), [table])
108105
const prevPage = useCallback(() => table.previousPage(), [table])
109106

110-
return useSliceSelector(
111-
useCallback((state: DataTableStoreState<any>) => ({
112-
canNextPage: table.getCanNextPage(),
113-
canPrevPage: table.getCanPreviousPage(),
114-
nextPage,
115-
prevPage,
116-
pageIndex: state.pageIndex,
117-
pageCount: table.getPageCount(),
118-
setPageIndex: store.setPageIndex,
119-
pageSize: state.pageSize,
120-
setPageSize: store.setPageSize,
121-
totalRows: state.filteredData.length,
122-
}), [store, table, nextPage, prevPage]),
123-
)
107+
return {
108+
canNextPage: table.getCanNextPage(),
109+
canPrevPage: table.getCanPreviousPage(),
110+
nextPage,
111+
prevPage,
112+
pageIndex: state.pageIndex,
113+
pageCount: table.getPageCount(),
114+
setPageIndex: store.setPageIndex,
115+
pageSize: state.pageSize,
116+
setPageSize: store.setPageSize,
117+
totalRows: state.filteredData.length,
118+
}
124119
}
125120

126121
export function useDataTableRows<TData>() {
122+
useRenderKey() // re-render when store changes (table is mutable singleton)
127123
const table = useTableInstance<TData>()
128-
return useSliceSelector<TData, {
129-
rows: Row<TData>[]
130-
headerGroups: HeaderGroup<TData>[]
131-
totalColumns: number
132-
}>(
133-
useCallback((_state: DataTableStoreState<TData>) => ({
134-
rows: table.getRowModel().rows,
135-
headerGroups: table.getHeaderGroups(),
136-
totalColumns: table.getAllColumns().length,
137-
}), [table]),
138-
)
124+
return {
125+
rows: table.getRowModel().rows,
126+
headerGroups: table.getHeaderGroups(),
127+
totalColumns: table.getAllColumns().length,
128+
}
139129
}
140130

141131
export function useDataTableLoading() {
@@ -161,4 +151,3 @@ export function useDataTableInlineContents<TData>() {
161151
}), [store]),
162152
)
163153
}
164-

packages/datum-ui/src/components/features/data-table/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ export interface DataTableStoreState<TData> {
337337
readonly isLoading: boolean
338338
readonly error: Error | null
339339
readonly inlineContents: readonly InlineContentEntry<TData>[]
340+
/** Monotonic counter incremented on every state change. Used by table-dependent selectors to detect updates. */
341+
readonly _version: number
340342
}
341343

342344
export interface DataTableStore<TData> {

0 commit comments

Comments
 (0)