fix(tables): scope optimistic stop-cancel to the active filtered view (#4996)

A filtered select-all Stop only cancels matching rows server-side, but
the optimistic update flipped in-flight cells across every cached rows
query — stale unfiltered views showed workflows as cancelled until the
refetch. snapshotAndMutateRows gains an onlyKey option; the cancel
mutation passes the active view's exact cache key (filter + sort) when a
filter is present, and onSettled's invalidation reconciles other views.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-06-12 15:19:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bc92a2cc49
commit c02c7b88a3
2 changed files with 67 additions and 38 deletions
@@ -377,7 +377,9 @@ export function Table({
/** Select-all Stop — filter-scoped when a filter is active; deselected rows keep running. */
const onStopAllRows = useCallback(
(filter?: Filter, excludeRowIds?: string[]) => {
cancelRunsMutate({ scope: 'all', filter, excludeRowIds })
// `sort` scopes the optimistic flip to the active view's cache (filtered stops
// only cancel matching rows server-side).
cancelRunsMutate({ scope: 'all', filter, sort: queryOptions.sort, excludeRowIds })
captureEvent(posthogRef.current, 'table_workflow_stopped', {
table_id: tableId,
workspace_id: workspaceId,
@@ -385,7 +387,7 @@ export function Table({
row_count: null,
})
},
[cancelRunsMutate, tableId, workspaceId]
[cancelRunsMutate, tableId, workspaceId, queryOptions.sort]
)
const onSelectionChange = (next: SelectionSnapshot) => {
+63 -36
View File
@@ -1224,6 +1224,9 @@ interface CancelRunsParams {
rowId?: string
/** Scope-`all` only: cancel just the cells on rows matching this filter (filtered select-all Stop). */
filter?: Filter
/** Active sort — with `filter` it identifies the exact rows query whose cells the optimistic
* cancel may flip (other cached views contain rows the server won't touch). */
sort?: Sort | null
/** Scope-`all` only: deselected rows whose cells keep running. */
excludeRowIds?: string[]
}
@@ -1246,39 +1249,57 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext)
body: { workspaceId, scope, rowId, filter, excludeRowIds },
})
},
onMutate: async ({ scope, rowId, excludeRowIds }) => {
onMutate: async ({ scope, rowId, filter, sort, excludeRowIds }) => {
const excludedRowIds =
excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null
const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => {
if (scope === 'row' && r.id !== rowId) return null
if (excludedRowIds?.has(r.id)) return null
const executions = (r.executions ?? {}) as RowExecutions
let rowTouched = false
const nextExecutions: RowExecutions = { ...executions }
for (const gid in executions) {
const exec = executions[gid]
if (!isExecInFlight(exec)) continue
if (exec.executionId == null) {
// Optimistic-only or dispatcher-pre-stamp pending — server has not
// claimed the cell yet, so no SSE will arrive to reconcile a
// `cancelled` stamp. Strip the entry instead and let the renderer
// fall through to the cell's prior state (value / empty / etc.).
delete nextExecutions[gid]
// A filtered stop only cancels matching rows server-side — flipping every cached view
// would show rows outside the filter as cancelled until refetch. Scope the optimistic
// flip to the active filtered view; onSettled's invalidation reconciles the rest.
const onlyKey = filter
? tableKeys.infiniteRows(
tableId,
tableRowsParamsKey({
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
filter,
sort: sort ?? null,
})
)
: undefined
const snapshots = await snapshotAndMutateRows(
queryClient,
tableId,
(r) => {
if (scope === 'row' && r.id !== rowId) return null
if (excludedRowIds?.has(r.id)) return null
const executions = (r.executions ?? {}) as RowExecutions
let rowTouched = false
const nextExecutions: RowExecutions = { ...executions }
for (const gid in executions) {
const exec = executions[gid]
if (!isExecInFlight(exec)) continue
if (exec.executionId == null) {
// Optimistic-only or dispatcher-pre-stamp pending — server has not
// claimed the cell yet, so no SSE will arrive to reconcile a
// `cancelled` stamp. Strip the entry instead and let the renderer
// fall through to the cell's prior state (value / empty / etc.).
delete nextExecutions[gid]
rowTouched = true
continue
}
nextExecutions[gid] = {
status: 'cancelled',
executionId: exec.executionId,
jobId: null,
workflowId: exec.workflowId,
error: 'Cancelled',
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
}
rowTouched = true
continue
}
nextExecutions[gid] = {
status: 'cancelled',
executionId: exec.executionId,
jobId: null,
workflowId: exec.workflowId,
error: 'Cancelled',
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
}
rowTouched = true
}
return rowTouched ? { ...r, executions: nextExecutions } : null
})
return rowTouched ? { ...r, executions: nextExecutions } : null
},
{ onlyKey }
)
return { snapshots }
},
onError: (_err, _variables, context) => {
@@ -1794,14 +1815,20 @@ export async function snapshotAndMutateRows(
queryClient: ReturnType<typeof useQueryClient>,
tableId: string,
transform: (row: TableRow) => TableRow | null,
options?: { cancelInFlight?: boolean }
): Promise<RowsCacheSnapshots> {
if (options?.cancelInFlight !== false) {
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
options?: {
cancelInFlight?: boolean
/** Restrict the walk to one exact cached query (e.g. the active filtered
* view) when the mutation's server effect doesn't cover other views. */
onlyKey?: readonly unknown[]
}
const matching = queryClient.getQueriesData<RowsCacheEntry>({
queryKey: tableKeys.rowsRoot(tableId),
})
): Promise<RowsCacheSnapshots> {
const scope = options?.onlyKey
? ({ queryKey: options.onlyKey, exact: true } as const)
: ({ queryKey: tableKeys.rowsRoot(tableId) } as const)
if (options?.cancelInFlight !== false) {
await queryClient.cancelQueries(scope)
}
const matching = queryClient.getQueriesData<RowsCacheEntry>(scope)
const snapshots: RowsCacheSnapshots = []
for (const [key, data] of matching) {
if (!data) continue