From 158fe3d6c1be064f07998435d7aa3617a38aa968 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 10:37:57 -0700 Subject: [PATCH 1/6] fix(table): cut dispatcher cold-start by lazy-loading heavy import chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger.dev table-run-dispatcher spent ~6s in module init before its first batchTriggerAndWait — it imports lib/table/service for getTableById, which eagerly imported lib/table/trigger → @/lib/webhooks/processor → webhook-execution + executor, dragging the entire workflow-execution stack into the dispatcher container even though it never fires a trigger. - trigger.ts lazy-imports the webhook processor + polling utils inside fireTableTrigger (the only consumer), so importing service no longer pulls the executor. - buildEnqueueItems only imports the cell job (for the inline `runner`) on the database backend; the trigger.dev backend triggers by task id and ignores runner. --- apps/sim/lib/table/trigger.ts | 9 +++++++-- apps/sim/lib/table/workflow-columns.ts | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/table/trigger.ts b/apps/sim/lib/table/trigger.ts index 32ece5e7111..0e52dc08186 100644 --- a/apps/sim/lib/table/trigger.ts +++ b/apps/sim/lib/table/trigger.ts @@ -9,8 +9,6 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import type { RowData, TableRow, TableSchema } from '@/lib/table/types' -import { fetchActiveWebhooks } from '@/lib/webhooks/polling/utils' -import { processPolledWebhookEvent } from '@/lib/webhooks/processor' const logger = createLogger('TableTrigger') @@ -57,6 +55,11 @@ export async function fireTableTrigger( requestId: string ): Promise { try { + // Lazy-imported: `@/lib/webhooks/processor` transitively pulls in the + // workflow executor + blocks stack. Importing it eagerly would force every + // consumer of `lib/table/service` (e.g. the dispatcher, which only needs + // `getTableById`) to pay that cold-start even when no trigger ever fires. + const { fetchActiveWebhooks } = await import('@/lib/webhooks/polling/utils') const webhooks = await fetchActiveWebhooks('table') if (webhooks.length === 0) return @@ -74,6 +77,8 @@ export async function fireTableTrigger( if (matching.length === 0) return + const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') + logger.info( `[${requestId}] Firing ${matching.length} trigger(s) for ${rows.length} ${eventType} event(s) in table ${tableId}` ) diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 686f97dbc6e..a9ce43f6448 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -203,11 +203,20 @@ export function buildPendingRuns( /** Build the per-cell `{payload, options}` items for `queue.batchEnqueue` / * `queue.batchEnqueueAndWait`. Hydrates trigger.dev tags, concurrency keys, * the inline runner, and the cancel key the inline backend uses to map a - * Stop click to the in-flight cell's AbortController. */ + * Stop click to the in-flight cell's AbortController. + * + * The `runner` is only consumed by the database (inline) backend — the + * trigger.dev backend triggers by task id. Importing the cell job pulls in + * the entire executor + blocks stack, so on trigger.dev we skip the import + * entirely: the dispatcher container would otherwise pay a multi-second + * cold-start loading code it never runs (the cell runs in its own container). */ export async function buildEnqueueItems( pendingRuns: WorkflowGroupCellPayload[] ): Promise> { - const { executeWorkflowGroupCellJob } = await import('@/background/workflow-column-execution') + const runner = isTriggerDevEnabled + ? undefined + : ((await import('@/background/workflow-column-execution')) + .executeWorkflowGroupCellJob as EnqueueOptions['runner']) return pendingRuns.map((runOpts) => ({ payload: runOpts, options: { @@ -225,7 +234,7 @@ export async function buildEnqueueItems( concurrencyKey: runOpts.tableId, concurrencyLimit: TABLE_CONCURRENCY_LIMIT, tags: cellTagsFor(runOpts), - runner: executeWorkflowGroupCellJob as EnqueueOptions['runner'], + ...(runner ? { runner } : {}), cancelKey: cellCancelKey(runOpts.tableId, runOpts.rowId, runOpts.groupId), }, })) From c1a71422a11a10ff58d0c6fd0bfa3a7ed230560c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 10:55:16 -0700 Subject: [PATCH 2/6] fix(table): run counter + gutter Stop update instantly on Run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "X running" badge, per-row gutter Stop button, and runningByRowId map stayed at zero after clicking Run until a manual refetch. useRunColumn optimistically stamped cells pending in the rows cache but never bumped the activeDispatches counter — so when the dispatcher's real pending SSE arrived, applyCell saw the cell was already in-flight (wasInFlight === isInFlight) and skipped the counter delta. The optimistic stamp ate the transition. - onMutate now bumps runningCellCount / runningByRowId by the cells it stamps, snapshotting prior run-state for rollback on error. - onSuccess seeds the dispatch into the overlay list from the response instead of invalidating activeDispatches (a refetch would reset the optimistic counter to the server's still-zero count before the dispatcher stamps). --- apps/sim/hooks/queries/tables.ts | 69 ++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 90ba8777a7b..f9228f9b8b3 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1336,10 +1336,13 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] const groupsById = new Map(groups.map((g) => [g.id, g])) + // Tally cells flipped to pending per row so we can bump the run-state + // counter in lockstep with the optimistic cell stamps below. + const stampedByRow: Record = {} const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { if (targetRowIds && !targetRowIds.has(r.id)) return null const executions = r.executions ?? {} - let changed = false + let stamped = 0 const next: RowExecutions = { ...executions } const nextData = { ...r.data } for (const groupId of targetGroupIds) { @@ -1367,20 +1370,70 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { if (o.columnName in nextData) nextData[o.columnName] = null } } - changed = true + stamped++ } - if (!changed) return null + if (stamped === 0) return null + stampedByRow[r.id] = stamped return { ...r, data: nextData, executions: next } }) - return { snapshots } + + // Bump the run-state counter to match the cells we just stamped. Without + // this the top-right "X running" badge and per-row gutter Stop button + // stay at zero until a refetch: the optimistic stamp marks the cell + // in-flight in the rows cache, so the dispatcher's real `pending` SSE + // event sees no `wasInFlight` transition and never bumps the counter. + const runStateSnapshot = queryClient.getQueryData( + tableKeys.activeDispatches(tableId) + ) + const totalStamped = Object.values(stampedByRow).reduce((s, n) => s + n, 0) + if (totalStamped > 0) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { + const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } + const nextByRow = { ...base.runningByRowId } + for (const [rid, n] of Object.entries(stampedByRow)) { + nextByRow[rid] = (nextByRow[rid] ?? 0) + n + } + return { + ...base, + runningCellCount: base.runningCellCount + totalStamped, + runningByRowId: nextByRow, + } + }) + } + return { snapshots, runStateSnapshot, didBumpRunState: totalStamped > 0 } }, onError: (_err, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) + // Roll back the optimistic counter bump to its pre-mutation value + // (possibly undefined, which clears the entry we created). + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } }, - onSuccess: () => { - // Seed the active-dispatch overlay immediately (insertDispatch ran - // server-side before responding); rows cache stays owned by SSE. - void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) + onSuccess: (data, { groupIds, runMode = 'all', rowIds }) => { + // Seed the dispatch into the overlay list (drives resolveCellExec's + // queued overlay for ahead-of-cursor rows). Upsert directly from the + // response instead of refetching — a refetch would reset the + // optimistic counter to the server's still-zero count (the dispatcher + // hasn't stamped cells yet). + const dispatchId = data?.data?.dispatchId + if (!dispatchId) return + queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { + const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } + if (base.dispatches.some((d) => d.id === dispatchId)) return base + const dispatch: ActiveDispatch = { + id: dispatchId, + status: 'pending', + mode: runMode, + isManualRun: true, + cursor: -1, + scope: { + groupIds, + ...(rowIds && rowIds.length > 0 ? { rowIds } : {}), + }, + } + return { ...base, dispatches: [...base.dispatches, dispatch] } + }) }, }) } From 248839ea36ead7967615a1b5985110e30a6a9f75 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 10:58:06 -0700 Subject: [PATCH 3/6] fix(table): drive cell typewriter with rAF so concurrent reveals stay smooth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The character-by-character reveal used a per-cell setInterval. When many cells reveal at once (a Run-all completing in waves), the independent interval callbacks fire at uncoordinated times and each forces its own render + layout/paint — O(cells) reflows over an un-virtualized grid, so it degrades as more cells fill. Switch to requestAnimationFrame: all cells' callbacks run before one paint, so React batches them into a single render + paint per frame regardless of cell count. Reveal length is derived from elapsed time, so a dropped frame catches up instead of slowing the animation. --- .../table-grid/cells/cell-render.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 6d63bca978b..80f4856882a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -296,6 +296,15 @@ const TYPEWRITER_MS_PER_CHAR = 15 * value statically — animation fires only for subsequent updates, which in * practice means SSE-driven workflow completions arriving via * `useTableEventStream → applyCell()`. + * + * Driven by `requestAnimationFrame`, not `setInterval`: when many cells reveal + * at once (a Run-all completing in waves), independent interval callbacks fire + * at uncoordinated times and each forces its own React render + layout/paint — + * O(cells) reflows over an un-virtualized grid, which degrades as more cells + * fill. rAF callbacks for a frame all run before one paint, so React batches + * every cell's update into a single render + paint per frame (~60fps, + * independent of cell count). Reveal length is derived from elapsed time, so a + * dropped frame catches up instead of slowing the animation. */ function useTypewriter(text: string | null): string | null { const [revealed, setRevealed] = useState(text) @@ -317,14 +326,17 @@ function useTypewriter(text: string | null): string | null { return } + const full = text + const start = performance.now() + let raf = 0 + const tick = (now: number) => { + const chars = Math.min(full.length, Math.floor((now - start) / TYPEWRITER_MS_PER_CHAR)) + setRevealed(full.slice(0, chars)) + if (chars < full.length) raf = requestAnimationFrame(tick) + } setRevealed('') - let i = 0 - const id = window.setInterval(() => { - i++ - setRevealed(text.slice(0, i)) - if (i >= text.length) window.clearInterval(id) - }, TYPEWRITER_MS_PER_CHAR) - return () => window.clearInterval(id) + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) }, [text]) return revealed From 22c37efef17a7849a78ef45aa3d072ebd9146a3c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 11:39:58 -0700 Subject: [PATCH 4/6] fix(table): roll back optimistic run counter when no dispatch is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRunColumn.onSuccess returned early on a null dispatchId (no matching groups / eligible rows) without undoing the onMutate counter bump — and no SSE would arrive to correct it, leaving the counter permanently inflated. Restore the pre-mutation run-state on that path, mirroring onError. --- apps/sim/hooks/queries/tables.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index f9228f9b8b3..180d03d0b76 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1410,14 +1410,22 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } }, - onSuccess: (data, { groupIds, runMode = 'all', rowIds }) => { + onSuccess: (data, { groupIds, runMode = 'all', rowIds }, context) => { // Seed the dispatch into the overlay list (drives resolveCellExec's // queued overlay for ahead-of-cursor rows). Upsert directly from the // response instead of refetching — a refetch would reset the // optimistic counter to the server's still-zero count (the dispatcher // hasn't stamped cells yet). const dispatchId = data?.data?.dispatchId - if (!dispatchId) return + if (!dispatchId) { + // No dispatch was created (e.g. no matching groups / eligible rows). + // No SSE will arrive to reconcile the optimistic counter bump, so roll + // it back to its pre-mutation value. + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } + return + } queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } if (base.dispatches.some((d) => d.id === dispatchId)) return base From 745a5a3abf1d4b087f00caf6e2611886e4981533 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 12:04:09 -0700 Subject: [PATCH 5/6] chore(table): tighten inline comments on dispatcher cold-start fixes --- .../table-grid/cells/cell-render.tsx | 11 +++----- apps/sim/hooks/queries/tables.ts | 27 +++++++------------ apps/sim/lib/table/trigger.ts | 7 +++-- apps/sim/lib/table/workflow-columns.ts | 8 +++--- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 80f4856882a..7201a3f7d23 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -297,14 +297,9 @@ const TYPEWRITER_MS_PER_CHAR = 15 * practice means SSE-driven workflow completions arriving via * `useTableEventStream → applyCell()`. * - * Driven by `requestAnimationFrame`, not `setInterval`: when many cells reveal - * at once (a Run-all completing in waves), independent interval callbacks fire - * at uncoordinated times and each forces its own React render + layout/paint — - * O(cells) reflows over an un-virtualized grid, which degrades as more cells - * fill. rAF callbacks for a frame all run before one paint, so React batches - * every cell's update into a single render + paint per frame (~60fps, - * independent of cell count). Reveal length is derived from elapsed time, so a - * dropped frame catches up instead of slowing the animation. + * rAF-driven (not `setInterval`) so concurrent reveals batch into one + * render/paint per frame instead of O(cells) uncoordinated reflows; reveal + * length is elapsed-time based so dropped frames catch up rather than slow. */ function useTypewriter(text: string | null): string | null { const [revealed, setRevealed] = useState(text) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 180d03d0b76..afd799e8a08 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1336,8 +1336,7 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] const groupsById = new Map(groups.map((g) => [g.id, g])) - // Tally cells flipped to pending per row so we can bump the run-state - // counter in lockstep with the optimistic cell stamps below. + // Tally cells stamped per row to bump the run-state counter in lockstep. const stampedByRow: Record = {} const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { if (targetRowIds && !targetRowIds.has(r.id)) return null @@ -1377,11 +1376,10 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { return { ...r, data: nextData, executions: next } }) - // Bump the run-state counter to match the cells we just stamped. Without - // this the top-right "X running" badge and per-row gutter Stop button - // stay at zero until a refetch: the optimistic stamp marks the cell - // in-flight in the rows cache, so the dispatcher's real `pending` SSE - // event sees no `wasInFlight` transition and never bumps the counter. + // Bump the counter to match the stamped cells. Without it the "X running" + // badge + gutter Stop stay at zero until a refetch: the optimistic stamp + // already marks the cell in-flight, so the dispatcher's `pending` SSE + // sees no `wasInFlight` transition and never bumps the counter. const runStateSnapshot = queryClient.getQueryData( tableKeys.activeDispatches(tableId) ) @@ -1404,23 +1402,18 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { }, onError: (_err, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) - // Roll back the optimistic counter bump to its pre-mutation value - // (possibly undefined, which clears the entry we created). + // Roll back the optimistic counter bump (snapshot may be undefined). if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } }, onSuccess: (data, { groupIds, runMode = 'all', rowIds }, context) => { - // Seed the dispatch into the overlay list (drives resolveCellExec's - // queued overlay for ahead-of-cursor rows). Upsert directly from the - // response instead of refetching — a refetch would reset the - // optimistic counter to the server's still-zero count (the dispatcher - // hasn't stamped cells yet). + // Seed the dispatch into the overlay (drives resolveCellExec for + // ahead-of-cursor rows) from the response — refetching would reset the + // optimistic counter to the server's still-zero count. const dispatchId = data?.data?.dispatchId if (!dispatchId) { - // No dispatch was created (e.g. no matching groups / eligible rows). - // No SSE will arrive to reconcile the optimistic counter bump, so roll - // it back to its pre-mutation value. + // No dispatch created → no SSE to reconcile the bump; roll it back. if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } diff --git a/apps/sim/lib/table/trigger.ts b/apps/sim/lib/table/trigger.ts index 0e52dc08186..2d7a9f3604d 100644 --- a/apps/sim/lib/table/trigger.ts +++ b/apps/sim/lib/table/trigger.ts @@ -55,10 +55,9 @@ export async function fireTableTrigger( requestId: string ): Promise { try { - // Lazy-imported: `@/lib/webhooks/processor` transitively pulls in the - // workflow executor + blocks stack. Importing it eagerly would force every - // consumer of `lib/table/service` (e.g. the dispatcher, which only needs - // `getTableById`) to pay that cold-start even when no trigger ever fires. + // Lazy: the webhook utils/processor pull in the executor + blocks stack. + // Eager imports would force every `lib/table/service` consumer (e.g. the + // dispatcher) to pay that cold-start even when no trigger fires. const { fetchActiveWebhooks } = await import('@/lib/webhooks/polling/utils') const webhooks = await fetchActiveWebhooks('table') if (webhooks.length === 0) return diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index a9ce43f6448..b0e7511936a 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -205,11 +205,9 @@ export function buildPendingRuns( * the inline runner, and the cancel key the inline backend uses to map a * Stop click to the in-flight cell's AbortController. * - * The `runner` is only consumed by the database (inline) backend — the - * trigger.dev backend triggers by task id. Importing the cell job pulls in - * the entire executor + blocks stack, so on trigger.dev we skip the import - * entirely: the dispatcher container would otherwise pay a multi-second - * cold-start loading code it never runs (the cell runs in its own container). */ + * `runner` is only used by the database backend; trigger.dev triggers by task + * id. The cell-job import pulls in the executor + blocks stack, so skip it on + * trigger.dev to avoid a multi-second dispatcher cold-start. */ export async function buildEnqueueItems( pendingRuns: WorkflowGroupCellPayload[] ): Promise> { From 92c55e40c761c53ee81125ef72e51ab26acee9a4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 20 May 2026 14:00:01 -0700 Subject: [PATCH 6/6] fix(table): bump run counter on edit/auto-run so Stop shows for queued cells The "X running" badge + per-row gutter Stop only updated on manual Run (useRunColumn bumped the run-state counter). Edit-triggered auto-runs (useUpdateTableRow, useBatchUpdateTableRows, useCreateTableRow) stamped cells pending in the rows cache but never bumped runningCellCount/runningByRowId, so Stop stayed hidden even though cells were queued (the counter is already queued-inclusive). Extracted countNewlyInFlight + bumpRunState helpers and wired them into all the optimistic auto-fire paths with onError rollback; reused them in useRunColumn. --- apps/sim/hooks/queries/tables.ts | 95 +++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 25 deletions(-) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index afd799e8a08..da557057431 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -225,6 +225,43 @@ async function fetchTableRunState(tableId: string, signal?: AbortSignal): Promis } } +/** Count groups flipped to in-flight (`pending`) by an optimistic schedule that + * weren't in-flight before — the delta to add to the run-state counter. */ +function countNewlyInFlight(before: RowExecutions, after: RowExecutions): number { + let n = 0 + for (const gid of Object.keys(after)) { + if (after[gid]?.status === 'pending' && !isExecInFlight(before[gid])) n++ + } + return n +} + +/** Add optimistically-stamped cells to the run-state counter so the "X running" + * badge + per-row gutter Stop reflect them instantly (the optimistic stamp + * eats the dispatcher's `pending` SSE, so `applyCell` never bumps the count). + * Returns the prior snapshot for rollback, or `null` when nothing was bumped. */ +function bumpRunState( + queryClient: ReturnType, + tableId: string, + stampedByRow: Record +): { snapshot: TableRunState | undefined } | null { + const total = Object.values(stampedByRow).reduce((s, n) => s + n, 0) + if (total === 0) return null + const snapshot = queryClient.getQueryData(tableKeys.activeDispatches(tableId)) + queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { + const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } + const nextByRow = { ...base.runningByRowId } + for (const [rid, n] of Object.entries(stampedByRow)) { + nextByRow[rid] = (nextByRow[rid] ?? 0) + n + } + return { + ...base, + runningCellCount: base.runningCellCount + total, + runningByRowId: nextByRow, + } + }) + return { snapshot } +} + /** * Aggregate live state for a table: active dispatches (drives the "about to * run" overlay), the running-cell count (top-right counter), and per-row @@ -453,6 +490,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) .workflowGroups ?? [] const stamped = withOptimisticAutoFireExec(groups, row) reconcileCreatedRow(queryClient, tableId, stamped) + // Bump the run-state counter for any auto-fire groups stamped pending so + // the "X running" badge + gutter Stop show immediately (the row had no + // prior executions, so the stamped set is the full delta). + const stampedCount = countNewlyInFlight({}, stamped.executions ?? {}) + if (stampedCount > 0) bumpRunState(queryClient, tableId, { [row.id]: stampedCount }) }, onError: (error) => { if (isValidationError(error)) return @@ -618,10 +660,14 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] + const stampedByRow: Record = {} patchCachedRows(queryClient, tableId, (row) => { if (row.id !== rowId) return row const patch = data as Partial const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + if (nextExecutions) { + stampedByRow[row.id] = countNewlyInFlight(row.executions ?? {}, nextExecutions) + } return { ...row, data: { ...row.data, ...patch } as RowData, @@ -629,7 +675,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) } }) - return { previousQueries } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { + previousQueries, + runStateSnapshot: bumped?.snapshot, + didBumpRunState: bumped !== null, + } }, onSuccess: (response, { rowId, data: mutatedData }) => { const serverRow = response.data.row @@ -655,6 +706,9 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryClient.setQueryData(queryKey, data) } } + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -694,11 +748,15 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] + const stampedByRow: Record = {} patchCachedRows(queryClient, tableId, (row) => { const raw = updateMap.get(row.id) if (!raw) return row const patch = raw as Partial const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + if (nextExecutions) { + stampedByRow[row.id] = countNewlyInFlight(row.executions ?? {}, nextExecutions) + } return { ...row, data: { ...row.data, ...patch } as RowData, @@ -706,7 +764,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon } }) - return { previousQueries } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { + previousQueries, + runStateSnapshot: bumped?.snapshot, + didBumpRunState: bumped !== null, + } }, onError: (error, _vars, context) => { if (context?.previousQueries) { @@ -714,6 +777,9 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon queryClient.setQueryData(queryKey, data) } } + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1376,29 +1442,8 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { return { ...r, data: nextData, executions: next } }) - // Bump the counter to match the stamped cells. Without it the "X running" - // badge + gutter Stop stay at zero until a refetch: the optimistic stamp - // already marks the cell in-flight, so the dispatcher's `pending` SSE - // sees no `wasInFlight` transition and never bumps the counter. - const runStateSnapshot = queryClient.getQueryData( - tableKeys.activeDispatches(tableId) - ) - const totalStamped = Object.values(stampedByRow).reduce((s, n) => s + n, 0) - if (totalStamped > 0) { - queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { - const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } - const nextByRow = { ...base.runningByRowId } - for (const [rid, n] of Object.entries(stampedByRow)) { - nextByRow[rid] = (nextByRow[rid] ?? 0) + n - } - return { - ...base, - runningCellCount: base.runningCellCount + totalStamped, - runningByRowId: nextByRow, - } - }) - } - return { snapshots, runStateSnapshot, didBumpRunState: totalStamped > 0 } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { snapshots, runStateSnapshot: bumped?.snapshot, didBumpRunState: bumped !== null } }, onError: (_err, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots)