Skip to content

Commit 6533967

Browse files
fix(table): clearing a workflow output cell also clears its exec record
When the user wipes a workflow output column value, the auto-fire reactor needs to be re-armed for that group. Previously, a stale cancelled / error exec record blocked the eligibility predicate (gate at line 79 hard-rejects those statuses on auto-fire) and the cell stayed stuck in its old terminal state — visible as "Cancelled" cells that wouldn't re-run no matter what. Both updateRow and batchUpdateRows now derive an `executionsPatch[gid] = null` for any output column the patch sets to empty. The data clear and the exec clear ride the same SQL transaction, so the row never lands in a stale- status-with-empty-data state. Symmetric to how `completed` already worked via `areOutputsFilled` in the predicate — clearing the cell wins over the prior exec status, regardless of what that status was. (Also revert typewriter-trigger experiment from a parallel session that was in-progress on this branch.)
1 parent 2ec89ff commit 6533967

3 files changed

Lines changed: 55 additions & 90 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use client'
22

3-
import { useRef } from 'react'
43
import type { RowExecutionMetadata } from '@/lib/table'
54
import type { SaveReason } from '../../../types'
65
import type { DisplayColumn } from '../types'
7-
import { CellRender, type CellRenderKind, resolveCellRender } from './cell-render'
6+
import { CellRender, resolveCellRender } from './cell-render'
87
import { InlineEditor } from './inline-editors'
98

109
interface CellContentProps {
@@ -40,10 +39,7 @@ export function CellContent({
4039
onCancel,
4140
waitingOnLabels,
4241
}: CellContentProps) {
43-
const kind = useTypewriterTrigger(
44-
resolveCellRender({ value, exec, column, waitingOnLabels }),
45-
column
46-
)
42+
const kind = resolveCellRender({ value, exec, column, waitingOnLabels })
4743

4844
return (
4945
<>
@@ -62,29 +58,3 @@ export function CellContent({
6258
</>
6359
)
6460
}
65-
66-
/**
67-
* Sets `animateMount: true` on a workflow-output `value` kind when the cell
68-
* just transitioned into the value state from a non-value one (queued /
69-
* running / waiting / error / cancelled / empty) — i.e., the worker just
70-
* filled it in — or when an existing value's text changed. Stays `false` on
71-
* initial page load (cell mounts already filled) and on no-op refetches.
72-
*/
73-
function useTypewriterTrigger(kind: CellRenderKind, column: DisplayColumn): CellRenderKind {
74-
const mountedRef = useRef(false)
75-
const lastKindRef = useRef<CellRenderKind['kind'] | null>(null)
76-
const lastValueTextRef = useRef<string | null>(null)
77-
if (!column.workflowGroupId) return kind
78-
const isFirstRender = !mountedRef.current
79-
mountedRef.current = true
80-
const prevKind = lastKindRef.current
81-
const prevText = lastValueTextRef.current
82-
lastKindRef.current = kind.kind
83-
if (kind.kind === 'value') lastValueTextRef.current = kind.text
84-
if (kind.kind !== 'value') return kind
85-
if (isFirstRender) return kind
86-
// Just transitioned into value from a non-value state, or text changed.
87-
const justTransitioned = prevKind !== 'value' || prevText !== kind.text
88-
if (!justTransitioned) return kind
89-
return { ...kind, animateMount: true }
90-
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import type React from 'react'
4-
import { useEffect, useRef, useState } from 'react'
54
import { Badge, Checkbox, Tooltip } from '@/components/emcn'
65
import { cn } from '@/lib/core/utils/cn'
76
import type { RowExecutionMetadata } from '@/lib/table'
@@ -27,7 +26,7 @@ import type { DisplayColumn } from '../types'
2726
*/
2827
export type CellRenderKind =
2928
// Workflow-output cells
30-
| { kind: 'value'; text: string; animateMount?: boolean }
29+
| { kind: 'value'; text: string }
3130
| { kind: 'block-error' }
3231
| { kind: 'running' }
3332
| { kind: 'pending-upstream' }
@@ -132,7 +131,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
132131
isEditing && 'invisible'
133132
)}
134133
>
135-
<Typewriter text={kind.text} animateMount={kind.animateMount ?? false} />
134+
{kind.text}
136135
</span>
137136
)
138137

@@ -254,54 +253,3 @@ function Wrap({ isEditing, children }: { isEditing: boolean; children: React.Rea
254253
if (!isEditing) return <>{children}</>
255254
return <div className='invisible'>{children}</div>
256255
}
257-
258-
const TYPEWRITER_MS_PER_CHAR = 18
259-
const TYPEWRITER_TOTAL_MS_CAP = 600
260-
261-
/**
262-
* Reveals `text` one character at a time. The caller controls whether to
263-
* animate on mount via `animateMount` — true when this Typewriter just
264-
* appeared because the cell transitioned from a non-value state (queued /
265-
* running / error) to a value, false on the first render of an already-filled
266-
* cell. Subsequent text changes always animate. Caps total runtime at
267-
* `TYPEWRITER_TOTAL_MS_CAP` so long values still finish fast.
268-
*/
269-
function Typewriter({ text, animateMount }: { text: string; animateMount: boolean }) {
270-
const [shown, setShown] = useState(animateMount ? 1 : text.length)
271-
// Track the last text we animated for so StrictMode's effect double-invoke
272-
// (and Fast Refresh remounts) don't re-trigger the animation for an
273-
// already-shown value.
274-
const lastAnimatedTextRef = useRef<string | null>(animateMount ? null : text)
275-
useEffect(() => {
276-
if (lastAnimatedTextRef.current === text) {
277-
setShown(text.length)
278-
return
279-
}
280-
lastAnimatedTextRef.current = text
281-
if (text.length <= 1) {
282-
setShown(text.length)
283-
return
284-
}
285-
setShown(1)
286-
const msPerChar = Math.max(
287-
4,
288-
Math.min(TYPEWRITER_MS_PER_CHAR, Math.floor(TYPEWRITER_TOTAL_MS_CAP / text.length))
289-
)
290-
let cancelled = false
291-
let i = 1
292-
let timeoutId = window.setTimeout(function tick() {
293-
if (cancelled) return
294-
i++
295-
setShown(i)
296-
if (i < text.length) timeoutId = window.setTimeout(tick, msPerChar)
297-
}, msPerChar)
298-
return () => {
299-
cancelled = true
300-
window.clearTimeout(timeoutId)
301-
}
302-
// animateMount is consulted only via the lazy init of `lastAnimatedTextRef`;
303-
// changing it later is meaningless.
304-
// eslint-disable-next-line react-hooks/exhaustive-deps
305-
}, [text])
306-
return <>{text.slice(0, shown)}</>
307-
}

apps/sim/lib/table/service.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,39 @@ export async function getRowById(
15191519
}
15201520
}
15211521

1522+
/**
1523+
* When a user edit clears a workflow output column to empty, also clear the
1524+
* exec record for that group. Without this, a `cancelled` (or `error`) exec
1525+
* sticks on the row even after the user wipes the output, blocking the
1526+
* auto-fire reactor (which respects terminal states). Treating the cleared
1527+
* cell as "user wants this re-armed" matches the rule that cells are the
1528+
* source of truth — we already do this for `completed` via
1529+
* `areOutputsFilled` in the eligibility predicate; this extends the same
1530+
* behavior to error/cancelled by making the data clear remove the exec.
1531+
*
1532+
* Returns a merged `executionsPatch` (caller's patch + null for groups whose
1533+
* outputs were cleared), or the caller's patch unchanged if nothing applies.
1534+
*/
1535+
function deriveExecClearsForDataPatch(
1536+
dataPatch: RowData,
1537+
schema: TableSchema,
1538+
callerPatch: Record<string, RowExecutionMetadata | null> | undefined
1539+
): Record<string, RowExecutionMetadata | null> | undefined {
1540+
const groupsToClear = new Set<string>()
1541+
for (const [columnName, value] of Object.entries(dataPatch)) {
1542+
const cleared = value === null || value === undefined || value === ''
1543+
if (!cleared) continue
1544+
const col = schema.columns.find((c) => c.name === columnName)
1545+
if (col?.workflowGroupId) groupsToClear.add(col.workflowGroupId)
1546+
}
1547+
if (groupsToClear.size === 0) return callerPatch
1548+
const merged: Record<string, RowExecutionMetadata | null> = { ...(callerPatch ?? {}) }
1549+
for (const gid of groupsToClear) {
1550+
if (!(gid in merged)) merged[gid] = null
1551+
}
1552+
return merged
1553+
}
1554+
15221555
/** Merges an `executionsPatch` into the row's existing executions blob. */
15231556
function applyExecutionsPatch(
15241557
existing: RowExecutions,
@@ -1590,7 +1623,14 @@ export async function updateRow(
15901623
...(existingRow.data as RowData),
15911624
...data.data,
15921625
}
1593-
const mergedExecutions = applyExecutionsPatch(existingRow.executions, data.executionsPatch)
1626+
// Auto-clear exec records for workflow output columns the user just wiped,
1627+
// so the auto-fire reactor sees no exec and re-arms the cell.
1628+
const effectiveExecutionsPatch = deriveExecClearsForDataPatch(
1629+
data.data,
1630+
table.schema,
1631+
data.executionsPatch
1632+
)
1633+
const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch)
15941634

15951635
// Validate size
15961636
const sizeValidation = validateRowSize(mergedData)
@@ -1653,7 +1693,7 @@ export async function updateRow(
16531693
// in-memory snapshot and the last writer wins, clobbering the other's
16541694
// exec keys. The data field still does last-writer-wins because that's
16551695
// the user's edit, but exec records are independently keyed by groupId.
1656-
const executionsExpr = buildExecutionsSqlPatch(data.executionsPatch)
1696+
const executionsExpr = buildExecutionsSqlPatch(effectiveExecutionsPatch)
16571697
const updated = await db
16581698
.update(userTableRows)
16591699
.set({
@@ -1913,7 +1953,14 @@ export async function batchUpdateRows(
19131953
for (const update of data.updates) {
19141954
const existing = existingMap.get(update.rowId)!
19151955
const merged = { ...existing.data, ...update.data }
1916-
const mergedExecutions = applyExecutionsPatch(existing.executions, update.executionsPatch)
1956+
// Auto-clear exec records for workflow output columns the user just
1957+
// wiped — same rationale as `updateRow`.
1958+
const effectiveExecutionsPatch = deriveExecClearsForDataPatch(
1959+
update.data,
1960+
table.schema,
1961+
update.executionsPatch
1962+
)
1963+
const mergedExecutions = applyExecutionsPatch(existing.executions, effectiveExecutionsPatch)
19171964

19181965
const sizeValidation = validateRowSize(merged)
19191966
if (!sizeValidation.valid) {
@@ -1929,7 +1976,7 @@ export async function batchUpdateRows(
19291976
rowId: update.rowId,
19301977
mergedData: merged,
19311978
mergedExecutions,
1932-
executionsPatch: update.executionsPatch,
1979+
executionsPatch: effectiveExecutionsPatch,
19331980
})
19341981
}
19351982

0 commit comments

Comments
 (0)