Skip to content

Commit c02c7b8

Browse files
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>
1 parent bc92a2c commit c02c7b8

2 files changed

Lines changed: 65 additions & 36 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,15 +377,17 @@ export function Table({
377377
/** Select-all Stop — filter-scoped when a filter is active; deselected rows keep running. */
378378
const onStopAllRows = useCallback(
379379
(filter?: Filter, excludeRowIds?: string[]) => {
380-
cancelRunsMutate({ scope: 'all', filter, excludeRowIds })
380+
// `sort` scopes the optimistic flip to the active view's cache (filtered stops
381+
// only cancel matching rows server-side).
382+
cancelRunsMutate({ scope: 'all', filter, sort: queryOptions.sort, excludeRowIds })
381383
captureEvent(posthogRef.current, 'table_workflow_stopped', {
382384
table_id: tableId,
383385
workspace_id: workspaceId,
384386
scope: 'all',
385387
row_count: null,
386388
})
387389
},
388-
[cancelRunsMutate, tableId, workspaceId]
390+
[cancelRunsMutate, tableId, workspaceId, queryOptions.sort]
389391
)
390392

391393
const onSelectionChange = (next: SelectionSnapshot) => {

apps/sim/hooks/queries/tables.ts

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,9 @@ interface CancelRunsParams {
12241224
rowId?: string
12251225
/** Scope-`all` only: cancel just the cells on rows matching this filter (filtered select-all Stop). */
12261226
filter?: Filter
1227+
/** Active sort — with `filter` it identifies the exact rows query whose cells the optimistic
1228+
* cancel may flip (other cached views contain rows the server won't touch). */
1229+
sort?: Sort | null
12271230
/** Scope-`all` only: deselected rows whose cells keep running. */
12281231
excludeRowIds?: string[]
12291232
}
@@ -1246,39 +1249,57 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext)
12461249
body: { workspaceId, scope, rowId, filter, excludeRowIds },
12471250
})
12481251
},
1249-
onMutate: async ({ scope, rowId, excludeRowIds }) => {
1252+
onMutate: async ({ scope, rowId, filter, sort, excludeRowIds }) => {
12501253
const excludedRowIds =
12511254
excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null
1252-
const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => {
1253-
if (scope === 'row' && r.id !== rowId) return null
1254-
if (excludedRowIds?.has(r.id)) return null
1255-
const executions = (r.executions ?? {}) as RowExecutions
1256-
let rowTouched = false
1257-
const nextExecutions: RowExecutions = { ...executions }
1258-
for (const gid in executions) {
1259-
const exec = executions[gid]
1260-
if (!isExecInFlight(exec)) continue
1261-
if (exec.executionId == null) {
1262-
// Optimistic-only or dispatcher-pre-stamp pending — server has not
1263-
// claimed the cell yet, so no SSE will arrive to reconcile a
1264-
// `cancelled` stamp. Strip the entry instead and let the renderer
1265-
// fall through to the cell's prior state (value / empty / etc.).
1266-
delete nextExecutions[gid]
1255+
// A filtered stop only cancels matching rows server-side — flipping every cached view
1256+
// would show rows outside the filter as cancelled until refetch. Scope the optimistic
1257+
// flip to the active filtered view; onSettled's invalidation reconciles the rest.
1258+
const onlyKey = filter
1259+
? tableKeys.infiniteRows(
1260+
tableId,
1261+
tableRowsParamsKey({
1262+
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
1263+
filter,
1264+
sort: sort ?? null,
1265+
})
1266+
)
1267+
: undefined
1268+
const snapshots = await snapshotAndMutateRows(
1269+
queryClient,
1270+
tableId,
1271+
(r) => {
1272+
if (scope === 'row' && r.id !== rowId) return null
1273+
if (excludedRowIds?.has(r.id)) return null
1274+
const executions = (r.executions ?? {}) as RowExecutions
1275+
let rowTouched = false
1276+
const nextExecutions: RowExecutions = { ...executions }
1277+
for (const gid in executions) {
1278+
const exec = executions[gid]
1279+
if (!isExecInFlight(exec)) continue
1280+
if (exec.executionId == null) {
1281+
// Optimistic-only or dispatcher-pre-stamp pending — server has not
1282+
// claimed the cell yet, so no SSE will arrive to reconcile a
1283+
// `cancelled` stamp. Strip the entry instead and let the renderer
1284+
// fall through to the cell's prior state (value / empty / etc.).
1285+
delete nextExecutions[gid]
1286+
rowTouched = true
1287+
continue
1288+
}
1289+
nextExecutions[gid] = {
1290+
status: 'cancelled',
1291+
executionId: exec.executionId,
1292+
jobId: null,
1293+
workflowId: exec.workflowId,
1294+
error: 'Cancelled',
1295+
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
1296+
}
12671297
rowTouched = true
1268-
continue
1269-
}
1270-
nextExecutions[gid] = {
1271-
status: 'cancelled',
1272-
executionId: exec.executionId,
1273-
jobId: null,
1274-
workflowId: exec.workflowId,
1275-
error: 'Cancelled',
1276-
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
12771298
}
1278-
rowTouched = true
1279-
}
1280-
return rowTouched ? { ...r, executions: nextExecutions } : null
1281-
})
1299+
return rowTouched ? { ...r, executions: nextExecutions } : null
1300+
},
1301+
{ onlyKey }
1302+
)
12821303
return { snapshots }
12831304
},
12841305
onError: (_err, _variables, context) => {
@@ -1794,14 +1815,20 @@ export async function snapshotAndMutateRows(
17941815
queryClient: ReturnType<typeof useQueryClient>,
17951816
tableId: string,
17961817
transform: (row: TableRow) => TableRow | null,
1797-
options?: { cancelInFlight?: boolean }
1818+
options?: {
1819+
cancelInFlight?: boolean
1820+
/** Restrict the walk to one exact cached query (e.g. the active filtered
1821+
* view) when the mutation's server effect doesn't cover other views. */
1822+
onlyKey?: readonly unknown[]
1823+
}
17981824
): Promise<RowsCacheSnapshots> {
1825+
const scope = options?.onlyKey
1826+
? ({ queryKey: options.onlyKey, exact: true } as const)
1827+
: ({ queryKey: tableKeys.rowsRoot(tableId) } as const)
17991828
if (options?.cancelInFlight !== false) {
1800-
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
1829+
await queryClient.cancelQueries(scope)
18011830
}
1802-
const matching = queryClient.getQueriesData<RowsCacheEntry>({
1803-
queryKey: tableKeys.rowsRoot(tableId),
1804-
})
1831+
const matching = queryClient.getQueriesData<RowsCacheEntry>(scope)
18051832
const snapshots: RowsCacheSnapshots = []
18061833
for (const [key, data] of matching) {
18071834
if (!data) continue

0 commit comments

Comments
 (0)