Skip to content

Commit b360887

Browse files
icecrasher321claude
andcommitted
refactor(sub-blocks): delete fetchOptions — a sub-block's options are a selector or derived, never both
Completes the migration. `fetchOptions`/`fetchOptionById` are off `SubBlockConfig`, off both controls, and out of `useFetchedOptions`, leaving exactly two ways a sub-block gets its options: selectorKey — a registered selector. The ONLY way to load a remote list. Parameterized by an explicit SelectorContext, so it works on the canvas, in the fork sync modal, and anywhere else. options — a static array, or a pure function of the block's own values. No I/O. Reading the remaining callsites showed most of the "derived" ones were nothing of the kind — they were workspace-scoped remote fetches wearing a local-looking signature. Those became seven `workspace.*` selectors (credential providers, credential groups + their per-group providers, secret names, raw secret names, sandboxes, trigger types) plus `providers.openrouterEmbeddingModels`. Only the agent block's three capability dropdowns were genuinely derived; `options` now takes the block's values so they can say so directly. The parameter is optional, so every existing zero-argument options function is untouched. `imap.mailboxes` is the one selector whose account is typed rather than stored. Its password is deliberately absent from the query key: a query key identifies a resource, a credential authorizes access to it. `oauthCredential` is safe there because it is only an id — a typed password is a secret, and keys are cached and surfaced by devtools. Host, port, TLS and username already identify the mailbox list uniquely; the password rides the body exactly as before. `selectorExcludeSelf` replaces the one thing a shared `sim.workflows` selector could not express. It is a declared flag rather than a blanket rule because the answer differs per field: the Sim trigger never receives events about its own workflow, while the Logs block legitimately reads the logs of the workflow it runs in. Deletes `lib/workflows/subblocks/options.ts` and `triggers/editor-state.ts` entirely — every caller was a `fetchOptions` resolver. The live-registry test for the trigger vocabulary moves to the selector that now owns it, keeping its lazy-import cycle guarantee under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cf474ac commit b360887

30 files changed

Lines changed: 491 additions & 778 deletions

File tree

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

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,8 @@ interface ComboBoxProps {
7171
config: SubBlockConfig
7272
/** Registered selector supplying the options. The canonical source for a remote list. */
7373
selectorKey?: SelectorKey
74-
/** Async function to fetch options dynamically */
75-
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
76-
/** Async function to fetch a single option's label by ID (for hydration) */
77-
fetchOptionById?: (
78-
blockId: string,
79-
optionId: string
80-
) => Promise<{ label: string; id: string } | null>
74+
/** Drop the hosting workflow from a `sim.workflows` list. */
75+
selectorExcludeSelf?: boolean
8176
/** Field dependencies that trigger option refetch when changed */
8277
dependsOn?: SubBlockConfig['dependsOn']
8378
}
@@ -94,8 +89,7 @@ export const ComboBox = memo(function ComboBox({
9489
placeholder = 'Type or select an option...',
9590
config,
9691
selectorKey,
97-
fetchOptions,
98-
fetchOptionById,
92+
selectorExcludeSelf,
9993
dependsOn,
10094
}: ComboBoxProps) {
10195
const activeSearchTarget = useActiveSearchTarget()
@@ -136,8 +130,7 @@ export const ComboBox = memo(function ComboBox({
136130
blockId,
137131
dependsOnFields,
138132
selectorKey,
139-
fetchOptions,
140-
fetchOptionById,
133+
selectorExcludeSelf,
141134
isPreview: Boolean(isPreview),
142135
disabled: Boolean(disabled),
143136
valueToHydrate: value as string | null | undefined,
@@ -202,7 +195,7 @@ export const ComboBox = memo(function ComboBox({
202195
let opts: ComboBoxOption[] =
203196
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
204197

205-
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
198+
if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) {
206199
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
207200
}
208201

@@ -230,7 +223,7 @@ export const ComboBox = memo(function ComboBox({
230223

231224
return opts
232225
}, [
233-
fetchOptions,
226+
isDynamic,
234227
normalizedFetchedOptions,
235228
staticOptions,
236229
hydratedOption,

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type { SubBlockConfig } from '@/blocks/types'
1717
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
1818
import type { SelectorKey } from '@/hooks/selectors/types'
1919
import { useOperationAccess } from '@/hooks/use-operation-access'
20+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
21+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
2022
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2123

2224
/** Selected-value badges shown before folding the rest into a "+N" badge. */
@@ -41,7 +43,7 @@ type DropdownOption =
4143
*/
4244
interface DropdownProps {
4345
/** Static options array or function that returns options */
44-
options: DropdownOption[] | (() => DropdownOption[])
46+
options: DropdownOption[] | ((params?: { values: Record<string, unknown> }) => DropdownOption[])
4547
/** Default value to select when no value is set */
4648
defaultValue?: string
4749
/** Unique identifier for the block */
@@ -62,13 +64,8 @@ interface DropdownProps {
6264
multiSelect?: boolean
6365
/** Registered selector supplying the options. The canonical source for a remote list. */
6466
selectorKey?: SelectorKey
65-
/** Async function to fetch options dynamically */
66-
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
67-
/** Async function to fetch a single option's label by ID (for hydration) */
68-
fetchOptionById?: (
69-
blockId: string,
70-
optionId: string
71-
) => Promise<{ label: string; id: string } | null>
67+
/** Drop the hosting workflow from a `sim.workflows` list. */
68+
selectorExcludeSelf?: boolean
7269
/** Field dependencies that trigger option refetch when changed */
7370
dependsOn?: SubBlockConfig['dependsOn']
7471
/** Enable search input in dropdown */
@@ -98,8 +95,7 @@ export const Dropdown = memo(function Dropdown({
9895
placeholder = 'Select an option...',
9996
multiSelect = false,
10097
selectorKey,
101-
fetchOptions,
102-
fetchOptionById,
98+
selectorExcludeSelf,
10399
dependsOn,
104100
searchable = false,
105101
preserveLabelCase = false,
@@ -140,9 +136,15 @@ export const Dropdown = memo(function Dropdown({
140136
: []
141137
: null
142138

139+
// Derived option lists read the block's own values (a model's valid reasoning efforts);
140+
// `dependsOn` already re-renders this control when one of those siblings changes.
141+
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
142+
const blockValues = useSubBlockStore((state) =>
143+
activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined
144+
)
143145
const evaluatedOptions = useMemo(() => {
144-
return typeof options === 'function' ? options() : options
145-
}, [options])
146+
return typeof options === 'function' ? options({ values: blockValues ?? {} }) : options
147+
}, [options, blockValues])
146148

147149
const {
148150
fetchedOptions,
@@ -155,8 +157,7 @@ export const Dropdown = memo(function Dropdown({
155157
blockId,
156158
dependsOnFields,
157159
selectorKey,
158-
fetchOptions,
159-
fetchOptionById,
160+
selectorExcludeSelf,
160161
isPreview: Boolean(isPreview),
161162
disabled: Boolean(disabled),
162163
valueToHydrate: singleValue,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ interface UseFetchedOptionsProps {
3333
* work on the canvas, and was in every case a duplicate of a selector that already existed.
3434
*/
3535
selectorKey?: SelectorKey
36-
fetchOptions?: (blockId: string) => Promise<FetchedOption[]>
37-
fetchOptionById?: (blockId: string, optionId: string) => Promise<FetchedOption | null>
36+
/** Drop the hosting workflow from the list — see `SubBlockConfig.selectorExcludeSelf`. */
37+
selectorExcludeSelf?: boolean
3838
isPreview: boolean
3939
disabled: boolean
4040
/**
@@ -82,8 +82,7 @@ export function useFetchedOptions({
8282
blockId,
8383
dependsOnFields,
8484
selectorKey,
85-
fetchOptions: fetchOptionsProp,
86-
fetchOptionById: fetchOptionByIdProp,
85+
selectorExcludeSelf,
8786
isPreview,
8887
disabled,
8988
valueToHydrate,
@@ -128,12 +127,14 @@ export function useFetchedOptions({
128127
: {}
129128
const merged: Record<string, { value?: unknown }> = { ...(block.subBlocks ?? {}) }
130129
for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value }
131-
return buildSelectorContextFromBlock(block.type, merged, {
130+
const context = buildSelectorContextFromBlock(block.type, merged, {
132131
workflowId: activeWorkflowId ?? undefined,
133132
workspaceId: workspaceId ?? undefined,
134133
canonicalModes: block.data?.canonicalModes,
135134
})
136-
}, [blockId, activeWorkflowId, workspaceId])
135+
if (selectorExcludeSelf && activeWorkflowId) context.excludeWorkflowId = activeWorkflowId
136+
return context
137+
}, [blockId, activeWorkflowId, workspaceId, selectorExcludeSelf])
137138

138139
const selectorDefinition = selectorKey ? getSelectorDefinition(selectorKey) : undefined
139140

@@ -146,7 +147,6 @@ export function useFetchedOptions({
146147
* new identity and the scope reset below refetches, exactly as it does for a prop fetcher.
147148
*/
148149
const fetchOptions = useMemo(() => {
149-
if (fetchOptionsProp) return fetchOptionsProp
150150
if (!selectorDefinition) return undefined
151151
const definition = selectorDefinition
152152
return async (): Promise<FetchedOption[]> => {
@@ -162,11 +162,10 @@ export function useFetchedOptions({
162162
return options.map((option) => ({ id: option.id, label: option.label }))
163163
}
164164
// eslint-disable-next-line react-hooks/exhaustive-deps -- dependencyValues is the refetch scope
165-
}, [fetchOptionsProp, selectorDefinition, readSelectorContext, dependencyValues])
165+
}, [selectorDefinition, readSelectorContext, dependencyValues])
166166

167167
/** Label hydration for a stored id, from the same definition. */
168168
const fetchOptionById = useMemo(() => {
169-
if (fetchOptionByIdProp) return fetchOptionByIdProp
170169
const definition = selectorDefinition
171170
const fetchById = definition?.fetchById
172171
if (!definition || !fetchById) return undefined
@@ -176,7 +175,7 @@ export function useFetchedOptions({
176175
const option = await fetchById({ key: definition.key, context, detailId: optionId, signal })
177176
return option ? { id: option.id, label: option.label } : null
178177
}
179-
}, [fetchOptionByIdProp, selectorDefinition, readSelectorContext])
178+
}, [selectorDefinition, readSelectorContext])
180179

181180
const [fetchedOptions, setFetchedOptions] = useState<FetchedOption[]>([])
182181
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
@@ -204,7 +203,7 @@ export function useFetchedOptions({
204203
setIsLoadingOptions(true)
205204
setFetchError(null)
206205
try {
207-
const options = await fetchOptions(blockId)
206+
const options = await fetchOptions()
208207
if (requestId !== fetchRequestIdRef.current) return
209208
setFetchedOptions(options)
210209
} catch (error) {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,7 @@ function SubBlockComponent({
681681
disabled={isDisabled}
682682
multiSelect={config.multiSelect}
683683
selectorKey={config.selectorKey}
684-
fetchOptions={config.fetchOptions}
685-
fetchOptionById={config.fetchOptionById}
684+
selectorExcludeSelf={config.selectorExcludeSelf}
686685
dependsOn={config.dependsOn}
687686
searchable={config.searchable}
688687
preserveLabelCase={config.preserveLabelCase}
@@ -717,8 +716,7 @@ function SubBlockComponent({
717716
disabled={isDisabled}
718717
config={config}
719718
selectorKey={config.selectorKey}
720-
fetchOptions={config.fetchOptions}
721-
fetchOptionById={config.fetchOptionById}
719+
selectorExcludeSelf={config.selectorExcludeSelf}
722720
dependsOn={config.dependsOn}
723721
/>
724722
</div>

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@ describe.concurrent('Blocks Module', () => {
904904
(sb) => sb.id === 'model' && sb.condition?.value === provider
905905
)
906906
if (provider === 'openrouter') {
907-
expect(modelSubBlock?.fetchOptions).toBeTypeOf('function')
907+
expect(modelSubBlock?.selectorKey).toBeTypeOf('string')
908908
} else {
909909
expect(
910910
Array.isArray(modelSubBlock?.options) ? modelSubBlock.options.length : 0

0 commit comments

Comments
 (0)