Skip to content

Commit be7cb39

Browse files
icecrasher321claude
andcommitted
fix(workspace-forking): resolve a custom block's inputs against the target environment, and stop a re-sync wiping its uploads
A repointed custom block's inputs are configured at sync time, but the modal drew them as bare text fields against no environment at all: - `{{SECRET}}` had no completion, and no way to know which secrets exist in the workspace the value is written INTO. - `<block.output>` had no completion. The canvas dropdown reads the workflow open in the editor; on the fork settings page there is none, and the workflow that matters is the target's. - A `file[]` input has no control here (it is an upload on the canvas), so it had no stored override — and the block was rebuilt from overrides alone, so every sync silently dropped the target's uploaded files. `WorkflowReferenceScope` lets a surface supply the workflow a reference resolves against. Absent a provider, the hooks read the live editor stores exactly as before, so the canvas is unchanged. The scope splits graph from values on purpose: reachability cannot change with the text being typed, and the validation hook runs in every reference-aware sub-block editor at once, so subscribing it to live sub-block values would re-render all of them on every keystroke. A test pins that split. `replaceCustomBlockInputs` now seeds from the target block when it is ALREADY the mapped type, layering the configured values on top. That keeps an input the modal cannot offer a control for, and leaves a field the user simply did not touch alone; a field they explicitly emptied stores `''`, which is an override and still wins. Under a DIFFERENT current type nothing is carried over — those values are keyed by another block's field ids, which is the orphaning this function exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b9eb0ae commit be7cb39

13 files changed

Lines changed: 731 additions & 102 deletions

File tree

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export const GET = withRouteHandler(
154154
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
155155
) ??
156156
readTargetDraftDependentValue(
157-
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
157+
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks,
158158
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
159159
field.subBlockKey
160160
),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ interface EnvVarDropdownProps {
3939
/** Maximum height for the dropdown */
4040
maxHeight?: string
4141
/** Reference to the input element for caret positioning */
42-
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
42+
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
4343
}
4444

4545
/**

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

Lines changed: 18 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ import {
1111
PopoverSection,
1212
usePopoverContext,
1313
} from '@sim/emcn'
14-
import { isEqual } from 'es-toolkit'
15-
import { useShallow } from 'zustand/react/shallow'
16-
import { useStoreWithEqualityFn } from 'zustand/traditional'
1714
import {
1815
getEffectiveBlockOutputType,
1916
getOutputPathsFromSchema,
@@ -28,19 +25,14 @@ import type {
2825
NestedTagChild,
2926
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types'
3027
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
28+
import { useWorkflowReferenceScope } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
3129
import { getBlock } from '@/blocks'
3230
import { BlockTile } from '@/blocks/block-tile'
3331
import type { BlockConfig } from '@/blocks/types'
3432
import { normalizeName } from '@/executor/constants'
35-
import { useVariablesStore } from '@/stores/variables/store'
3633
import type { Variable } from '@/stores/variables/types'
37-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
38-
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
39-
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
4034
import type { BlockState } from '@/stores/workflows/workflow/types'
4135

42-
const EMPTY_VARIABLES: Variable[] = []
43-
4436
/**
4537
* Context for sharing nested navigation state between components.
4638
* This enables unlimited nesting depth with a single back button.
@@ -89,7 +81,7 @@ interface TagDropdownProps {
8981
/** Custom styles for positioning */
9082
style?: React.CSSProperties
9183
/** Reference to the input element for caret positioning */
92-
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
84+
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
9385
}
9486

9587
interface TagComputationResult {
@@ -197,16 +189,13 @@ const ensureRootTag = (tags: string[], rootTag: string): string[] => {
197189
const getOutputTypeForPath = (
198190
block: BlockState,
199191
blockConfig: BlockConfig | null,
200-
blockId: string,
201192
outputPath: string,
202-
mergedSubBlocksOverride?: Record<string, any>
193+
subBlocks: Record<string, any>
203194
): string => {
204195
if (block?.type === 'variables') {
205196
return 'any'
206197
}
207198

208-
const subBlocks =
209-
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
210199
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
211200
const triggerMode = Boolean(block?.triggerMode && isTriggerCapable)
212201

@@ -472,13 +461,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
472461
const blockConfig = getBlock(block.type)
473462
const mergedSubBlocks = getMergedSubBlocks(group.blockId)
474463

475-
childType = getOutputTypeForPath(
476-
block,
477-
blockConfig || null,
478-
group.blockId,
479-
outputPath,
480-
mergedSubBlocks
481-
)
464+
childType = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks)
482465
}
483466

484467
return (
@@ -667,13 +650,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
667650
const blockConfig = getBlock(block.type)
668651
const mergedSubBlocks = getMergedSubBlocks(group.blockId)
669652

670-
tagDescription = getOutputTypeForPath(
671-
block,
672-
blockConfig || null,
673-
group.blockId,
674-
outputPath,
675-
mergedSubBlocks
676-
)
653+
tagDescription = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks)
677654
}
678655
}
679656

@@ -950,27 +927,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
950927
inputValueRef.current = inputValue
951928
cursorPositionRef.current = cursorPosition
952929

953-
const { blocks, edges, loops, parallels } = useWorkflowStore(
954-
useShallow((state) => ({
955-
blocks: state.blocks,
956-
edges: state.edges,
957-
loops: state.loops || {},
958-
parallels: state.parallels || {},
959-
}))
960-
)
961-
962-
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
930+
// The workflow being referenced — the editor's own on the canvas, a supplied one on a
931+
// surface configuring a block that lives in another workflow (see `WorkflowReferenceScope`).
932+
const {
933+
blocks,
934+
edges,
935+
loops,
936+
parallels,
937+
workflowId,
938+
subBlockValues: workflowSubBlockValues,
939+
variables: workflowVariables,
940+
} = useWorkflowReferenceScope()
963941
const rawAccessiblePrefixes = useAccessibleReferencePrefixes(blockId)
964942

965943
const combinedAccessiblePrefixes = useMemo(() => {
966944
if (!rawAccessiblePrefixes) return new Set<string>()
967945
return new Set<string>(rawAccessiblePrefixes)
968946
}, [rawAccessiblePrefixes])
969947

970-
const workflowSubBlockValues = useSubBlockStore(
971-
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
972-
)
973-
974948
const getMergedSubBlocks = useCallback(
975949
(targetBlockId: string): Record<string, any> => {
976950
const base = blocks[targetBlockId]?.subBlocks || {}
@@ -984,18 +958,6 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
984958
[blocks, workflowSubBlockValues]
985959
)
986960

987-
const workflowVariables = useStoreWithEqualityFn(
988-
useVariablesStore,
989-
useCallback(
990-
(state) =>
991-
workflowId
992-
? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId)
993-
: EMPTY_VARIABLES,
994-
[workflowId]
995-
),
996-
isEqual
997-
)
998-
999961
const searchTerm = useMemo(
1000962
() => getTagSearchTerm(inputValue, cursorPosition),
1001963
[inputValue, cursorPosition]
@@ -1479,17 +1441,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
14791441
const parts = tag.split('.')
14801442
if (parts.length >= 3 && blockGroup) {
14811443
const arrayFieldName = parts[1]
1482-
const block = useWorkflowStore.getState().blocks[blockGroup.blockId]
1444+
const block = blocks[blockGroup.blockId]
14831445
const blockConfig = block ? (getBlock(block.type) ?? null) : null
14841446
const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId)
14851447

1486-
const fieldType = getOutputTypeForPath(
1487-
block,
1488-
blockConfig,
1489-
blockGroup.blockId,
1490-
arrayFieldName,
1491-
mergedSubBlocks
1492-
)
1448+
const fieldType = getOutputTypeForPath(block, blockConfig, arrayFieldName, mergedSubBlocks)
14931449

14941450
if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') {
14951451
const blockName = parts[0]
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it } from 'vitest'
7+
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
8+
import {
9+
buildWorkflowReferenceScope,
10+
type WorkflowReferenceScope,
11+
WorkflowReferenceScopeProvider,
12+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
13+
import { normalizeName } from '@/executor/constants'
14+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
15+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
16+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
17+
import type { BlockState } from '@/stores/workflows/workflow/types'
18+
19+
const WORKFLOW_ID = 'wf-1'
20+
21+
function block(id: string, name: string): BlockState {
22+
return { id, name, type: 'agent', subBlocks: {} } as unknown as BlockState
23+
}
24+
25+
/** A → B → C, so only A is upstream of B and only A/B are upstream of C. */
26+
const GRAPH = {
27+
blocks: { a: block('a', 'Alpha'), b: block('b', 'Bravo'), c: block('c', 'Charlie') },
28+
edges: [
29+
{ id: 'e1', source: 'a', target: 'b' },
30+
{ id: 'e2', source: 'b', target: 'c' },
31+
],
32+
}
33+
34+
interface Harness {
35+
result: () => Set<string> | undefined
36+
renderCount: () => number
37+
unmount: () => void
38+
}
39+
40+
function renderPrefixes(blockId: string | undefined, scope?: WorkflowReferenceScope): Harness {
41+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
42+
const container = document.createElement('div')
43+
const root: Root = createRoot(container)
44+
let latest: Set<string> | undefined
45+
let renders = 0
46+
47+
function Probe() {
48+
renders += 1
49+
latest = useAccessibleReferencePrefixes(blockId)
50+
return null
51+
}
52+
53+
act(() => {
54+
root.render(
55+
scope ? (
56+
<WorkflowReferenceScopeProvider scope={scope}>
57+
<Probe />
58+
</WorkflowReferenceScopeProvider>
59+
) : (
60+
<Probe />
61+
)
62+
)
63+
})
64+
65+
return {
66+
result: () => latest,
67+
renderCount: () => renders,
68+
unmount: () => act(() => root.unmount()),
69+
}
70+
}
71+
72+
describe('useAccessibleReferencePrefixes', () => {
73+
beforeEach(() => {
74+
useWorkflowStore.setState({ ...GRAPH, loops: {}, parallels: {} })
75+
useWorkflowRegistry.setState({ activeWorkflowId: WORKFLOW_ID })
76+
useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: {} } })
77+
})
78+
79+
it('offers only the referencing block and its ancestors on the canvas', () => {
80+
const harness = renderPrefixes('c')
81+
const prefixes = harness.result()
82+
expect(prefixes?.has(normalizeName('Alpha'))).toBe(true)
83+
expect(prefixes?.has(normalizeName('Bravo'))).toBe(true)
84+
expect(prefixes?.has(normalizeName('Charlie'))).toBe(true)
85+
harness.unmount()
86+
})
87+
88+
it('excludes a block that is downstream of the referencing one', () => {
89+
const harness = renderPrefixes('a')
90+
expect(harness.result()?.has(normalizeName('Bravo'))).toBe(false)
91+
harness.unmount()
92+
})
93+
94+
it('does not re-render when a sub-block VALUE changes', () => {
95+
// Reachability cannot change with the text being typed, and this hook runs in every
96+
// reference-aware sub-block editor at once. Subscribing it to the sub-block store would
97+
// re-render all of them on every keystroke anywhere in the workflow.
98+
const harness = renderPrefixes('c')
99+
const before = harness.renderCount()
100+
act(() => {
101+
useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: { a: { prompt: 'typing' } } } })
102+
})
103+
expect(harness.renderCount()).toBe(before)
104+
harness.unmount()
105+
})
106+
107+
it('still re-renders when the graph itself changes', () => {
108+
const harness = renderPrefixes('c')
109+
const before = harness.renderCount()
110+
act(() => {
111+
useWorkflowStore.setState({
112+
blocks: { ...GRAPH.blocks, a: block('a', 'Renamed') },
113+
})
114+
})
115+
expect(harness.renderCount()).toBeGreaterThan(before)
116+
expect(harness.result()?.has(normalizeName('Renamed'))).toBe(true)
117+
harness.unmount()
118+
})
119+
120+
it('resolves against a supplied graph instead of the editor’s', () => {
121+
const scope = buildWorkflowReferenceScope({
122+
workflowId: 'other-wf',
123+
blocks: { x: block('x', 'Extract'), y: block('y', 'Load') },
124+
edges: [{ id: 'e', source: 'x', target: 'y' }],
125+
referencingBlockId: 'y',
126+
})
127+
const harness = renderPrefixes('y', scope)
128+
const prefixes = harness.result()
129+
expect(prefixes?.has(normalizeName('Extract'))).toBe(true)
130+
// The editor's own workflow must not leak in.
131+
expect(prefixes?.has(normalizeName('Alpha'))).toBe(false)
132+
harness.unmount()
133+
})
134+
135+
it('offers every block when the referencing one is absent from the supplied graph', () => {
136+
// A block this sync is about to ADD has no position in the target workflow yet; the
137+
// ancestor walk would return nothing and suggest nothing at all.
138+
const scope = buildWorkflowReferenceScope({
139+
workflowId: 'other-wf',
140+
blocks: { x: block('x', 'Extract'), y: block('y', 'Load') },
141+
edges: [{ id: 'e', source: 'x', target: 'y' }],
142+
referencingBlockId: 'not-in-this-workflow',
143+
})
144+
const harness = renderPrefixes('not-in-this-workflow', scope)
145+
const prefixes = harness.result()
146+
expect(prefixes?.has(normalizeName('Extract'))).toBe(true)
147+
expect(prefixes?.has(normalizeName('Load'))).toBe(true)
148+
harness.unmount()
149+
})
150+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
11
import { useMemo } from 'react'
2-
import { useShallow } from 'zustand/react/shallow'
32
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
43
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
4+
import { useWorkflowReferenceGraph } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope'
55
import { normalizeName } from '@/executor/constants'
6-
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
76
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
87

98
export function useAccessibleReferencePrefixes(blockId?: string | null): Set<string> | undefined {
10-
const { blocks, edges, loops, parallels } = useWorkflowStore(
11-
useShallow((state) => ({
12-
blocks: state.blocks,
13-
edges: state.edges,
14-
loops: state.loops || {},
15-
parallels: state.parallels || {},
16-
}))
17-
)
9+
// The GRAPH only — this runs on every keystroke in every reference-aware sub-block editor,
10+
// and reachability cannot change with the values being typed.
11+
const { blocks, edges, loops, parallels, unrestricted } = useWorkflowReferenceGraph()
1812

1913
return useMemo(() => {
2014
if (!blockId) {
2115
return undefined
2216
}
2317

24-
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
25-
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
26-
const accessibleIds = new Set<string>(ancestorIds)
18+
const accessibleIds = new Set<string>()
19+
if (unrestricted) {
20+
// The referencing block is not in this graph, so there is no path to walk. Every block
21+
// is offered instead of none — see `WorkflowReferenceScope.unrestricted`.
22+
Object.keys(blocks).forEach((id) => accessibleIds.add(id))
23+
} else {
24+
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
25+
BlockPathCalculator.findAllPathNodes(graphEdges, blockId).forEach((id) =>
26+
accessibleIds.add(id)
27+
)
28+
}
2729
accessibleIds.add(blockId)
2830

2931
Object.values(loops as Record<string, Loop>).forEach((loop) => {
@@ -46,5 +48,5 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
4648
SYSTEM_REFERENCE_PREFIXES.forEach((prefix) => prefixes.add(prefix))
4749

4850
return prefixes
49-
}, [blockId, blocks, edges, loops, parallels])
51+
}, [blockId, blocks, edges, loops, parallels, unrestricted])
5052
}

0 commit comments

Comments
 (0)