Skip to content

Commit 7ac1e3c

Browse files
authored
Keep row actions menu open when table actions callback changes identity (#3364)
The Firefox run on the audit log merge to main failed `Silo IP pools` on all three attempts, each time in `clickRowAction` on the menu item, with Chrome and Safari passing: https://github.com/oxidecomputer/console/actions/runs/33767277998/attempts/1 I could not repro locally with the code as-is, but I can make it happen by adding a delay in the mock API (see below). `SiloIpPoolsTab` fetches the silo's pools twice: the paginated list that the table renders, and a second `useQuery` for all of them, which the "Make default" confirm modal uses to find the existing default for the same IP version and type so it can warn that it will be replaced. `makeActions` depends on that second result. The sequence in the test: 1. The test unlinks a pool, and the mutation's `onSuccess` invalidates both the paginated list and the all-pools list. 2. The test waits for the unlinked row to disappear, which happens as soon as the paginated refetch lands, and then opens the next row's menu. 3. If the all-pools refetch has already landed by then, nothing happens. If it lands after the menu is open, it gives `makeActions` a new identity. 4. `useColsWithActions` builds a new actions column with a new inline `cell` closure. 5. [`flexRender`](https://github.com/TanStack/table/blob/ab2819c/packages/react-table/src/index.tsx#L18-L27) renders a function `cell` as a component, so a new function is a new element type and React remounts the whole cell. 6. The open menu is unmounted along with the cell and comes back closed, so the menu item Playwright was about to click is gone. To confirm this, I added a 150ms delay to the mock all-pools response so it lands right around the time the test opens the menu. On main, the e2e test fails 5 out of 42 runs on Firefox with exactly the CI error, detached menu item and all, at the same line. With the fix and the same delay, it passes 24 out of 24. A longer delay like 1.5s makes the test pass again on main because Playwright opens the menu and clicks before the refetch lands, which is also why the test has been fine locally. With the 1.5s delay and a throwaway e2e test that holds the menu open across it, the menu closed before the fix and stayed open after. The fix is to make the more actions button cell a single module-level `ActionsCell` component and pass `makeActions` and `copyIdLabel` through column `meta` instead of closing over them in a dynamic cell definition. The element type is then stable no matter how often the callback or the columns array changes identity, so it fixes every table using `getActionsCol` without touching any call sites. The browser spec opens a row menu, rerenders with a new `makeActions`, and asserts the menu is still open. It failed in all three browsers before the fix. This is the same mechanism behind the polling-closes-the-menu problems in #2112, #2460, and #2816. Each of those was fixed by memoizing `makeActions` or the columns array carefully enough to keep the closure identity stable. With a stable cell component, that memoization is only a render-count optimization, so I softened the doc comments on `useColsWithActions`. The `instanceState` hack on NIC rows from #2460 could be unwound now, but I've left that for a followup.
1 parent c927f2f commit 7ac1e3c

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)