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
}
}
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
}
}