From eced858269ba0a7e712a0bbf2366836a200db2fd Mon Sep 17 00:00:00 2001 From: Campbell Wass Date: Fri, 11 Jul 2025 15:31:54 +1000 Subject: [PATCH] feat(compiler): evaluate static interpolations at compile time --- .../__snapshots__/codegen.spec.ts.snap | 18 ++++++++ .../compiler-core/__tests__/codegen.spec.ts | 35 +++++++++++++++ .../__snapshots__/cacheStatic.spec.ts.snap | 4 +- packages/compiler-core/src/codegen.ts | 17 ++++++++ .../src/transforms/transformExpression.ts | 13 +++++- packages/compiler-core/src/utils.ts | 37 +++++++++++++++- .../stringifyStatic.spec.ts.snap | 8 ++++ .../transforms/stringifyStatic.spec.ts | 34 +++++++++++++++ .../src/transforms/stringifyStatic.ts | 43 ++++++------------- 9 files changed, 174 insertions(+), 35 deletions(-) diff --git a/packages/compiler-core/__tests__/__snapshots__/codegen.spec.ts.snap b/packages/compiler-core/__tests__/__snapshots__/codegen.spec.ts.snap index db268af4f9b..d1bbc6d6d0d 100644 --- a/packages/compiler-core/__tests__/__snapshots__/codegen.spec.ts.snap +++ b/packages/compiler-core/__tests__/__snapshots__/codegen.spec.ts.snap @@ -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) { @@ -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) { diff --git a/packages/compiler-core/__tests__/codegen.spec.ts b/packages/compiler-core/__tests__/codegen.spec.ts index 34386ce6930..3653be66df5 100644 --- a/packages/compiler-core/__tests__/codegen.spec.ts +++ b/packages/compiler-core/__tests__/codegen.spec.ts @@ -3,6 +3,7 @@ import { type DirectiveArguments, type ForCodegenNode, type IfConditionalExpression, + type InterpolationNode, NodeTypes, type RootNode, type VNodeCall, @@ -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({ diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap index b8bef22c478..9ade20b23f6 100644 --- a/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap +++ b/packages/compiler-core/__tests__/transforms/__snapshots__/cacheStatic.spec.ts.snap @@ -148,7 +148,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", null, "foo " + _toDisplayString(1) + " " + _toDisplayString(true), -1 /* CACHED */) + _createElementVNode("span", null, "foo " + "1" + " " + "true", -1 /* CACHED */) ]))) } }" @@ -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 */) ]))) } }" diff --git a/packages/compiler-core/src/codegen.ts b/packages/compiler-core/src/codegen.ts index 6b4559fabb2..8dc19eae0d2 100644 --- a/packages/compiler-core/src/codegen.ts +++ b/packages/compiler-core/src/codegen.ts @@ -7,6 +7,7 @@ import { type CommentNode, type CompoundExpressionNode, type ConditionalExpression, + ConstantTypes, type ExpressionNode, type FunctionExpression, type IfStatement, @@ -32,6 +33,7 @@ import { SourceMapGenerator } from 'source-map-js' import { advancePositionWithMutation, assert, + evaluateConstant, isSimpleIdentifier, toValidAssetId, } from './utils' @@ -41,6 +43,7 @@ import { isArray, isString, isSymbol, + toDisplayString, } from '@vue/shared' import { CREATE_COMMENT, @@ -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(`""`) + } + + return + } + if (pure) push(PURE_ANNOTATION) push(`${helper(TO_DISPLAY_STRING)}(`) genNode(node.content, context) diff --git a/packages/compiler-core/src/transforms/transformExpression.ts b/packages/compiler-core/src/transforms/transformExpression.ts index 9ae8897e674..d13abff2db4 100644 --- a/packages/compiler-core/src/transforms/transformExpression.ts +++ b/packages/compiler-core/src/transforms/transformExpression.ts @@ -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', +) export const transformExpression: NodeTransform = (node, context) => { if (node.type === NodeTypes.INTERPOLATION) { @@ -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 } diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts index b49d70bb2fb..40e94c6669e 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -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' @@ -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 { + 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 + } +} diff --git a/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap b/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap index 5bc40d3fab5..8c6c3629e8c 100644 --- a/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap +++ b/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap @@ -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("
1
1
1
false
1
1
1
false
1
1
1
false
", 21)) +}" +`; + exports[`stringify static html > stringify v-html 1`] = ` "const { createElementVNode: _createElementVNode, createStaticVNode: _createStaticVNode } = Vue diff --git a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts index f58e207d6cf..7d6007e334f 100644 --- a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts +++ b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts @@ -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 => `
{{${e}}}
`) + .join('\n') + .repeat(copiesNeededToTriggerStringify), + ) + + const staticElements = [`1`, '1', '1', 'false', '', '', ''] + const { code: staticCode } = compileWithStringify( + staticElements + .map(e => `
${e}
`) + .join('\n') + .repeat(copiesNeededToTriggerStringify), + ) + + expect(interpolateCode).toBe(staticCode) + expect(interpolateCode).toMatchSnapshot() + }) }) diff --git a/packages/compiler-dom/src/transforms/stringifyStatic.ts b/packages/compiler-dom/src/transforms/stringifyStatic.ts index cd8f1a9d184..b0169dbaf86 100644 --- a/packages/compiler-dom/src/transforms/stringifyStatic.ts +++ b/packages/compiler-dom/src/transforms/stringifyStatic.ts @@ -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 { @@ -304,6 +305,17 @@ function stringifyNode( case NodeTypes.COMMENT: return `` 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) + + if ( + node.content.type === NodeTypes.SIMPLE_EXPRESSION && + !node.content.content + ) { + return '' + } + return escapeHtml(toDisplayString(evaluateConstant(node.content))) case NodeTypes.COMPOUND_EXPRESSION: return escapeHtml(evaluateConstant(node)) @@ -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 - } -}