mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+20
-25
@@ -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))
|
||||
)
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user