Skip to content

Commit 8fe090a

Browse files
fix(input-format): field not editable race condition (#5102)
* fix(input-format): field not editable race condition * remove dead code * simplify
1 parent 2ffc004 commit 8fe090a

6 files changed

Lines changed: 81 additions & 111 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useCallback, useRef } from 'react'
2-
import { generateId } from '@sim/utils/id'
32
import { Plus } from 'lucide-react'
43
import { Trash } from '@/components/emcn/icons/trash'
54
import 'prismjs/components/prism-json'
@@ -21,6 +20,7 @@ import {
2120
} from '@/components/emcn'
2221
import { cn } from '@/lib/core/utils/cn'
2322
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
23+
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
2424
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
2525
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
2626
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
@@ -74,18 +74,6 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [
7474
{ label: 'false', value: 'false' },
7575
]
7676

77-
/**
78-
* Creates a new field with default values
79-
*/
80-
const createDefaultField = (): Field => ({
81-
id: generateId(),
82-
name: '',
83-
type: 'string',
84-
value: '',
85-
description: '',
86-
collapsed: false,
87-
})
88-
8977
/**
9078
* Validates and sanitizes field names by removing control characters and quotes
9179
*/
@@ -127,8 +115,17 @@ export function FieldFormat({
127115
disabled,
128116
})
129117

118+
/**
119+
* Stable fallback field used while the store value is still empty (e.g. a
120+
* newly added block). Caching it in a ref keeps the field id constant across
121+
* renders, so the inputs don't remount on each keystroke and edits commit to
122+
* the same id instead of a freshly generated one.
123+
*/
124+
const fallbackFieldRef = useRef<Field | null>(null)
125+
const fallbackField = (fallbackFieldRef.current ??= createDefaultInputFormatField())
126+
130127
const value = isPreview ? previewValue : storeValue
131-
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
128+
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [fallbackField]
132129
const isReadOnly = isPreview || disabled
133130

134131
const renderFieldLabel = (label: string) => <Label>{label}</Label>
@@ -138,7 +135,7 @@ export function FieldFormat({
138135
*/
139136
const addField = () => {
140137
if (isReadOnly) return
141-
setStoreValue([...fields, createDefaultField()])
138+
setStoreValue([...fields, createDefaultInputFormatField()])
142139
}
143140

144141
/**
@@ -148,15 +145,19 @@ export function FieldFormat({
148145
if (isReadOnly) return
149146

150147
if (fields.length === 1) {
151-
setStoreValue([createDefaultField()])
148+
setStoreValue([createDefaultInputFormatField()])
152149
return
153150
}
154151

155152
setStoreValue(fields.filter((field) => field.id !== id))
156153
}
157154

158-
const storeValueRef = useRef(storeValue)
159-
storeValueRef.current = storeValue
155+
/**
156+
* Mirrors the rendered fields (store value or stable fallback) so updateField
157+
* always commits against the same ids the UI is currently showing.
158+
*/
159+
const fieldsRef = useRef(fields)
160+
fieldsRef.current = fields
160161

161162
const isReadOnlyRef = useRef(isReadOnly)
162163
isReadOnlyRef.current = isReadOnly
@@ -173,14 +174,8 @@ export function FieldFormat({
173174
? validateFieldName(fieldValue)
174175
: fieldValue
175176

176-
const currentStoreValue = storeValueRef.current
177-
const currentFields: Field[] =
178-
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
179-
? currentStoreValue
180-
: [createDefaultField()]
181-
182177
setStoreValueRef.current(
183-
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
178+
fieldsRef.current.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
184179
)
185180
},
186181
[]

apps/sim/lib/workflows/defaults.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateId } from '@sim/utils/id'
22
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
3+
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
34
import { getBlock } from '@/blocks'
45
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
56
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -39,15 +40,7 @@ function resolveInitialValue(subBlock: SubBlockConfig): unknown {
3940
}
4041

4142
if (subBlock.type === 'input-format') {
42-
return [
43-
{
44-
id: generateId(),
45-
name: '',
46-
type: 'string',
47-
value: '',
48-
collapsed: false,
49-
},
50-
]
43+
return [createDefaultInputFormatField()]
5144
}
5245

5346
if (subBlock.type === 'table') {

apps/sim/lib/workflows/input-format.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
createDefaultInputFormatField,
34
extractInputFieldsFromBlocks,
45
normalizeInputFormatValue,
56
} from '@/lib/workflows/input-format'
@@ -227,3 +228,28 @@ describe('normalizeInputFormatValue', () => {
227228
expect(normalizeInputFormatValue(input)).toEqual(input)
228229
})
229230
})
231+
232+
describe('createDefaultInputFormatField', () => {
233+
it.concurrent('creates an empty field with the canonical default shape', () => {
234+
const field = createDefaultInputFormatField()
235+
expect(field).toEqual({
236+
id: expect.any(String),
237+
name: '',
238+
type: 'string',
239+
value: '',
240+
collapsed: false,
241+
})
242+
expect(field.id.length).toBeGreaterThan(0)
243+
})
244+
245+
it.concurrent('omits description so it is not persisted by default', () => {
246+
expect('description' in createDefaultInputFormatField()).toBe(false)
247+
})
248+
249+
it.concurrent('returns a fresh id and a new object on each call', () => {
250+
const first = createDefaultInputFormatField()
251+
const second = createDefaultInputFormatField()
252+
expect(first.id).not.toBe(second.id)
253+
expect(first).not.toBe(second)
254+
})
255+
})

apps/sim/lib/workflows/input-format.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { generateId } from '@sim/utils/id'
12
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
23
import type { InputFormatField } from '@/lib/workflows/types'
34

@@ -10,6 +11,36 @@ export interface WorkflowInputField {
1011
description?: string
1112
}
1213

14+
/**
15+
* Stateful input-format field as stored in sub-block values: the editor's
16+
* per-row shape, including the editor-only `id` and `collapsed` fields. Stricter
17+
* than the wire-level {@link InputFormatField} (required `name`/`type`/`value`).
18+
*/
19+
interface InputFormatFieldState {
20+
id: string
21+
name: string
22+
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
23+
value: string
24+
description?: string
25+
collapsed: boolean
26+
}
27+
28+
/**
29+
* Creates a new empty input-format field with a fresh id.
30+
*
31+
* Single source of truth for the default field shape used when seeding
32+
* input-format / response-format sub-blocks and when adding rows in the editor.
33+
*/
34+
export function createDefaultInputFormatField(): InputFormatFieldState {
35+
return {
36+
id: generateId(),
37+
name: '',
38+
type: 'string',
39+
value: '',
40+
collapsed: false,
41+
}
42+
}
43+
1344
/**
1445
* Extracts input fields from workflow blocks.
1546
* Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.

apps/sim/stores/workflows/utils.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Edge } from 'reactflow'
44
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
55
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
66
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
7+
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
78
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
89
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
910
import { getBlock } from '@/blocks'
@@ -151,15 +152,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
151152
} else if (subBlock.defaultValue !== undefined) {
152153
initialValue = subBlock.defaultValue
153154
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
154-
initialValue = [
155-
{
156-
id: generateId(),
157-
name: '',
158-
type: 'string',
159-
value: '',
160-
collapsed: false,
161-
},
162-
]
155+
initialValue = [createDefaultInputFormatField()]
163156
} else if (subBlock.type === 'table') {
164157
initialValue = []
165158
}

apps/sim/stores/workflows/workflow/store.ts

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createLogger } from '@sim/logger'
2-
import { toError } from '@sim/utils/errors'
32
import { generateId } from '@sim/utils/id'
43
import type { Edge } from 'reactflow'
54
import { create } from 'zustand'
@@ -9,7 +8,6 @@ import {
98
getDynamicHandleSubblockType,
109
isDynamicHandleSubblock,
1110
} from '@/lib/workflows/dynamic-handle-topology'
12-
import type { SubBlockConfig } from '@/blocks/types'
1311
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
1412
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1513
import {
@@ -37,72 +35,6 @@ import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
3735

3836
const logger = createLogger('WorkflowStore')
3937

40-
/**
41-
* Creates a deep clone of an initial sub-block value to avoid shared references.
42-
*
43-
* @param value - The value to clone.
44-
* @returns A cloned value suitable for initializing sub-block state.
45-
*/
46-
function cloneInitialSubblockValue(value: unknown): unknown {
47-
if (Array.isArray(value)) {
48-
return value.map((item) => cloneInitialSubblockValue(item))
49-
}
50-
51-
if (value && typeof value === 'object') {
52-
return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(
53-
(acc, [key, entry]) => {
54-
acc[key] = cloneInitialSubblockValue(entry)
55-
return acc
56-
},
57-
{}
58-
)
59-
}
60-
61-
return value ?? null
62-
}
63-
64-
/**
65-
* Resolves the initial value for a sub-block based on its configuration.
66-
*
67-
* @param config - The sub-block configuration.
68-
* @returns The resolved initial value or null when no defaults are defined.
69-
*/
70-
function resolveInitialSubblockValue(config: SubBlockConfig): unknown {
71-
if (typeof config.value === 'function') {
72-
try {
73-
const resolved = config.value({})
74-
return cloneInitialSubblockValue(resolved)
75-
} catch (error) {
76-
logger.warn('Failed to resolve dynamic sub-block default value', {
77-
subBlockId: config.id,
78-
error: toError(error).message,
79-
})
80-
}
81-
}
82-
83-
if (config.defaultValue !== undefined) {
84-
return cloneInitialSubblockValue(config.defaultValue)
85-
}
86-
87-
if (config.type === 'input-format') {
88-
return [
89-
{
90-
id: generateId(),
91-
name: '',
92-
type: 'string',
93-
value: '',
94-
collapsed: false,
95-
},
96-
]
97-
}
98-
99-
if (config.type === 'table') {
100-
return []
101-
}
102-
103-
return null
104-
}
105-
10638
const initialState = {
10739
currentWorkflowId: null,
10840
blocks: {},

0 commit comments

Comments
 (0)