From 1b2420843de09b1efc781f406852e510ae17472c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 16 Jun 2026 15:14:38 -0700 Subject: [PATCH 1/3] fix(input-format): field not editable race condition --- .../components/starter/input-format.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 474c82a3c18..68c2b09a3a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -127,8 +127,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(null) + const fallbackField = (fallbackFieldRef.current ??= createDefaultField()) + 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) => @@ -155,8 +164,12 @@ export function FieldFormat({ 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 +186,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)) ) }, [] From db7d97f7a80d79683a367443b8ae12ada7c89995 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 16 Jun 2026 15:30:46 -0700 Subject: [PATCH 2/3] remove dead code --- .../components/starter/input-format.tsx | 11 +-- apps/sim/lib/workflows/defaults.ts | 11 +-- apps/sim/lib/workflows/input-format.ts | 32 +++++++++ apps/sim/stores/workflows/utils.ts | 11 +-- apps/sim/stores/workflows/workflow/store.ts | 68 ------------------- 5 files changed, 38 insertions(+), 95 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 68c2b09a3a2..f067d4c36d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -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' @@ -77,14 +77,7 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [ /** * Creates a new field with default values */ -const createDefaultField = (): Field => ({ - id: generateId(), - name: '', - type: 'string', - value: '', - description: '', - collapsed: false, -}) +const createDefaultField = (): Field => createDefaultInputFormatField() /** * Validates and sanitizes field names by removing control characters and quotes diff --git a/apps/sim/lib/workflows/defaults.ts b/apps/sim/lib/workflows/defaults.ts index e9dc0f8076b..c326ca43a64 100644 --- a/apps/sim/lib/workflows/defaults.ts +++ b/apps/sim/lib/workflows/defaults.ts @@ -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') { diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 56455a2e8f1..509a1f39db7 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -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,37 @@ export interface WorkflowInputField { description?: string } +/** + * Stateful input-format field as stored in sub-block values. + * + * Extends the wire-level {@link InputFormatField} with the editor-only `id` and + * `collapsed` fields maintained per row in the inputs editor. + */ +export 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. diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index ceee7b786ef..5505a7738f1 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -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 = [] } diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 0e8f011ede6..d7bc200a80f 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -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).reduce>( - (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: {}, From 8e92af96604820246a1df16e0ac67b44e45ed35e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 16 Jun 2026 15:36:17 -0700 Subject: [PATCH 3/3] simplify --- .../components/starter/input-format.tsx | 11 +++----- apps/sim/lib/workflows/input-format.test.ts | 26 +++++++++++++++++++ apps/sim/lib/workflows/input-format.ts | 9 +++---- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index f067d4c36d3..f63cb9ff978 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -74,11 +74,6 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [ { label: 'false', value: 'false' }, ] -/** - * Creates a new field with default values - */ -const createDefaultField = (): Field => createDefaultInputFormatField() - /** * Validates and sanitizes field names by removing control characters and quotes */ @@ -127,7 +122,7 @@ export function FieldFormat({ * the same id instead of a freshly generated one. */ const fallbackFieldRef = useRef(null) - const fallbackField = (fallbackFieldRef.current ??= createDefaultField()) + const fallbackField = (fallbackFieldRef.current ??= createDefaultInputFormatField()) const value = isPreview ? previewValue : storeValue const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [fallbackField] @@ -140,7 +135,7 @@ export function FieldFormat({ */ const addField = () => { if (isReadOnly) return - setStoreValue([...fields, createDefaultField()]) + setStoreValue([...fields, createDefaultInputFormatField()]) } /** @@ -150,7 +145,7 @@ export function FieldFormat({ if (isReadOnly) return if (fields.length === 1) { - setStoreValue([createDefaultField()]) + setStoreValue([createDefaultInputFormatField()]) return } diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 230e7d0890f..886e9aac5ab 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 509a1f39db7..b5a8ac2612b 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -12,12 +12,11 @@ export interface WorkflowInputField { } /** - * Stateful input-format field as stored in sub-block values. - * - * Extends the wire-level {@link InputFormatField} with the editor-only `id` and - * `collapsed` fields maintained per row in the inputs editor. + * 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`). */ -export interface InputFormatFieldState { +interface InputFormatFieldState { id: string name: string type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'