Skip to content

Commit ca62e80

Browse files
committed
Keep row actions menu open when table actions callback changes identity
1 parent c927f2f commit ca62e80

4 files changed

Lines changed: 94 additions & 14 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table'
9+
import { useCallback } from 'react'
10+
import { expect, test } from 'vitest'
11+
import { render } from 'vitest-browser-react'
12+
13+
import { Table } from '../Table'
14+
import { useColsWithActions, type MenuAction } from './action-col'
15+
16+
type Item = { id: string; name: string }
17+
18+
const data: Item[] = [{ id: '1', name: 'alpha' }]
19+
const staticCols: ColumnDef<Item>[] = [{ accessorKey: 'name', header: 'Name' }]
20+
21+
function ActionsTable({ version }: { version: number }) {
22+
// depends on `version` so its identity changes when the prop changes, like a
23+
// real page whose actions depend on a background query result
24+
const makeActions = useCallback(
25+
(item: Item): MenuAction[] => [
26+
{ label: `Rename ${item.name} v${version}`, onActivate: () => {} },
27+
],
28+
[version]
29+
)
30+
const columns = useColsWithActions(staticCols, makeActions)
31+
const table = useReactTable({
32+
columns,
33+
data,
34+
getCoreRowModel: getCoreRowModel(),
35+
getRowId: (row) => row.id,
36+
})
37+
return <Table aria-label="Items" table={table} />
38+
}
39+
40+
test('open row actions menu survives makeActions identity change', async () => {
41+
const screen = await render(<ActionsTable version={1} />)
42+
43+
await screen.getByRole('button', { name: 'Row actions' }).click()
44+
await expect
45+
.element(screen.getByRole('menuitem', { name: 'Rename alpha v1' }))
46+
.toBeVisible()
47+
48+
// simulates a query resolving and changing a dep of makeActions while the
49+
// menu is open. Before the fix, the cell remounted and the menu closed.
50+
await screen.rerender(<ActionsTable version={2} />)
51+
52+
await expect
53+
.element(screen.getByRole('menuitem', { name: 'Rename alpha v2' }))
54+
.toBeVisible()
55+
})

‎app/table/columns/action-col.tsx‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import type { ColumnDef } from '@tanstack/react-table'
8+
import type { CellContext, ColumnDef } from '@tanstack/react-table'
99
import cn from 'classnames'
1010
import { useMemo } from 'react'
1111

@@ -35,13 +35,15 @@ type MenuActionLink = MenuActionBase & {
3535
*/
3636
export type MenuAction = MenuActionItem | MenuActionLink
3737

38-
type MakeActions<Item> = (item: Item) => Array<MenuAction>
38+
export type MakeActions<Item> = (item: Item) => Array<MenuAction>
3939

40-
/** Convenience helper to combine regular cols with actions col and memoize */
40+
/**
41+
* Convenience helper to combine regular cols with actions col and memoize.
42+
* Memoizing `columns` and `makeActions` at the call site avoids re-renders but
43+
* is no longer required for correctness (see `ActionsCell`).
44+
*/
4145
export function useColsWithActions<TData extends Record<string, unknown>>(
42-
/** Should be static or memoized */
4346
columns: ColumnDef<TData, any>[], // eslint-disable-line @typescript-eslint/no-explicit-any
44-
/** Must be memoized to avoid re-renders */
4547
makeActions: MakeActions<TData>,
4648
copyIdLabel?: string
4749
) {
@@ -61,17 +63,34 @@ export const getActionsCol = <TData extends Record<string, unknown>>(
6163
meta: {
6264
thClassName: 'action-col',
6365
tdClassName: 'action-col',
66+
makeActions,
67+
copyIdLabel,
6468
},
65-
66-
cell: ({ row }) => {
67-
// TODO: control flow here has always confused me, would like to straighten it out
68-
const actions = makeActions(row.original)
69-
const id = typeof row.original.id === 'string' ? row.original.id : null
70-
return <RowActions id={id} actions={actions} copyIdLabel={copyIdLabel} />
71-
},
69+
cell: ActionsCell,
7270
}
7371
}
7472

73+
/**
74+
* `flexRender` renders a function `cell` as a component, so its identity is
75+
* the element type. Passing `makeActions` through `meta` instead of a closure
76+
* keeps the type stable when `makeActions` changes (e.g., a query it depends
77+
* on refetches), which would otherwise remount the cell and close an open menu.
78+
*
79+
* This is why polling used to close row action menus and why call sites had to
80+
* memoize `makeActions` and `columns` so carefully (#2112, #2460, #2816). Those
81+
* memoizations are now only a render-count optimization.
82+
*/
83+
function ActionsCell<TData extends Record<string, unknown>>({
84+
row,
85+
column,
86+
}: CellContext<TData, unknown>) {
87+
// TODO: control flow here has always confused me, would like to straighten it out
88+
const { makeActions, copyIdLabel } = column.columnDef.meta ?? {}
89+
const actions = makeActions?.(row.original)
90+
const id = typeof row.original.id === 'string' ? row.original.id : null
91+
return <RowActions id={id} actions={actions} copyIdLabel={copyIdLabel} />
92+
}
93+
7594
type RowActionsProps = {
7695
/** If `id` is provided, a `Copy ID` menu item will be automatically included. */
7796
id?: string | null

‎types/react-table.d.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import '@tanstack/react-table'
8+
import type { RowData } from '@tanstack/react-table'
9+
10+
import type { MakeActions } from '../app/table/columns/action-col'
911

1012
declare module '@tanstack/react-table' {
11-
interface ColumnMeta {
13+
interface ColumnMeta<TData extends RowData, TValue> {
1214
thClassName?: string
1315
tdClassName?: string
16+
/** Set by `getActionsCol`, read by `ActionsCell` */
17+
makeActions?: MakeActions<TData>
18+
copyIdLabel?: string
1419
}
1520
}

‎vitest.browser.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default defineConfig({
3232
optimizeDeps: {
3333
entries: ['app/**/*.browser.spec.{ts,tsx}', 'app/util/ip.spec.ts'],
3434
include: [
35+
'@base-ui/react/menu',
3536
'date-fns',
3637
'ip-num/IPNumber.js',
3738
'react-router',

0 commit comments

Comments
 (0)