fix(input-format): field not editable race condition (#5102)

* fix(input-format): field not editable race condition

* remove dead code

* simplify
This commit is contained in:
Vikhyath Mondreti
2026-06-16 19:03:10 -07:00
committed by GitHub
parent 2ffc004a0a
commit 8fe090a3a1
6 changed files with 81 additions and 111 deletions
@@ -1,5 +1,4 @@
import { useCallback, useRef } from 'react'
import { generateId } from '@sim/utils/id'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
@@ -21,6 +20,7 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
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[] = [
{ label: 'false', value: 'false' },
]
/**
* Creates a new field with default values
*/
const createDefaultField = (): Field => ({
id: generateId(),
name: '',
type: 'string',
value: '',
description: '',
collapsed: false,
})
/**
* Validates and sanitizes field names by removing control characters and quotes
*/
@@ -127,8 +115,17 @@ export function FieldFormat({
disabled,
})
/**
* Stable fallback field used while the store value is still empty (e.g. a
* newly added block). Caching it in a ref keeps the field id constant across
* renders, so the inputs don't remount on each keystroke and edits commit to
* the same id instead of a freshly generated one.
*/
const fallbackFieldRef = useRef<Field | null>(null)
const fallbackField = (fallbackFieldRef.current ??= createDefaultInputFormatField())
const value = isPreview ? previewValue : storeValue
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [fallbackField]
const isReadOnly = isPreview || disabled
const renderFieldLabel = (label: string) => <Label>{label}</Label>
@@ -138,7 +135,7 @@ export function FieldFormat({
*/
const addField = () => {
if (isReadOnly) return
setStoreValue([...fields, createDefaultField()])
setStoreValue([...fields, createDefaultInputFormatField()])
}
/**
@@ -148,15 +145,19 @@ export function FieldFormat({
if (isReadOnly) return
if (fields.length === 1) {
setStoreValue([createDefaultField()])
setStoreValue([createDefaultInputFormatField()])
return
}
setStoreValue(fields.filter((field) => field.id !== id))
}
const storeValueRef = useRef(storeValue)
storeValueRef.current = storeValue
/**
* Mirrors the rendered fields (store value or stable fallback) so updateField
* always commits against the same ids the UI is currently showing.
*/
const fieldsRef = useRef(fields)
fieldsRef.current = fields
const isReadOnlyRef = useRef(isReadOnly)
isReadOnlyRef.current = isReadOnly
@@ -173,14 +174,8 @@ export function FieldFormat({
? validateFieldName(fieldValue)
: fieldValue
const currentStoreValue = storeValueRef.current
const currentFields: Field[] =
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
? currentStoreValue
: [createDefaultField()]
setStoreValueRef.current(
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
fieldsRef.current.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
)
},
[]
+2 -9
View File
@@ -1,5 +1,6 @@
import { generateId } from '@sim/utils/id'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { getBlock } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -39,15 +40,7 @@ function resolveInitialValue(subBlock: SubBlockConfig): unknown {
}
if (subBlock.type === 'input-format') {
return [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
return [createDefaultInputFormatField()]
}
if (subBlock.type === 'table') {
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultInputFormatField,
extractInputFieldsFromBlocks,
normalizeInputFormatValue,
} from '@/lib/workflows/input-format'
@@ -227,3 +228,28 @@ describe('normalizeInputFormatValue', () => {
expect(normalizeInputFormatValue(input)).toEqual(input)
})
})
describe('createDefaultInputFormatField', () => {
it.concurrent('creates an empty field with the canonical default shape', () => {
const field = createDefaultInputFormatField()
expect(field).toEqual({
id: expect.any(String),
name: '',
type: 'string',
value: '',
collapsed: false,
})
expect(field.id.length).toBeGreaterThan(0)
})
it.concurrent('omits description so it is not persisted by default', () => {
expect('description' in createDefaultInputFormatField()).toBe(false)
})
it.concurrent('returns a fresh id and a new object on each call', () => {
const first = createDefaultInputFormatField()
const second = createDefaultInputFormatField()
expect(first.id).not.toBe(second.id)
expect(first).not.toBe(second)
})
})
+31
View File
@@ -1,3 +1,4 @@
import { generateId } from '@sim/utils/id'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
@@ -10,6 +11,36 @@ export interface WorkflowInputField {
description?: string
}
/**
* Stateful input-format field as stored in sub-block values: the editor's
* per-row shape, including the editor-only `id` and `collapsed` fields. Stricter
* than the wire-level {@link InputFormatField} (required `name`/`type`/`value`).
*/
interface InputFormatFieldState {
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
value: string
description?: string
collapsed: boolean
}
/**
* Creates a new empty input-format field with a fresh id.
*
* Single source of truth for the default field shape used when seeding
* input-format / response-format sub-blocks and when adding rows in the editor.
*/
export function createDefaultInputFormatField(): InputFormatFieldState {
return {
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
}
}
/**
* Extracts input fields from workflow blocks.
* Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.
+2 -9
View File
@@ -4,6 +4,7 @@ import type { Edge } from 'reactflow'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
@@ -151,15 +152,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
initialValue = [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
initialValue = [createDefaultInputFormatField()]
} else if (subBlock.type === 'table') {
initialValue = []
}
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import type { Edge } from 'reactflow'
import { create } from 'zustand'
@@ -9,7 +8,6 @@ import {
getDynamicHandleSubblockType,
isDynamicHandleSubblock,
} from '@/lib/workflows/dynamic-handle-topology'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
@@ -37,72 +35,6 @@ import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
const logger = createLogger('WorkflowStore')
/**
* Creates a deep clone of an initial sub-block value to avoid shared references.
*
* @param value - The value to clone.
* @returns A cloned value suitable for initializing sub-block state.
*/
function cloneInitialSubblockValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => cloneInitialSubblockValue(item))
}
if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = cloneInitialSubblockValue(entry)
return acc
},
{}
)
}
return value ?? null
}
/**
* Resolves the initial value for a sub-block based on its configuration.
*
* @param config - The sub-block configuration.
* @returns The resolved initial value or null when no defaults are defined.
*/
function resolveInitialSubblockValue(config: SubBlockConfig): unknown {
if (typeof config.value === 'function') {
try {
const resolved = config.value({})
return cloneInitialSubblockValue(resolved)
} catch (error) {
logger.warn('Failed to resolve dynamic sub-block default value', {
subBlockId: config.id,
error: toError(error).message,
})
}
}
if (config.defaultValue !== undefined) {
return cloneInitialSubblockValue(config.defaultValue)
}
if (config.type === 'input-format') {
return [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
}
if (config.type === 'table') {
return []
}
return null
}
const initialState = {
currentWorkflowId: null,
blocks: {},