Skip to content

feat(compiler): evaluate static interpolations at compile time #13617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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 @@ -94,6 +94,15 @@ return function render(_ctx, _cache) {
}"
`;

exports[`compiler: codegen > empty interpolation 1`] = `
"
return function render(_ctx, _cache) {
with (_ctx) {
return ""
}
}"
`;

exports[`compiler: codegen > forNode 1`] = `
"
return function render(_ctx, _cache) {
Expand Down Expand Up @@ -183,6 +192,15 @@ export function render(_ctx, _cache) {
}"
`;

exports[`compiler: codegen > static interpolation 1`] = `
"
return function render(_ctx, _cache) {
with (_ctx) {
return "hello1falseundefinednullhi"
}
}"
`;

exports[`compiler: codegen > static text 1`] = `
"
return function render(_ctx, _cache) {
Expand Down
35 changes: 35 additions & 0 deletions packages/compiler-core/__tests__/codegen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type DirectiveArguments,
type ForCodegenNode,
type IfConditionalExpression,
type InterpolationNode,
NodeTypes,
type RootNode,
type VNodeCall,
Expand Down Expand Up @@ -192,6 +193,40 @@ describe('compiler: codegen', () => {
expect(code).toMatchSnapshot()
})

test('static interpolation', () => {
const codegenNode: InterpolationNode = {
type: NodeTypes.INTERPOLATION,
loc: locStub,
content: createSimpleExpression(
`"hello" + 1 + false + undefined + null + ${'`hi`'}`,
true,
locStub,
),
}
const { code } = generate(
createRoot({
codegenNode,
}),
)
expect(code).toMatch(`return "hello1falseundefinednullhi"`)
expect(code).toMatchSnapshot()
})

test('empty interpolation', () => {
const codegenNode: InterpolationNode = {
type: NodeTypes.INTERPOLATION,
loc: locStub,
content: createSimpleExpression(``, true, locStub),
}
const { code } = generate(
createRoot({
codegenNode,
}),
)
expect(code).toMatch(`return ""`)
expect(code).toMatchSnapshot()
})

test('comment', () => {
const { code } = generate(
createRoot({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ return function render(_ctx, _cache) {
const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
Copy link
Author

Choose a reason for hiding this comment

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

We can't remove the toDisplayString here since the optimisation for this test is performed during codegen, after we've emitted the import.
This shouldn't matter since any unused imports will be tree-shaken by most build tools.


return (_openBlock(), _createElementBlock("div", null, _cache[0] || (_cache[0] = [
_createElementVNode("span", null, "foo " + _toDisplayString(1) + " " + _toDisplayString(true), -1 /* CACHED */)
_createElementVNode("span", null, "foo " + "1" + " " + "true", -1 /* CACHED */)
])))
}
}"
Expand All @@ -162,7 +162,7 @@ return function render(_ctx, _cache) {
const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

return (_openBlock(), _createElementBlock("div", null, _cache[0] || (_cache[0] = [
_createElementVNode("span", { foo: 0 }, _toDisplayString(1), -1 /* CACHED */)
_createElementVNode("span", { foo: 0 }, "1", -1 /* CACHED */)
])))
}
}"
Expand Down
17 changes: 17 additions & 0 deletions packages/compiler-core/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type CommentNode,
type CompoundExpressionNode,
type ConditionalExpression,
ConstantTypes,
type ExpressionNode,
type FunctionExpression,
type IfStatement,
Expand All @@ -32,6 +33,7 @@ import { SourceMapGenerator } from 'source-map-js'
import {
advancePositionWithMutation,
assert,
evaluateConstant,
isSimpleIdentifier,
toValidAssetId,
} from './utils'
Expand All @@ -41,6 +43,7 @@ import {
isArray,
isString,
isSymbol,
toDisplayString,
} from '@vue/shared'
import {
CREATE_COMMENT,
Expand Down Expand Up @@ -760,6 +763,20 @@ function genExpression(node: SimpleExpressionNode, context: CodegenContext) {

function genInterpolation(node: InterpolationNode, context: CodegenContext) {
const { push, helper, pure } = context

if (
node.content.type === NodeTypes.SIMPLE_EXPRESSION &&
node.content.constType === ConstantTypes.CAN_STRINGIFY
) {
if (node.content.content) {
push(JSON.stringify(toDisplayString(evaluateConstant(node.content))))
} else {
push(`""`)
}
Comment on lines +771 to +775
Copy link
Author

Choose a reason for hiding this comment

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

We can't call evaluateConstant when node.content is undefined, so we just emit an empty string (which is the behaviour of `toDisplayString(undefined)"


return
}

if (pure) push(PURE_ANNOTATION)
push(`${helper(TO_DISPLAY_STRING)}(`)
genNode(node.content, context)
Expand Down
13 changes: 11 additions & 2 deletions packages/compiler-core/src/transforms/transformExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import { parseExpression } from '@babel/parser'
import { IS_REF, UNREF } from '../runtimeHelpers'
import { BindingTypes } from '../options'

const isLiteralWhitelisted = /*@__PURE__*/ makeMap('true,false,null,this')
const isLiteralWhitelisted = /*@__PURE__*/ makeMap(
'true,false,null,undefined,this',
Copy link
Author

Choose a reason for hiding this comment

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

undefined can be used as a variable name, but we check if it's a variable everywhere we use isLiteralWhitelisted, so I believe this change is sound.

)

export const transformExpression: NodeTransform = (node, context) => {
if (node.type === NodeTypes.INTERPOLATION) {
Expand Down Expand Up @@ -119,7 +121,14 @@ export function processExpression(
return node
}

if (!context.prefixIdentifiers || !node.content.trim()) {
if (!node.content.trim()) {
// This allows stringification to continue in the presence of empty
// interpolations.
node.constType = ConstantTypes.CAN_STRINGIFY
return node
}

if (!context.prefixIdentifiers) {
return node
}

Expand Down
37 changes: 36 additions & 1 deletion packages/compiler-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ import {
TO_HANDLERS,
WITH_MEMO,
} from './runtimeHelpers'
import { NOOP, isObject, isString } from '@vue/shared'
import {
NOOP,
isObject,
isString,
isSymbol,
toDisplayString,
} from '@vue/shared'
import type { PropsExpression } from './transforms/transformElement'
import { parseExpression } from '@babel/parser'
import type { Expression, Node } from '@babel/types'
Expand Down Expand Up @@ -564,3 +570,32 @@ export function getMemoedVNodeCall(
}

export const forAliasRE: RegExp = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/

// __UNSAFE__
// Reason: eval.
// It's technically safe to eval because only constant expressions are possible
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
// in addition, constant exps bail on presence of parens so you can't even
// run JSFuck in here. But we mark it unsafe for security review purposes.
// (see compiler-core/src/transforms/transformExpression)
export function evaluateConstant(exp: ExpressionNode): string {
Copy link
Author

Choose a reason for hiding this comment

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

Moved - unchanged

if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
return new Function(`return (${exp.content})`)()
} else {
// compound
let res = ``
exp.children.forEach(c => {
if (isString(c) || isSymbol(c)) {
return
}
if (c.type === NodeTypes.TEXT) {
res += c.content
} else if (c.type === NodeTypes.INTERPOLATION) {
res += toDisplayString(evaluateConstant(c.content))
} else {
res += evaluateConstant(c as ExpressionNode)
}
})
return res
}
}
Comment on lines +574 to +601
Copy link

@coderabbitai coderabbitai bot Jul 11, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add error handling and improve type safety.

The function implementation looks good overall but has a few areas for improvement:

  1. Missing error handling: The new Function() call should be wrapped in try-catch to handle malformed expressions gracefully.
  2. Unsafe type casting: Line 596 uses c as ExpressionNode without type checking, which could lead to runtime errors.

Apply this diff to improve error handling and type safety:

 export function evaluateConstant(exp: ExpressionNode): string {
   if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
-    return new Function(`return (${exp.content})`)()
+    try {
+      return new Function(`return (${exp.content})`)()
+    } catch (e) {
+      // Fallback for malformed expressions
+      return exp.content
+    }
   } else {
     // compound
     let res = ``
     exp.children.forEach(c => {
       if (isString(c) || isSymbol(c)) {
         return
       }
       if (c.type === NodeTypes.TEXT) {
         res += c.content
       } else if (c.type === NodeTypes.INTERPOLATION) {
         res += toDisplayString(evaluateConstant(c.content))
       } else {
-        res += evaluateConstant(c as ExpressionNode)
+        if (isObject(c) && 'type' in c) {
+          res += evaluateConstant(c as ExpressionNode)
+        }
       }
     })
     return res
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// __UNSAFE__
// Reason: eval.
// It's technically safe to eval because only constant expressions are possible
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
// in addition, constant exps bail on presence of parens so you can't even
// run JSFuck in here. But we mark it unsafe for security review purposes.
// (see compiler-core/src/transforms/transformExpression)
export function evaluateConstant(exp: ExpressionNode): string {
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
return new Function(`return (${exp.content})`)()
} else {
// compound
let res = ``
exp.children.forEach(c => {
if (isString(c) || isSymbol(c)) {
return
}
if (c.type === NodeTypes.TEXT) {
res += c.content
} else if (c.type === NodeTypes.INTERPOLATION) {
res += toDisplayString(evaluateConstant(c.content))
} else {
res += evaluateConstant(c as ExpressionNode)
}
})
return res
}
}
// __UNSAFE__
// Reason: eval.
// It's technically safe to eval because only constant expressions are possible
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
// in addition, constant exps bail on presence of parens so you can't even
// run JSFuck in here. But we mark it unsafe for security review purposes.
// (see compiler-core/src/transforms/transformExpression)
export function evaluateConstant(exp: ExpressionNode): string {
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
try {
return new Function(`return (${exp.content})`)()
} catch (e) {
// Fallback for malformed expressions
return exp.content
}
} else {
// compound
let res = ``
exp.children.forEach(c => {
if (isString(c) || isSymbol(c)) {
return
}
if (c.type === NodeTypes.TEXT) {
res += c.content
} else if (c.type === NodeTypes.INTERPOLATION) {
res += toDisplayString(evaluateConstant(c.content))
} else {
if (isObject(c) && 'type' in c) {
res += evaluateConstant(c as ExpressionNode)
}
}
})
return res
}
}
🤖 Prompt for AI Agents
In packages/compiler-core/src/utils.ts between lines 574 and 601, the
evaluateConstant function lacks error handling around the new Function call and
uses unsafe type casting on line 596. To fix this, wrap the new Function
invocation in a try-catch block to gracefully handle any errors from malformed
expressions, returning a safe fallback value or rethrowing as needed.
Additionally, before casting c to ExpressionNode on line 596, add a type check
to ensure c is indeed an ExpressionNode to prevent runtime errors.

Copy link
Author

Choose a reason for hiding this comment

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

I feel like this util was written in a specific way, for it's specific use-case, so changing it is out-of-scope, but happy to look into it if the human reviewer would like me to

Copy link

Choose a reason for hiding this comment

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

@CamWass That's a perfectly reasonable approach! You're right that this function was designed for its specific use case within the compiler optimization, and the current implementation aligns with that purpose. The safety measures you've mentioned in the comments (constant expressions only, no parens) do provide reasonable protection for the intended use case.

If a human reviewer feels the error handling should be added, we can always revisit it. For now, keeping the focus on the PR's core objective of compile-time static interpolation evaluation makes sense.

Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ return function render(_ctx, _cache) {
}"
`;

exports[`stringify static html > static interpolation 1`] = `
"const { createElementVNode: _createElementVNode, createStaticVNode: _createStaticVNode } = Vue

return function render(_ctx, _cache) {
return _cache[0] || (_cache[0] = _createStaticVNode("<div>1</div><div>1</div><div>1</div><div>false</div><div></div><div></div><div></div><div>1</div><div>1</div><div>1</div><div>false</div><div></div><div></div><div></div><div>1</div><div>1</div><div>1</div><div>false</div><div></div><div></div><div></div>", 21))
}"
`;

exports[`stringify static html > stringify v-html 1`] = `
"const { createElementVNode: _createElementVNode, createStaticVNode: _createStaticVNode } = Vue

Expand Down
34 changes: 34 additions & 0 deletions packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,4 +525,38 @@ describe('stringify static html', () => {

expect(code).toMatchSnapshot()
})

test('static interpolation', () => {
const interpolateElements = [
`"1"`,
'`1`',
'1',
'false',
'undefined',
'null',
'',
]

const copiesNeededToTriggerStringify = Math.ceil(
StringifyThresholds.NODE_COUNT / interpolateElements.length,
)

const { code: interpolateCode } = compileWithStringify(
interpolateElements
.map(e => `<div>{{${e}}}</div>`)
.join('\n')
.repeat(copiesNeededToTriggerStringify),
)

const staticElements = [`1`, '1', '1', 'false', '', '', '']
const { code: staticCode } = compileWithStringify(
staticElements
.map(e => `<div>${e}</div>`)
.join('\n')
.repeat(copiesNeededToTriggerStringify),
)

expect(interpolateCode).toBe(staticCode)
expect(interpolateCode).toMatchSnapshot()
})
})
43 changes: 13 additions & 30 deletions packages/compiler-dom/src/transforms/stringifyStatic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import {
ConstantTypes,
type ElementNode,
ElementTypes,
type ExpressionNode,
type HoistTransform,
Namespaces,
NodeTypes,
type PlainElementNode,
type SimpleExpressionNode,
TO_DISPLAY_STRING,
type TemplateChildNode,
type TextCallNode,
type TransformContext,
createCallExpression,
evaluateConstant,
isStaticArgOf,
} from '@vue/compiler-core'
import {
Expand Down Expand Up @@ -304,6 +305,17 @@ function stringifyNode(
case NodeTypes.COMMENT:
return `<!--${escapeHtml(node.content)}-->`
case NodeTypes.INTERPOLATION:
// We add TO_DISPLAY_STRING for every interpolation, so we need to
// decrease its usage count whenever we remove an interpolation.
context.removeHelper(TO_DISPLAY_STRING)
Copy link
Author

Choose a reason for hiding this comment

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

This isn't strictly necessary (see above comment about unused imports), but doesn't hurt to cleanup.


if (
node.content.type === NodeTypes.SIMPLE_EXPRESSION &&
!node.content.content
) {
return ''
Copy link
Author

Choose a reason for hiding this comment

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

Again, can't call evaluateConstant(undefined)

}

return escapeHtml(toDisplayString(evaluateConstant(node.content)))
case NodeTypes.COMPOUND_EXPRESSION:
return escapeHtml(evaluateConstant(node))
Expand Down Expand Up @@ -386,32 +398,3 @@ function stringifyElement(
}
return res
}

// __UNSAFE__
// Reason: eval.
// It's technically safe to eval because only constant expressions are possible
// here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
// in addition, constant exps bail on presence of parens so you can't even
// run JSFuck in here. But we mark it unsafe for security review purposes.
// (see compiler-core/src/transforms/transformExpression)
function evaluateConstant(exp: ExpressionNode): string {
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
return new Function(`return (${exp.content})`)()
} else {
// compound
let res = ``
exp.children.forEach(c => {
if (isString(c) || isSymbol(c)) {
return
}
if (c.type === NodeTypes.TEXT) {
res += c.content
} else if (c.type === NodeTypes.INTERPOLATION) {
res += toDisplayString(evaluateConstant(c.content))
} else {
res += evaluateConstant(c as ExpressionNode)
}
})
return res
}
}