Skip to content

Commit afc9fbc

Browse files
fix(tables): scope optimistic stop-cancel to the active filtered view
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 53fdcab commit afc9fbc

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
@@ -1252,6 +1252,9 @@ interface CancelRunsParams {
12521252
rowId?: string
12531253
/** Scope-`all` only: cancel just the cells on rows matching this filter (filtered select-all Stop). */
12541254
filter?: Filter
1255+
/** Active sort — with `filter` it identifies the exact rows query whose cells the optimistic
1256+
* cancel may flip (other cached views contain rows the server won't touch). */
1257+
sort?: Sort | null
12551258
/** Scope-`all` only: deselected rows whose cells keep running. */
12561259
excludeRowIds?: string[]
12571260
}
@@ -1274,39 +1277,57 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext)
12741277
body: { workspaceId, scope, rowId, filter, excludeRowIds },
12751278
})
12761279
},
1277-
onMutate: async ({ scope, rowId, excludeRowIds }) => {
1280+
onMutate: async ({ scope, rowId, filter, sort, excludeRowIds }) => {
12781281
const excludedRowIds =
12791282
excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null
1280-
const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => {
1281-
if (scope === 'row' && r.id !== rowId) return null
1282-
if (excludedRowIds?.has(r.id)) return null
1283-
const executions = (r.executions ?? {}) as RowExecutions
1284-
let rowTouched = false
1285-
const nextExecutions: RowExecutions = { ...executions }
1286-
for (const gid in executions) {
1287-
const exec = executions[gid]
1288-
if (!isExecInFlight(exec)) continue
1289-
if (exec.executionId == null) {
1290-
// Optimistic-only or dispatcher-pre-stamp pending — server has not
1291-
// claimed the cell yet, so no SSE will arrive to reconcile a
1292-
// `cancelled` stamp. Strip the entry instead and let the renderer
1293-
// fall through to the cell's prior state (value / empty / etc.).
1294-
delete nextExecutions[gid]
1283+
// A filtered stop only cancels matching rows server-side — flipping every cached view
1284+
// would show rows outside the filter as cancelled until refetch. Scope the optimistic
1285+
// flip to the active filtered view; onSettled's invalidation reconciles the rest.
1286+
const onlyKey = filter
1287+
? tableKeys.infiniteRows(
1288+
tableId,
1289+
tableRowsParamsKey({
1290+
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
1291+
filter,
1292+
sort: sort ?? null,
1293+
})
1294+
)
1295+
: undefined
1296+
const snapshots = await snapshotAndMutateRows(
1297+
queryClient,
1298+
tableId,
1299+
(r) => {
1300+
if (scope === 'row' && r.id !== rowId) return null
1301+
if (excludedRowIds?.has(r.id)) return null
1302+
const executions = (r.executions ?? {}) as RowExecutions
1303+
let rowTouched = false
1304+
const nextExecutions: RowExecutions = { ...executions }
1305+
for (const gid in executions) {
1306+
const exec = executions[gid]
1307+
if (!isExecInFlight(exec)) continue
1308+
if (exec.executionId == null) {
1309+
// Optimistic-only or dispatcher-pre-stamp pending — server has not
1310+
// claimed the cell yet, so no SSE will arrive to reconcile a
1311+
// `cancelled` stamp. Strip the entry instead and let the renderer
1312+
// fall through to the cell's prior state (value / empty / etc.).
1313+
delete nextExecutions[gid]
1314+
rowTouched = true
1315+
continue
1316+
}
1317+
nextExecutions[gid] = {
1318+
status: 'cancelled',
1319+
executionId: exec.executionId,
1320+
jobId: null,
1321+
workflowId: exec.workflowId,
1322+
error: 'Cancelled',
1323+
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
1324+
}
12951325
rowTouched = true
1296-
continue
1297-
}
1298-
nextExecutions[gid] = {
1299-
status: 'cancelled',
1300-
executionId: exec.executionId,
1301-
jobId: null,
1302-
workflowId: exec.workflowId,
1303-
error: 'Cancelled',
1304-
...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}),
13051326
}
1306-
rowTouched = true
1307-
}
1308-
return rowTouched ? { ...r, executions: nextExecutions } : null
1309-
})
1327+
return rowTouched ? { ...r, executions: nextExecutions } : null
1328+
},
1329+
{ onlyKey }
1330+
)
13101331
return { snapshots }
13111332
},
13121333
onError: (_err, _variables, context) => {
@@ -1822,14 +1843,20 @@ export async function snapshotAndMutateRows(
18221843
queryClient: ReturnType<typeof useQueryClient>,
18231844
tableId: string,
18241845
transform: (row: TableRow) => TableRow | null,
1825-
options?: { cancelInFlight?: boolean }
1846+
options?: {
1847+
cancelInFlight?: boolean
1848+
/** Restrict the walk to one exact cached query (e.g. the active filtered
1849+
* view) when the mutation's server effect doesn't cover other views. */
1850+
onlyKey?: readonly unknown[]
1851+
}
18261852
): Promise<RowsCacheSnapshots> {
1853+
const scope = options?.onlyKey
1854+
? ({ queryKey: options.onlyKey, exact: true } as const)
1855+
: ({ queryKey: tableKeys.rowsRoot(tableId) } as const)
18271856
if (options?.cancelInFlight !== false) {
1828-
await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) })
1857+
await queryClient.cancelQueries(scope)
18291858
}
1830-
const matching = queryClient.getQueriesData<RowsCacheEntry>({
1831-
queryKey: tableKeys.rowsRoot(tableId),
1832-
})
1859+
const matching = queryClient.getQueriesData<RowsCacheEntry>(scope)
18331860
const snapshots: RowsCacheSnapshots = []
18341861
for (const [key, data] of matching) {
18351862
if (!data) continue

0 commit comments

Comments
 (0)