Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import {
restoreCursorAfterInsertion,
sanitizeForParsing,
validateJavaScript,
validatePython,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
Expand Down Expand Up @@ -166,7 +171,7 @@ interface CodeProps {
defaultCollapsed?: boolean
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
showCopyButton?: boolean
onValidationChange?: (isValid: boolean) => void
onValidationChange?: (isValid: boolean, errorMessage?: string | null) => void
wandConfig: {
enabled: boolean
prompt: string
Expand Down Expand Up @@ -250,6 +255,18 @@ export const Code = memo(function Code({
}
}, [shouldValidateJson, trimmedCode])

const syntaxError = useMemo(() => {
if (effectiveLanguage === 'json' || !trimmedCode) return null
const sanitized = sanitizeForParsing(trimmedCode)
if (effectiveLanguage === 'javascript') {
return validateJavaScript(sanitized)
}
if (effectiveLanguage === 'python') {
return validatePython(sanitized)
}
return null
}, [effectiveLanguage, trimmedCode])

const gutterWidthPx = useMemo(() => {
const lineCount = code.split('\n').length
return calculateGutterWidth(lineCount)
Expand Down Expand Up @@ -341,19 +358,21 @@ export const Code = memo(function Code({
useEffect(() => {
if (!onValidationChange) return

const isValid = !shouldValidateJson || isValidJson
const isValid = (!shouldValidateJson || isValidJson) && !syntaxError

if (isValid) {
onValidationChange(true)
onValidationChange(true, null)
return
}

const errorMessage = !isValidJson ? 'Invalid JSON' : syntaxError

const timeoutId = setTimeout(() => {
onValidationChange(false)
onValidationChange(false, errorMessage)
}, 150)

return () => clearTimeout(timeoutId)
}, [isValidJson, onValidationChange, shouldValidateJson])
}, [isValidJson, syntaxError, onValidationChange, shouldValidateJson])

useEffect(() => {
handleStreamStartRef.current = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const getPreviewValue = (
* Renders the label with optional validation and description tooltips.
*
* @param config - The sub-block configuration defining the label content
* @param isValidJson - Whether the JSON content is valid (for code blocks)
* @param codeValidation - Validation state for code blocks (valid flag + optional error message)
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
* @param wandState - State and handlers for the inline AI generate feature
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
Expand All @@ -200,7 +200,7 @@ const getPreviewValue = (
*/
const renderLabel = (
config: SubBlockConfig,
isValidJson: boolean,
codeValidation: { isValid: boolean; errorMessage: string | null },
subBlockValues?: Record<string, any>,
wandState?: {
isSearchActive: boolean
Expand Down Expand Up @@ -250,21 +250,18 @@ const renderLabel = (
{config.title}
{required && <span className='ml-0.5'>*</span>}
{labelSuffix}
{config.type === 'code' &&
config.language === 'json' &&
!isValidJson &&
!wandState?.isStreaming && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{config.type === 'code' && !codeValidation.isValid && !wandState?.isStreaming && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{codeValidation.errorMessage ?? 'Syntax error'}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</Label>
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
{showCopy && (
Expand Down Expand Up @@ -466,7 +463,8 @@ function SubBlockComponent({
const params = useParams()
const workspaceId = params.workspaceId as string

const [isValidJson, setIsValidJson] = useState(true)
const [isValidCode, setIsValidCode] = useState(true)
const [codeErrorMessage, setCodeErrorMessage] = useState<string | null>(null)
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [copied, setCopied] = useState(false)
Expand All @@ -484,8 +482,9 @@ function SubBlockComponent({
e.stopPropagation()
}

const handleValidationChange = (isValid: boolean): void => {
setIsValidJson(isValid)
const handleValidationChange = (isValid: boolean, errorMessage?: string | null): void => {
setIsValidCode(isValid)
setCodeErrorMessage(errorMessage ?? null)
}
Comment on lines +485 to 488
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleValidationChange not memoized — triggers unnecessary re-renders

handleValidationChange is a plain function created on every render. Because it is passed as the onValidationChange prop to Code (which is a memo component with default shallow comparison), every state update inside SubBlockComponent — including those caused by handleValidationChange itself — creates a new function reference, which then causes Code to re-render and re-fire the useEffect that has onValidationChange in its dependency array. This sets a fresh 150 ms timer for invalid code, and the cycle repeats once more before React's bail-out for equal state values finally stops it.

Wrapping the callback in useCallback with an empty dependency array (the setState functions from useState are stable) fixes this:

Suggested change
const handleValidationChange = (isValid: boolean, errorMessage?: string | null): void => {
setIsValidCode(isValid)
setCodeErrorMessage(errorMessage ?? null)
}
const handleValidationChange = useCallback((isValid: boolean, errorMessage?: string | null): void => {
setIsValidCode(isValid)
setCodeErrorMessage(errorMessage ?? null)
}, [])


const isWandEnabled = config.wandConfig?.enabled ?? false
Expand Down Expand Up @@ -1151,7 +1150,7 @@ function SubBlockComponent({
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
{renderLabel(
config,
isValidJson,
{ isValid: isValidCode, errorMessage: codeErrorMessage },
subBlockValues,
{
isSearchActive,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sanitizeForParsing, validateJavaScript, validatePython } from './utils'

describe('sanitizeForParsing', () => {
it('replaces <Block.output> references with valid identifiers', () => {
const result = sanitizeForParsing('const x = <Block.output>')
expect(result).not.toContain('<')
expect(result).not.toContain('>')
expect(result).toContain('__placeholder_')
})

it('replaces {{ENV_VAR}} with valid identifiers', () => {
const result = sanitizeForParsing('const url = {{API_URL}}')
expect(result).not.toContain('{{')
expect(result).not.toContain('}}')
expect(result).toContain('__placeholder_')
})

it('replaces nested path references like <Block.output[0].field>', () => {
const result = sanitizeForParsing('const x = <Agent.response.choices[0].text>')
expect(result).not.toContain('<Agent')
})

it('replaces loop/parallel context references', () => {
const result = sanitizeForParsing('const item = <loop.currentItem>')
expect(result).not.toContain('<loop')
})

it('replaces variable references', () => {
const result = sanitizeForParsing('const v = <variable.myVar>')
expect(result).not.toContain('<variable')
})

it('handles multiple references in one string', () => {
const code = 'const a = <Block1.out>; const b = {{SECRET}}; const c = <Block2.value>'
const result = sanitizeForParsing(code)
expect(result).not.toContain('<Block1')
expect(result).not.toContain('{{SECRET}}')
expect(result).not.toContain('<Block2')
expect(result.match(/__placeholder_/g)?.length).toBe(3)
})

it('does not replace regular JS comparison operators', () => {
const code = 'if (a < b && c > d) {}'
const result = sanitizeForParsing(code)
expect(result).toBe(code)
})

it('does not replace HTML tags that are not references', () => {
const code = 'const html = "<div>hello</div>"'
const result = sanitizeForParsing(code)
expect(result).toBe(code)
})
})

describe('validateJavaScript', () => {
it('returns null for valid JavaScript', () => {
expect(validateJavaScript('const x = 1')).toBeNull()
expect(validateJavaScript('function foo() { return 42 }')).toBeNull()
expect(validateJavaScript('const arr = [1, 2, 3].map(x => x * 2)')).toBeNull()
})

it('returns null for valid async/await code', () => {
expect(validateJavaScript('async function foo() { await bar() }')).toBeNull()
})

it('returns null for bare return statements (function block wraps in async fn)', () => {
expect(validateJavaScript('return 42')).toBeNull()
expect(validateJavaScript(sanitizeForParsing('return <Block.output>'))).toBeNull()
expect(validateJavaScript('const x = 1\nreturn x')).toBeNull()
})

it('returns null for await at top level (wrapped in async fn)', () => {
expect(validateJavaScript('const res = await fetch("url")')).toBeNull()
})

it('returns null for valid ES module syntax', () => {
expect(validateJavaScript('import { foo } from "bar"')).toBeNull()
expect(validateJavaScript('export default function() {}')).toBeNull()
})

it('detects missing closing brace', () => {
const result = validateJavaScript('function foo() {')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})

it('detects missing closing paren', () => {
const result = validateJavaScript('console.log("hello"')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})

it('detects unexpected token', () => {
const result = validateJavaScript('const = 5')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})

it('includes adjusted line and column in error message', () => {
const result = validateJavaScript('const x = 1\nconst = 5')
expect(result).toMatch(/line 2/)
expect(result).toMatch(/col \d+/)
})

it('returns null for empty code', () => {
expect(validateJavaScript('')).toBeNull()
})

it('does not error on sanitized references', () => {
const code = sanitizeForParsing('const x = <Block.output> + {{ENV_VAR}}')
expect(validateJavaScript(code)).toBeNull()
})
})

describe('validatePython', () => {
it('returns null for valid Python', () => {
expect(validatePython('x = 1')).toBeNull()
expect(validatePython('def foo():\n return 42')).toBeNull()
expect(validatePython('arr = [1, 2, 3]')).toBeNull()
})

it('returns null for Python with comments', () => {
expect(validatePython('x = 1 # this is a comment')).toBeNull()
expect(validatePython('# full line comment\nx = 1')).toBeNull()
})

it('returns null for Python with strings containing brackets', () => {
expect(validatePython('x = "hello (world)"')).toBeNull()
expect(validatePython("x = 'brackets [here] {too}'")).toBeNull()
})

it('returns null for triple-quoted strings', () => {
expect(validatePython('x = """hello\nworld"""')).toBeNull()
expect(validatePython("x = '''multi\nline\nstring'''")).toBeNull()
})

it('returns null for triple-quoted strings with brackets', () => {
expect(validatePython('x = """has { and ( inside"""')).toBeNull()
})

it('detects unmatched opening paren', () => {
const result = validatePython('foo(1, 2')
expect(result).not.toBeNull()
expect(result).toContain("'('")
})

it('detects unmatched closing paren', () => {
const result = validatePython('foo)')
expect(result).not.toBeNull()
expect(result).toContain("')'")
})

it('detects unmatched bracket', () => {
const result = validatePython('arr = [1, 2')
expect(result).not.toBeNull()
expect(result).toContain("'['")
})

it('detects unterminated string', () => {
const result = validatePython('x = "hello')
expect(result).not.toBeNull()
expect(result).toContain('Unterminated string')
})

it('detects unterminated triple-quoted string', () => {
const result = validatePython('x = """hello')
expect(result).not.toBeNull()
expect(result).toContain('Unterminated triple-quoted string')
})

it('includes line number in error', () => {
const result = validatePython('x = 1\ny = (2')
expect(result).toMatch(/line 2/)
})

it('handles escaped quotes in strings', () => {
expect(validatePython('x = "hello \\"world\\""')).toBeNull()
expect(validatePython("x = 'it\\'s fine'")).toBeNull()
})

it('handles brackets inside comments', () => {
expect(validatePython('x = 1 # unmatched ( here')).toBeNull()
})

it('returns null for empty code', () => {
expect(validatePython('')).toBeNull()
})

it('does not error on sanitized references', () => {
const code = sanitizeForParsing('x = <Block.output> + {{ENV_VAR}}')
expect(validatePython(code)).toBeNull()
})
})
Loading