Skip to content

feat(ui): Row actions in the admin list view - #2260

Open
borisno2 wants to merge 5 commits into
mainfrom
claude/issue-2255-row-actions
Open

borisno2 wants to merge 5 commits into
mainfrom
claude/issue-2255-row-actions

Conversation

@borisno2

@borisno2 borisno2 commented Nov 7, 2026

Copy link
Copy Markdown
Member

Implements #2255
Part of #2246

Summary

Each row in the admin list view now has a menu of Row actions, which replaces the old per-row Edit link.

  • Built-ins: Edit, Copy link, and Delete. Delete asks for confirmation through ConfirmDialog. The row title (the label column, or the first non-relationship column when the label column is hidden) links to the item page.

  • Config: ui.listView.rowActions: [{ key, label, icon?, destructive?, hasAccess?, isAvailable?, handler }]. The new core types are RowAction, RowActionContext, RowActionResult and RowActionIcon. icon is a closed union of 16 names, and the UI maps each name to a lucide icon.

  • Server/client boundary: ListView resolves hasAccess once per request and runs the synchronous isAvailable on the server for each row. It sends rowActions (only key/label/icon/destructive) and rowActionKeys (the available keys, keyed by row id). Handlers and both checks never reach the client. isAvailable sees the row's own columns, foreign keys and computed fields, and no relations. That is the same shape as the dispatch re-read, so the menu and the dispatch decide on the same data.

  • Dispatch: a new serverAction operation, { listKey, action: 'rowAction', key, id }, returns { rowAction: true, message? } or { rowAction: false, error }. It never returns success, so the redirect-on-success wrapper does not intercept it. The steps are:

    1. Look the action up by key.
    2. Re-run hasAccess.
    3. Parse id through the list's id type.
    4. Re-read the row with context.db[listKey].where({ id: { equals } }).first().
    5. Re-run isAvailable on the re-read row.
    6. Only then run handler({ listKey, id, item, context }).

    A failure at step 2, 3, 4 (missing or denied) or 5 returns the same object, { rowAction: false, error: 'This action was not run' }, so a caller cannot tell a denied row from a missing one (Silent failure). Handler errors go through the existing clientFailure redaction.

  • Delete shows on the existing list-level rule (canDeleteList plus hideDelete). It dispatches a one-id bulkDelete so it gets { deleted, total } back rather than a redirecting success. A denied delete reports “<label>” not deleted with no reason.

  • Outcomes go to the existing status line (useBulkStatus, persisted in sessionStorage), so they survive the router.refresh().

  • Keyboard: the menu is the Radix DropdownMenu. Enter, Space or ArrowDown opens it, arrows/Home/End move, and Escape closes it and returns focus to the trigger. A confirmation opens only after the menu has handed focus back (onCloseAutoFocus), so cancelling the dialog also returns focus to the trigger.

  • starter-auth: adds a Publish now Row action, available only when status !== 'published'.

Overlap with #2259 (#2254 table refresh)

#2259 also adds packages/ui/src/primitives/dropdown-menu.tsx. This PR's copy of that file and of the primitives/index.ts export block is byte-identical to the one on claude/issue-2254-table-refresh, so whichever PR merges second should rebase cleanly on those files. This PR still keeps the sticky Actions column cell and only swaps its contents, which leaves the phone-card replacement to #2259.

E2E changes

Journeys that clicked the row's Edit link (02, 03, 05-item-view, 07) now click [data-slot="row-title-link"]. The new 10-row-actions.spec.ts covers:

  • opening the menu by keyboard, closing it with Escape, and focus returning to the trigger
  • running Publish now, with the status line reading "Post published", the status cell turning Published, and the action disappearing from the menu
  • deleting from the menu after confirming

Docs

  • docs/content/reference/ui.md: a new "Row actions" section with an options table, plus DropdownMenu in the primitives list.
  • docs/content/reference/context-api.md: a new rowAction section.
  • Changesets: minor for @opensaas/stack-core and @opensaas/stack-ui, and the required create-opensaas-app patch.

Test plan

  • Core server-actions.test.ts, against a Test context. It checks that the handler receives listKey/id/re-read item/secured context; that isAvailable is re-run on the re-read row; that a row that became denied returns exactly the same outcome as a missing row; that hasAccess is re-checked; that an invalid id and an unknown key are refused; and that a handler throw is redacted.
  • UI ListViewRowActions.test.tsx, a real ListView over createTestDatabase. It checks per-row availability from isAvailable, that isAvailable sees no relations, and that hasAccess drops actions and no functions are serialised.
  • UI RowActions.test.tsx. It checks the keyboard open/move/close and focus return (including after a cancelled confirm), per-row menu contents, dispatch payload and status line, the not-run message, delete confirm with "not deleted", hidden Delete, and Copy link.
  • pnpm build, UI and core tsc, starter-auth generate + tsc (generated artifacts unchanged)
  • pnpm lint, pnpm format:check, pnpm manypkg fix, check:scaffolder-changeset, check:doc-ts-blocks
  • Core suite: 2467 passed. UI suite: 1052/1053 passed in a full run. The one failure was a 5 s timeout in ListUiOptions.test.tsx while it booted a database under load, and that file passes on its own.
  • Playwright e2e: not run locally because the Chromium download is blocked in this sandbox, so CI is the first run of the new and edited journeys.

Generated by Claude Code

Each row gets a menu with the built-in Edit, Copy link and Delete, and the
row title links to the item page. A list declares its own Row actions in
`ui.listView.rowActions`: `hasAccess` is resolved once per request and the
synchronous `isAvailable` per row on the server, so only available keys
reach the client and handlers never do.

Dispatch is a new `serverAction` operation, `rowAction`, which re-reads the
row through the secured context and re-runs both checks before the handler
runs. A missing, denied or no-longer-eligible row answers the same "not run"
outcome. Outcomes use the list's existing status line.

Adds the DropdownMenu primitive (matching #2254's), a "Publish now" Row
action in starter-auth with an e2e journey, and moves existing e2e journeys
from the removed row Edit link to the row title link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Nov 7, 2026 11:53pm UTC

@changeset-bot

changeset-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ac0817e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-ui Minor
create-opensaas-app Patch
@opensaas/stack-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/core/src/context/index.ts Resolved
Comment thread packages/core/src/context/index.ts Resolved
Comment thread packages/ui/src/components/ListView.tsx Resolved
Comment thread packages/ui/src/components/ListView.tsx Resolved
Comment thread packages/ui/src/components/ListView.tsx Resolved

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES. This is posted as a COMMENT because GitHub won't let this identity request changes on its own PR.

The dispatch design is sound. rowAction re-reads the row through the secured context.db and re-runs hasAccess and isAvailable before the handler. A missing, denied, unparseable or ineligible row all return the same notRun object, and a key that was never configured is refused. Some things need fixing before merge. The details are in the inline comments.

Must fix

  1. packages/core/src/context/index.ts ~L976: the handler receives the raw wire string props.id, not the parsed id. On an int autoincrement list, the documented update({ where: { id } }) gets the string "42" and fails as 'Action failed'. Pass the parsed id, and type RowActionContext.id from the contract rather than as string.
  2. packages/ui/src/components/ListViewClient.tsx ~L74: parseRowActionResult uses a type cast (result as {...}), which the repo rules forbid. It also duplicates parseBulkActionResult, so share one parser.
  3. packages/ui/src/components/RowActions.tsx ~L113: run has no catch, so a rejected server action (stale action id after a deploy, or offline) fails silently with no status line.

Should fix
4. packages/core/src/context/index.ts ~L971: the re-check and the handler are not atomic. A double-click or two concurrent admins can both pass isAvailable on a one-shot action. Either run the check and the handler in context.transaction with .forUpdate(), or stop the docs implying that isAvailable guards against stale pages under concurrency.
5. packages/ui/src/components/ListView.tsx ~L139: isAvailable sees computed fields worked out under the list view's own includes, while dispatch's .first() reads the row without them. The menu and dispatch can then disagree: actions are hidden that would run, or shown and then always 'not run'.
6. packages/ui/src/components/ListView.tsx ~L86: errors are handled inconsistently. A throwing hasAccess crashes the list page, a throwing isAvailable is swallowed in the list, and at dispatch a throw returns 'Action failed' instead of the uniform not-run response. The last one weakens the indistinguishability guarantee.
7. packages/ui/src/components/ListViewClient.tsx ~L358: deleting from the row menu only calls refresh. Unlike bulk delete, it does not step back a page, so deleting the last row on the last page leaves an empty 'page 2 of 1'.
8. packages/ui/src/components/ListViewClient.tsx ~L588: the title cell is wrapped in <Link>, which nests anchors when a custom ui.cell renders its own <a>.

Nits: titleColumn sits between renderCell and its TSDoc, so the docblock now attaches to the wrong symbol (L403). resolveVisibleRowActions and serializeRowAction duplicate the bulk-action resolution helpers.

Not verified: none of this was run locally. The e2e journeys (10-row-actions.spec.ts and the edited 02/03/05/07) get their first run in CI, so watch that run. The server/client boundary looked correct in the diff: only key, label, icon, destructive and the per-row available keys cross it.


Generated by Claude Code

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 95.43% (🎯 81%) 4559 / 4777
🟢 Statements 93.45% (🎯 76%) 5196 / 5560
🟢 Functions 96.17% (🎯 78%) 1030 / 1071
🟢 Branches 89.41% (🎯 71%) 3631 / 4061
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/config/row-action-keys.ts 100% 100% 100% 100%
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 58.23% 799 / 1372
🔵 Statements 55.71% 848 / 1522
🔵 Functions 56.28% 224 / 398
🔵 Branches 46.02% 677 / 1471
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 82.29% 2366 / 2875
🔵 Statements 82.16% 2571 / 3129
🔵 Functions 88.75% 426 / 480
🔵 Branches 77.56% 1355 / 1747
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 85.12% 452 / 531
🔵 Statements 83.84% 514 / 613
🔵 Functions 88.46% 115 / 130
🔵 Branches 79.06% 423 / 535
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 96.77% 570 / 589
🔵 Statements 95.7% 646 / 675
🔵 Functions 98.8% 166 / 168
🔵 Branches 91.18% 579 / 635
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 89.14% 665 / 746
🔵 Statements 88.64% 726 / 819
🔵 Functions 95.55% 129 / 135
🔵 Branches 86.36% 494 / 572
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 50 / 50
🔵 Statements 100% 51 / 51
🔵 Functions 100% 11 / 11
🔵 Branches 97.05% 33 / 34
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 76 / 76
🔵 Statements 100% 80 / 80
🔵 Functions 100% 17 / 17
🔵 Branches 96.55% 56 / 58
File CoverageNo changed files found.
Generated in workflow #3709 for commit ac0817e by the Vitest Coverage Report Action

… Edit link

The json-demo and tiptap-demo journeys clicked the per-row Edit link this
PR removed; they now open the row through its title link.

Dispatch hands the handler the parsed id, typed as the list's own id
(`RowActionId<TypeInfo>`), and maps a throwing `hasAccess` or `isAvailable`
to the uniform "not run" outcome. The list view resolves Bulk and Row
action visibility through one `resolveVisibleActions`, which hides an
action whose `hasAccess` throws, and evaluates `isAvailable` over a read
with no includes, the shape dispatch re-reads the row in.

The client shares one action-result parser, reports a rejected dispatch
as not run or not deleted, returns to page 1 after a row delete as a
bulk delete does, and puts the title link beside a custom cell rather
than around it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
Comment thread packages/core/src/context/index.ts Resolved
Comment thread packages/core/src/context/index.ts Resolved
Comment thread packages/ui/src/components/ListView.tsx Resolved
Comment thread packages/ui/src/components/ListView.tsx Resolved
Comment thread packages/core/src/context/index.ts Resolved

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES (re-review at 985f696; posted as a comment because GitHub refuses request-changes on your own PR)

Security boundary: holds. rowAction dispatch re-reads the row through the secured context.db[listKey].where({ id: { equals } }).first(). These cases all return the same { rowAction: false, error: 'This action was not run' }, and the server tests cover each one:

  • an unparseable id
  • a denied or missing row (null)
  • an ineligible row
  • a throwing hasAccess or isAvailable

The handler now receives the parsed id. Only key, label, icon and destructive cross to the client (serializeRowAction). hasAccess, isAvailable and handler stay on the server.

Earlier threads. I checked each reply against the current code. These nine are fixed and resolved:

  • parsed id
  • availability read shape
  • out-of-range delete
  • rejected serverAction
  • nested anchors
  • hasAccess throw handling
  • the TSDoc placement
  • the parser cast and duplicate
  • the resolveVisibleActions duplicate

The thread on the re-check and handler not being atomic (r4501284842) stays open for the pending design decision. Inline comment r4501621786 repeats it by mistake: disregard it. I've replied there and resolved that copy.

New findings, posted inline. I'd like these three fixed before merge:

  1. RowActions.tsx:151: disabled={busy} is set in the same batch as the confirm dialog closing. Focus then returns to a disabled trigger and drops to <body>, which contradicts the documented focus return. Only the cancelled path is tested.
  2. core/src/context/index.ts:951: nothing enforces that rowActions keys are unique, although the type doc says they must be. With duplicate keys, find() silently runs the first handler when the user chose the second.
  3. ListView.tsx:133: when the availability read fails, it returns {}. That hides every custom action on every row, including actions with no isAvailable that never needed the read. Fall back to the actions without isAvailable instead.

Non-blocking:

  • Dispatch passes swallows hasAccess and isAvailable throws without logging them, unlike ListView (index.ts:960).
  • A single-row delete now always jumps to page 1. Only the last row on a page needs that (ListViewClient.tsx:363).
  • The availability read and the hasAccess resolution run one after another with resolveListUi. Run them together with Promise.all (ListView.tsx:413).
    • Cost note: when no visible action declares isAvailable, the extra read is skipped, which is correct. When one does, it is a second full-column read of the page and re-runs every computed field, including relation-declaring ones (ADR-0081).
  • BUILT_IN_CELLS is a snapshot of the mutable registry taken at module load, so which cells count as built-in depends on load order (CellRenderer.tsx:13).
  • as unknown as AccessContext casts in ListViewRowActions.test.tsx break the CLAUDE.md no-casting rule.
  • The restating comment in examples/starter-auth/opensaas.config.ts:309 would be copied into every app the starter-auth template scaffolds.

packages/ui/src/primitives/dropdown-menu.tsx was intentionally not reviewed for changes.


Generated by Claude Code

- Keep focus on the trigger after a confirmed or running action: guard
  re-entry in run and mark the trigger aria-disabled instead of disabled
- Refuse duplicate ui.listView.rowActions keys when the config is resolved
- Fall back to the actions with no isAvailable when the availability read fails
- Log a throwing hasAccess or isAvailable at dispatch
- Stay on the current page after a row delete unless it emptied a later page
- Resolve action access, availability and list UI concurrently
- Use an explicit frozen set of built-in cells for the row title link
- Drop casts from the ListView Row actions tests and a restating comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
Brings in the table refresh (#2259): column picker, density, page sizes,
toolbar sort and the phone-card layout. The Row actions trigger now sits in
the card's pinned actions cell in place of the old Edit link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
Dispatch now opens context.transaction, takes .forUpdate() on the target
row, re-reads it in its own statement after the lock, re-runs hasAccess and
isAvailable against that read, and runs the handler with the
transaction-bound context. A handler's writes commit atomically with the
check and roll back on a throw, and two concurrent dispatches of a one-shot
action run the handler once. Silent failure and the redacted handler error
are unchanged.

Adds an escape-only concurrency test, plus tests that the handler context is
transaction-bound, and documents the lock in the RowAction TSDoc, ui.md,
context-api.md and the changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
const list = tx.db[props.listKey]
const locked = await list
.where({ id: { equals: id } })
.forUpdate()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row lock taken before hasAccess is checked. The dispatch opens a transaction and takes .forUpdate() on the row (plus a second read) before re-running hasAccess, which does not depend on the row. A session that hasAccess refuses, but that can query the list, can hand-post { action: 'rowAction', key, id } to the public server action and take FOR UPDATE locks on any row it can read, stalling concurrent writers on those rows, and hasAccess (which may be async and slow) runs while the lock is held. bulkAction checks hasAccess before doing any work. Move the hasAccess check ahead of transaction(...) and keep the same notRun result, so a refused session never locks anything.


Generated by Claude Code

let rows: Array<Record<string, unknown>> = ids.map((id) => ({ id }))
if (actions.some((action) => action.isAvailable)) {
try {
rows = await read.where({ id: { in: ids } }).all()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every list render pays for a second read of the whole page. When any visible action declares isAvailable, the rows are read again with where({ id: { in: ids } }).all(). That read computes every computed field, and per the costs in CLAUDE.md, a computed field whose needs names a relation fetches every visible child. On a 100-row page that doubles the read, or worse. It also runs when serverAction is absent, where the client never renders custom actions (customActions = onRun ? actions : []). At minimum, skip it when there is no serverAction, and consider reading only the columns the gate needs.


Generated by Claude Code

): boolean {
if (!action.isAvailable) return true
try {
return action.isAvailable({ item, session })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The menu and the dispatch evaluate isAvailable differently. Here it is called synchronously and its return value is used as truthy, while core's dispatch (passes in context/index.ts) awaits it. A JS config, or a cast, that supplies an async isAvailable returns a Promise here, which is always truthy, so the action is offered on every row. Dispatch then awaits the real false and answers "This action was not run". The try/catch-to-false wrapper is also written twice, once per package. Export one core helper that both the list view and dispatch call, so the menu and the dispatch can't diverge.


Generated by Claude Code

}
}

const titleColumn =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression: a list whose displayed columns are all relationships gets no title. On a junction list like Audition { student, production }, displayColumns.find((c) => !relationshipRefs[c]) returns undefined. Such a row then has no row-title-link and no CARD_TITLE_CELL on the phone card layout. Before this PR it fell back to displayColumns[0]. Fall back to the first column, rendering an "Open" link beside it as the custom-cell branch already does, instead of having no title at all.


Generated by Claude Code

SelectCell,
TimestampCell,
CalendarDayCell,
RelationshipCell,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RelationshipCell is not anchor-free, yet it is in the set treated as safe to wrap in a link. rendersBuiltInCell is what decides whether the title cell gets wrapped in <Link data-slot="row-title-link">, but RelationshipCell renders its own <Link>. If the title column is a relationship, the result is nested <a> elements: invalid HTML, and a click target that is unclear. That happens when getLabelFieldName auto-detects a name/title field that is a relationship, since only a configured labelField is checked for that type. Leave RelationshipCell out of this set, or exclude relationship columns from titleColumn in every branch.


Generated by Claude Code

size="icon"
data-slot="row-actions-trigger"
aria-label={`Actions for ${rowLabel}`}
aria-disabled={busy || undefined}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While an action is running, other actions and confirmed deletes are dropped without feedback. aria-disabled does not stop the Radix trigger from opening. While an action runs, the user can open the menu again, choose Delete and confirm it, and run() then returns early on running.current with no status message. The confirmed delete never happens and nothing says so. Either really disable the trigger (disabled={busy}) or report that another action is still running.


Generated by Claude Code

if (!serverAction) return
let deleted = 0
try {
const result = await serverAction({ listKey, action: 'bulkDelete', ids: [id] })

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row Delete drops the reason a delete was refused. It goes through bulkDelete, which catches every error and only counts rows. A validate or beforeOperation hook that refuses with a ValidationError ("cannot delete a published post") therefore shows only “X” not deleted. The delete action on the item page shows that message. Silent failure hides access denials, not the application's own validation messages. Consider a single-row dispatch that returns the redacted clientFailure message without a success key.


Generated by Claude Code

}),
)
} catch {
setBulkStatus(notRun)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A transport failure is reported as "not run", and the page is not refreshed. If serverAction rejects, for example on a network drop or timeout after the server has committed, the status line says "this action was not run", which may be false. The handler returns before router.refresh(), so the row's menu keeps offering an action that may already have run. Report an unknown outcome instead, and refresh in a finally.


Generated by Claude Code

{ fallback, errorPrefix = '' }: { fallback: string; errorPrefix?: string },
): string {
if (typeof result !== 'object' || result === null || !(key in result)) return fallback
const ran =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: ran can be simpler. The key === 'bulkAction' ? 'bulkAction' in result && result.bulkAction : 'rowAction' in result && result.rowAction branch re-checks key in result, which the line above already guaranteed. const ran = Reflect.get(result, key) (or (result as Record<string, unknown>)[key], if a cast is acceptable here) does the same job without the duplicated arm.


Generated by Claude Code

}

/**
* The keys of the Row actions each row offers, keyed by row id. `isAvailable`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention (CLAUDE.md, "Comments"): "Everything else that feels worth writing is rationale, and rationale does not belong beside code. Put it in an ADR...". This docblock on the internal, non-exported availableRowActionKeys ("...so a computed field sees the same dependencies here as it does when the action runs"), and the one on resolveVisibleActions ("hiding an action is a UX affordance, not the security boundary"), are rationale on internal helpers. The "public API docblocks" exception does not cover them. Move the reasoning to the PR body or an ADR.


Generated by Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE. I'm posting this as a COMMENT review because GitHub won't let the PR's author approve it.

Reviewed at ac0817e. As settled, I treated two things as fixed: locked dispatch inside context.transaction, and a distinct error for an unknown key.

Earlier threads

All nine unresolved threads from the previous round are fixed at head, and I've resolved them:

  • RowActions focus: aria-disabled plus a re-entry ref.
  • passes logging.
  • Availability-read fallback: actions with no isAvailable are still offered.
  • Single-row delete: returns to page 1 only when it was the last row on a page past 1.
  • Promise.all in ListView.
  • builtInCellComponents: now an explicit frozen set.
  • assertUniqueRowActionKeys: runs on the sync path and the plugin path.
  • Test casts: removed.
  • starter-auth comment: deleted.

Locked dispatch (packages/core/src/context/index.ts ~945–1011)

  • ADR-0047 ordering holds. .forUpdate().first() runs first. The item that isAvailable and the handler see comes from a separate statement after the lock, so a racer that waited on the lock sees the committed row.
  • Silent-failure equivalence holds. An unparseable id, a missing or denied row, a hasAccess that returns false or throws, and an isAvailable that returns false or throws all return the same notRun constant. Only an unknown key differs, which is settled.
  • A throwing handler rolls back. The throw propagates out of the transaction callback and clientFailure turns it into Action failed. That differs from notRun, which is correct: the row passed every check.
  • The handler stays inside the transaction. It receives engineContextOf(tx), and a nested context.transaction inside it joins the owner (joinNested), so it cannot open a second connection that blocks on our own lock. The handler can only leave the transaction through a context it captured in a closure, and that is the application's choice.
  • No lock-ordering hazard. The row is locked FOR UPDATE before the handler's own root update (FOR NO KEY UPDATE, which is weaker and on the same transaction). That is the parent-first order ADR-0078 recommends, not the update-then-.forUpdate() shape it warns about.

row-action-race.test.ts

  • The test is sound. The barrier sits in the query rule, which the lock terminal's scoped read evaluates before it locks, so both racers have read the row before either one locks it. Each racer has its own single-connection pool.
  • The test is falsifiable. Without the lock, both re-reads see sentAt === null and both handlers run.
  • It skips visibly. It uses describe.skipIf(escape.kind !== 'postgres') with an [escape-only: …] title, the same pattern as capacity-gate.test.ts.

Findings

None of these block. The 10 inline threads from this round carry the details:

  1. Dispatch locks the row before hasAccess runs (index.ts:977). A session that can read the list but that hasAccess refuses can still briefly take FOR UPDATE on rows it can read. hasAccess doesn't depend on the row, so it could run before the transaction opens.
  2. A second full read of the page runs whenever any action declares isAvailable, even with no serverAction (ListView.tsx:130).
  3. The menu calls isAvailable synchronously while dispatch awaits it (ListView.tsx:107).
  4. titleColumn is undefined when every displayed column is a relationship (ListViewClient.tsx:538).
  5. A relationship cell in the title column produces nested anchors (registry.ts:81).
  6. An overlapping run is dropped without any feedback (RowActions.tsx:155).
  7. Row delete loses the hook's ValidationError message (ListViewClient.tsx:486).
  8. A rejected server action reports "not run" and skips the refresh (ListViewClient.tsx:511).
  9. The ran ternary repeats the same check once per key (ListViewClient.tsx:84).
  10. Internal docblocks on helpers that aren't exported (ListView.tsx:115). This is a style nit: the comment rule's public-API exception doesn't cover them, but they don't change behaviour.

Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — ac0817ef Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants