Skip to content

Commit 42cee27

Browse files
fix(forking): stop a parent re-pick blanking a dependent's stored target value (#6787)
* fix(forking): stop a parent re-pick blanking a dependent's stored target value A dependent selector (a sheet under a spreadsheet, a label under a mailbox) is invalidated when its parent is re-picked, because the stored child no longer exists under the new parent. That invalidation was recorded by writing an empty string into the in-session override map — the same value the user's own "clear this field" produces. The two are not the same thing, and the map is submitted verbatim and written into the target workflow's configuration, so an invalidated field cleared the target's real stored value. The sharpest case is an undo. Re-pick a parent away from its original target, then back. The parent nets out unchanged, so nothing is remapped and the remap's own clearing pass never runs — but the child is still blank, and that blank lands on a value the user never touched, with nothing in the UI showing it happened. Record the invalidation with a distinct marker instead. It reads as blank in the selector, the in-block chain context, and the Sync gate, so a required invalidated field still blocks Sync and still renders; but it is omitted from the submitted payload rather than sent as empty, so no override is written and the target keeps what it had. A blank the user picked themselves is still submitted and still clears the target. Also: skip the cascade entirely when a re-pick selects the value the field already had, since the selector fires its change handler either way. Fork file copy: a file whose name is already taken in a reused target folder is de-duplicated with the same allocator the ordinary upload path uses, rather than colliding with the folder-name unique index and being dropped from the fork with its blob deleted. Adds hook-level coverage for the submitted payload, which had none. * fix(forking): preserve dependent-chain semantics * fix(forking): preserve edits during fork sync * fix(workflows): clear stale dependent inputs in Mothership edits --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent 43821e2 commit 42cee27

27 files changed

Lines changed: 1842 additions & 313 deletions

File tree

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { ChipCombobox, type ComboboxOption } from '@sim/emcn'
55
import { Loader } from '@sim/emcn/icons'
66
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
77
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
8+
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
89
import type {
910
ConfigFieldMap,
1011
ConfigFieldValue,
1112
} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields'
12-
import { getDependsOnFields } from '@/blocks/utils'
1313
import type { ConnectorConfigField } from '@/connectors/types'
1414
import { getSelectorDefinition } from '@/hooks/selectors/registry'
1515
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useMemo, useState } from 'react'
4-
import { getDependsOnFields } from '@/blocks/utils'
4+
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
55
import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types'
66

77
export type ConfigFieldValue = string | string[]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { Combobox, type ComboboxOption, cn } from '@sim/emcn'
33
import { Plus } from '@sim/emcn/icons'
44
import { useReactFlow } from 'reactflow'
5+
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
56
import { SandboxCreateModal } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal'
67
import type { SandboxLanguage } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
78
import { shouldClearMissingOption } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/missing-option'
@@ -13,7 +14,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
1314
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1415
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
1516
import type { SubBlockConfig } from '@/blocks/types'
16-
import { getDependsOnFields } from '@/blocks/utils'
1717
import { usePermissionConfig } from '@/hooks/use-permission-config'
1818
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1919

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import {
66
NO_DENIED_OPERATIONS,
77
OPERATION_SUBBLOCK_ID,
88
} from '@/lib/permission-groups/operation-access'
9+
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
910
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
1011
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
1112
import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options'
1213
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
1314
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1415
import { getBlock } from '@/blocks/registry'
1516
import type { SubBlockConfig } from '@/blocks/types'
16-
import { getDependsOnFields } from '@/blocks/utils'
1717
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
1818
import { useOperationAccess } from '@/hooks/use-operation-access'
1919
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
1+
import { getTransitiveSubBlockDependents } from '@/lib/workflows/subblocks/dependencies'
22
import { getBlock } from '@/blocks/registry'
33

44
/**
55
* Clear every TRANSITIVE `dependsOn` descendant of `changedParamId` in a nested tool's params,
66
* mirroring the top-level block clear (`use-collaborative-workflow`). Reuses the shared
7-
* {@link getWorkflowSearchDependentClears} walk - transitive BFS plus canonical-pair expansion, so a
7+
* {@link getTransitiveSubBlockDependents} walk - transitive BFS plus canonical-pair expansion, so a
88
* basic OR advanced member change clears the dependent - so both surfaces clear identically. Only
99
* descendants that currently hold a non-empty value are reset to `''`; the changed param itself and
1010
* non-descendants are untouched. Returns the same reference when nothing changed.
@@ -16,7 +16,7 @@ export function clearDependentToolParams(
1616
): Record<string, string> {
1717
const subBlocks = getBlock(toolType)?.subBlocks ?? []
1818
let next: Record<string, string> | null = null
19-
for (const { subBlockId } of getWorkflowSearchDependentClears(subBlocks, changedParamId)) {
19+
for (const { subBlockId } of getTransitiveSubBlockDependents(subBlocks, [changedParamId])) {
2020
if (!params[subBlockId]) continue
2121
next ??= { ...params }
2222
next[subBlockId] = ''

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Button, cn, Input, toast } from '@sim/emcn'
55
import { ChevronDown, ChevronRight, ChevronUp, X } from '@sim/emcn/icons'
66
import { useParams } from 'next/navigation'
77
import { useShallow } from 'zustand/react/shallow'
8-
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
98
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
109
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
1110
import {
@@ -20,6 +19,7 @@ import {
2019
import { getWorkflowSearchBlocks } from '@/lib/workflows/search-replace/state'
2120
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
2221
import type { WorkflowSearchReplaceSubflowUpdate } from '@/lib/workflows/search-replace/types'
22+
import { getTransitiveSubBlockDependents } from '@/lib/workflows/subblocks/dependencies'
2323
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
2424
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2525
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
@@ -474,10 +474,9 @@ export function WorkflowSearchReplace() {
474474
const blockConfig = block ? getBlock(block.type) : null
475475
if (!blockConfig?.subBlocks) continue
476476

477-
const dependentClears = getWorkflowSearchDependentClears(
478-
blockConfig.subBlocks,
479-
update.subBlockId
480-
)
477+
const dependentClears = getTransitiveSubBlockDependents(blockConfig.subBlocks, [
478+
update.subBlockId,
479+
])
481480
for (const clear of dependentClears) {
482481
const alreadyUpdated = batchUpdates.some(
483482
(candidate) =>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
import { resolveSelectedTriggerId } from '@/lib/workflows/blocks/canvas-trigger-sentence'
5151
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
5252
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
53+
import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies'
5354
import {
5455
getDisplayValue,
5556
hasDisplayableRowValue,
@@ -96,7 +97,6 @@ import {
9697
SELECTOR_TYPES_HYDRATION_REQUIRED,
9798
type SubBlockConfig,
9899
} from '@/blocks/types'
99-
import { getDependsOnFields } from '@/blocks/utils'
100100
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
101101
import { useCustomTools } from '@/hooks/queries/custom-tools'
102102
import { useDeployWorkflow } from '@/hooks/queries/deployments'

apps/sim/blocks/utils.test.ts

Lines changed: 0 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,10 @@ vi.mock('@/lib/oauth/utils', () => ({
6767
getScopesForService: vi.fn(() => []),
6868
}))
6969

70-
import type { SubBlockConfig } from '@/blocks/types'
7170
import {
7271
BUILT_IN_TOOL_TYPES,
7372
getApiKeyCondition,
74-
getDependsOnFields,
7573
getSerializedModelProviderId,
76-
getSubBlocksDependingOnChange,
7774
parseOptionalBooleanInput,
7875
parseOptionalJsonInput,
7976
parseOptionalNumberInput,
@@ -377,96 +374,6 @@ describe('parseOptionalBooleanInput', () => {
377374
})
378375
})
379376

380-
describe('getDependsOnFields', () => {
381-
it('returns an empty array when dependsOn is unset', () => {
382-
expect(getDependsOnFields(undefined)).toEqual([])
383-
})
384-
385-
it('returns array dependencies unchanged', () => {
386-
expect(getDependsOnFields(['credential', 'projectId'])).toEqual(['credential', 'projectId'])
387-
})
388-
389-
it('flattens all and any dependencies', () => {
390-
expect(getDependsOnFields({ all: ['credential'], any: ['teamId', 'manualTeamId'] })).toEqual([
391-
'credential',
392-
'teamId',
393-
'manualTeamId',
394-
])
395-
})
396-
})
397-
398-
describe('getSubBlocksDependingOnChange', () => {
399-
it('finds direct dependents of a changed subblock', () => {
400-
const subBlocks: SubBlockConfig[] = [
401-
{ id: 'provider', title: 'Provider', type: 'dropdown' },
402-
{ id: 'model', title: 'Model', type: 'dropdown', dependsOn: ['provider'] },
403-
{ id: 'prompt', title: 'Prompt', type: 'long-input' },
404-
]
405-
406-
expect(
407-
getSubBlocksDependingOnChange(subBlocks, 'provider').map((subBlock) => subBlock.id)
408-
).toEqual(['model'])
409-
})
410-
411-
it('matches dependents through canonical basic and advanced siblings', () => {
412-
const subBlocks: SubBlockConfig[] = [
413-
{
414-
id: 'channel',
415-
title: 'Channel',
416-
type: 'channel-selector',
417-
canonicalParamId: 'channelId',
418-
mode: 'basic',
419-
},
420-
{
421-
id: 'manualChannel',
422-
title: 'Channel ID',
423-
type: 'short-input',
424-
canonicalParamId: 'channelId',
425-
mode: 'advanced',
426-
},
427-
{
428-
id: 'messageId',
429-
title: 'Message ID',
430-
type: 'short-input',
431-
dependsOn: ['channelId'],
432-
},
433-
{
434-
id: 'threadTs',
435-
title: 'Thread Timestamp',
436-
type: 'short-input',
437-
dependsOn: ['otherField'],
438-
},
439-
]
440-
441-
expect(
442-
getSubBlocksDependingOnChange(subBlocks, 'manualChannel').map((subBlock) => subBlock.id)
443-
).toEqual(['messageId'])
444-
expect(
445-
getSubBlocksDependingOnChange(subBlocks, 'channel').map((subBlock) => subBlock.id)
446-
).toEqual(['messageId'])
447-
})
448-
449-
it('matches object-form dependencies when any listed dependency changes', () => {
450-
const subBlocks: SubBlockConfig[] = [
451-
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
452-
{ id: 'teamId', title: 'Team', type: 'short-input' },
453-
{
454-
id: 'projectId',
455-
title: 'Project',
456-
type: 'short-input',
457-
dependsOn: { all: ['credential'], any: ['teamId'] },
458-
},
459-
]
460-
461-
expect(
462-
getSubBlocksDependingOnChange(subBlocks, 'credential').map((subBlock) => subBlock.id)
463-
).toEqual(['projectId'])
464-
expect(
465-
getSubBlocksDependingOnChange(subBlocks, 'teamId').map((subBlock) => subBlock.id)
466-
).toEqual(['projectId'])
467-
})
468-
})
469-
470377
describe('getSerializedModelProviderId', () => {
471378
const resolver = vi.mocked(getProviderFromModel)
472379

apps/sim/blocks/utils.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
} from '@/lib/core/config/env-flags'
99
import { getScopesForService } from '@/lib/oauth/utils'
1010
import { containsReference } from '@/lib/workflows/sanitization/references'
11-
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
1211
import type { SubBlockConfig } from '@/blocks/types'
1312
import {
1413
getBaseModelProviders,
@@ -112,39 +111,6 @@ export function getPiModelOptions() {
112111
})
113112
}
114113

115-
/**
116-
* Gets all dependency fields as a flat array.
117-
* Handles both simple array format and object format with all/any fields.
118-
*/
119-
export function getDependsOnFields(dependsOn: SubBlockConfig['dependsOn']): string[] {
120-
if (!dependsOn) return []
121-
if (Array.isArray(dependsOn)) return dependsOn
122-
return [...(dependsOn.all || []), ...(dependsOn.any || [])]
123-
}
124-
125-
/**
126-
* Finds subblocks that depend on a changed field, accounting for canonical pairs.
127-
*/
128-
export function getSubBlocksDependingOnChange(
129-
allSubBlocks: SubBlockConfig[],
130-
changedSubBlockId: string
131-
): SubBlockConfig[] {
132-
const canonicalIndex = buildCanonicalIndex(allSubBlocks)
133-
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[changedSubBlockId]
134-
const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined
135-
const changedFields = new Set<string>([changedSubBlockId])
136-
137-
if (canonicalId) changedFields.add(canonicalId)
138-
if (group?.basicId) changedFields.add(group.basicId)
139-
for (const advancedId of group?.advancedIds || []) {
140-
changedFields.add(advancedId)
141-
}
142-
143-
return allSubBlocks.filter((subBlock) =>
144-
getDependsOnFields(subBlock.dependsOn).some((field) => changedFields.has(field))
145-
)
146-
}
147-
148114
function getProviderFromStore(model: string): string | null {
149115
const { providers } = useProvidersStore.getState()
150116
const normalized = model.toLowerCase()

0 commit comments

Comments
 (0)