From: mary <148872143+mary-ext@users.noreply.github.com> Date: Sun, 4 Jan 2026 07:00:36 +0000 (+0700) Subject: refactor(compiler-vapor): skip unnecessary attribute quoting in templates (#13673) X-Git-Tag: v3.6.0-beta.2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=ad154669fcd7efd7b1f608d3988a9376719c6ccf;p=thirdparty%2Fvuejs%2Fcore.git refactor(compiler-vapor): skip unnecessary attribute quoting in templates (#13673) Co-authored-by: daiwei --- diff --git a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap index 467cdbdff0..61463860d2 100644 --- a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap @@ -140,7 +140,7 @@ export function render(_ctx) { exports[`compile > directives > v-pre > basic 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("
{{ bar }}", true) +const t0 = _template("
{{ bar }}", true) export function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() @@ -150,7 +150,7 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { exports[`compile > directives > v-pre > should not affect siblings after it 1`] = ` "import { resolveComponent as _resolveComponent, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, child as _child, setProp as _setProp, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue'; -const t0 = _template("
{{ bar }}") +const t0 = _template("
{{ bar }}") const t1 = _template("
") export function render(_ctx, $props, $emit, $attrs, $slots) { diff --git a/packages/compiler-vapor/__tests__/compile.spec.ts b/packages/compiler-vapor/__tests__/compile.spec.ts index 6af36a7875..85cc0b5a7d 100644 --- a/packages/compiler-vapor/__tests__/compile.spec.ts +++ b/packages/compiler-vapor/__tests__/compile.spec.ts @@ -67,7 +67,7 @@ describe('compile', () => { expect(code).toMatchSnapshot() expect(code).contains( - JSON.stringify('
{{ bar }}'), + JSON.stringify('
{{ bar }}'), ) expect(code).not.contains('effect') }) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformAssetUrl.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformAssetUrl.spec.ts.snap index b48877ebf7..766937617f 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformAssetUrl.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformAssetUrl.spec.ts.snap @@ -2,7 +2,7 @@ exports[`compiler sfc: transform asset url > should allow for full base URLs, with paths 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -12,7 +12,7 @@ export function render(_ctx) { exports[`compiler sfc: transform asset url > should allow for full base URLs, without paths 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -22,7 +22,7 @@ export function render(_ctx) { exports[`compiler sfc: transform asset url > should allow for full base URLs, without port 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -32,7 +32,7 @@ export function render(_ctx) { exports[`compiler sfc: transform asset url > should allow for full base URLs, without protocol 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -54,7 +54,7 @@ export function render(_ctx) { exports[`compiler sfc: transform asset url > support uri is empty 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true, 1) +const t0 = _template("", true, 1) export function render(_ctx) { const n0 = t0() @@ -69,10 +69,10 @@ import _imports_1 from 'fixtures/logo.png'; import _imports_2 from '/fixtures/logo.png'; const t0 = _template("") const t1 = _template("") -const t2 = _template("") -const t3 = _template("") +const t2 = _template("") +const t3 = _template("") const t4 = _template("") -const t5 = _template("") +const t5 = _template("") export function render(_ctx) { const n0 = t0() @@ -90,7 +90,7 @@ exports[`compiler sfc: transform asset url > transform with stringify 1`] = ` "import { template as _template } from 'vue'; import _imports_0 from './bar.png'; import _imports_1 from '/bar.png'; -const t0 = _template("
", true) +const t0 = _template("
", true) export function render(_ctx) { const n0 = t0() @@ -102,7 +102,7 @@ exports[`compiler sfc: transform asset url > with explicit base 1`] = ` "import { template as _template } from 'vue'; import _imports_0 from 'bar.png'; import _imports_1 from '@theme/bar.png'; -const t0 = _template("") +const t0 = _template("") const t1 = _template("") const t2 = _template("") @@ -121,8 +121,8 @@ import _imports_0 from './bar.png'; import _imports_1 from '/bar.png'; const t0 = _template("") const t1 = _template("") -const t2 = _template("") -const t3 = _template("") +const t2 = _template("") +const t3 = _template("") export function render(_ctx) { const n0 = t0() diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformSrcset.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformSrcset.spec.ts.snap index f6dc18a73c..d87283d30e 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformSrcset.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/templateTransformSrcset.spec.ts.snap @@ -18,17 +18,17 @@ exports[`compiler sfc: transform srcset > transform srcset 1`] = ` "import { template as _template } from 'vue'; import _imports_0 from './logo.png'; import _imports_1 from '/logo.png'; -const t0 = _template("") -const t1 = _template("") -const t2 = _template("") -const t3 = _template("") -const t4 = _template("") -const t5 = _template("") -const t6 = _template("") -const t7 = _template("") -const t8 = _template("") -const t9 = _template("") -const t10 = _template("") +const t0 = _template("") +const t1 = _template("") +const t2 = _template("") +const t3 = _template("") +const t4 = _template("") +const t5 = _template("") +const t6 = _template("") +const t7 = _template("") +const t8 = _template("") +const t9 = _template("") +const t10 = _template("") export function render(_ctx) { const n0 = t0() @@ -51,17 +51,17 @@ exports[`compiler sfc: transform srcset > transform srcset w/ base 1`] = ` "import { template as _template } from 'vue'; import _imports_0 from '/logo.png'; import _imports_1 from '/foo/logo.png'; -const t0 = _template("") -const t1 = _template("") -const t2 = _template("") -const t3 = _template("") -const t4 = _template("") -const t5 = _template("") -const t6 = _template("") -const t7 = _template("") -const t8 = _template("") -const t9 = _template("") -const t10 = _template("") +const t0 = _template("") +const t1 = _template("") +const t2 = _template("") +const t3 = _template("") +const t4 = _template("") +const t5 = _template("") +const t6 = _template("") +const t7 = _template("") +const t8 = _template("") +const t9 = _template("") +const t10 = _template("") export function render(_ctx) { const n0 = t0() @@ -84,17 +84,17 @@ exports[`compiler sfc: transform srcset > transform srcset w/ includeAbsolute: t "import { template as _template } from 'vue'; import _imports_0 from './logo.png'; import _imports_1 from '/logo.png'; -const t0 = _template("") -const t1 = _template("") -const t2 = _template("") -const t3 = _template("") -const t4 = _template("") -const t5 = _template("") -const t6 = _template("") -const t7 = _template("") -const t8 = _template("") -const t9 = _template("") -const t10 = _template("") +const t0 = _template("") +const t1 = _template("") +const t2 = _template("") +const t3 = _template("") +const t4 = _template("") +const t5 = _template("") +const t6 = _template("") +const t7 = _template("") +const t8 = _template("") +const t9 = _template("") +const t10 = _template("") export function render(_ctx) { const n0 = t0() @@ -117,7 +117,7 @@ exports[`compiler sfc: transform srcset > transform srcset w/ stringify 1`] = ` "import { template as _template } from 'vue'; import _imports_0 from './logo.png'; import _imports_1 from '/logo.png'; -const t0 = _template("
", true) +const t0 = _template("
", true) export function render(_ctx) { const n0 = t0() diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap index 6eea04512f..a80f5d88d9 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap @@ -22,7 +22,7 @@ export function render(_ctx) { exports[`compiler: element transform > checkbox with static indeterminate 1`] = ` "import { setProp as _setProp, template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -483,7 +483,7 @@ export function render(_ctx) { exports[`compiler: element transform > props + child 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("
", true) +const t0 = _template("
", true) export function render(_ctx) { const n0 = t0() @@ -493,7 +493,7 @@ export function render(_ctx) { exports[`compiler: element transform > props + children 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("
", true) +const t0 = _template("
", true) export function render(_ctx) { const n0 = t0() @@ -556,9 +556,99 @@ export function render(_ctx) { }" `; -exports[`compiler: element transform > static props 1`] = ` +exports[`compiler: element transform > static props quoting > escapes double quotes in value 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("
", true) +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > mixed quoting with boolean attribute 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
bar\\">", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains < 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains = 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains > 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
b\\">", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains backtick 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains single quote 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > quoted when value contains whitespace 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > space omitted after quoted attribute 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
bar\\">", true) + +export function render(_ctx) { + const n0 = t0() + return n0 +}" +`; + +exports[`compiler: element transform > static props quoting > unquoted when value has no special chars 1`] = ` +"import { template as _template } from 'vue'; +const t0 = _template("
", true) export function render(_ctx) { const n0 = t0() @@ -568,7 +658,7 @@ export function render(_ctx) { exports[`compiler: element transform > svg 1`] = ` "import { template as _template } from 'vue'; -const t0 = _template("", true, 1) +const t0 = _template("", true, 1) export function render(_ctx) { const n0 = t0() diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap index df90ca2e64..39e26e3230 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap @@ -786,7 +786,7 @@ export function render(_ctx) { exports[`compiler v-bind > with constant value 1`] = ` "import { setProp as _setProp, template as _template } from 'vue'; -const t0 = _template("
", true) +const t0 = _template("
", true) export function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vModel.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vModel.spec.ts.snap index dad14ded14..a6f0a2abfa 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vModel.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vModel.spec.ts.snap @@ -156,7 +156,7 @@ export function render(_ctx) { exports[`compiler: vModel transform > should support input (checkbox) 1`] = ` "import { applyCheckboxModel as _applyCheckboxModel, template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -178,7 +178,7 @@ export function render(_ctx) { exports[`compiler: vModel transform > should support input (radio) 1`] = ` "import { applyRadioModel as _applyRadioModel, template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() @@ -189,7 +189,7 @@ export function render(_ctx) { exports[`compiler: vModel transform > should support input (text) 1`] = ` "import { applyTextModel as _applyTextModel, template as _template } from 'vue'; -const t0 = _template("", true) +const t0 = _template("", true) export function render(_ctx) { const n0 = t0() diff --git a/packages/compiler-vapor/__tests__/transforms/templateTransformAssetUrl.spec.ts b/packages/compiler-vapor/__tests__/transforms/templateTransformAssetUrl.spec.ts index 225d18aded..80d4660b24 100644 --- a/packages/compiler-vapor/__tests__/transforms/templateTransformAssetUrl.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/templateTransformAssetUrl.spec.ts @@ -98,7 +98,7 @@ describe('compiler sfc: transform asset url', () => { `, ) // should not remove it - expect(code).toMatch(`xlink:href=\\"#myCircle\\"`) // compiled to template string, not object, so remove quotes + expect(code).toMatch(`xlink:href=#myCircle`) // compiled to template string, not object, so remove quotes }) test('should allow for full base URLs, with paths', () => { diff --git a/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts b/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts index a1cf589c61..dede6cb526 100644 --- a/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts @@ -573,18 +573,6 @@ describe('compiler: element transform', () => { }) }) - test('static props', () => { - const { code, ir } = compileWithElementTransform( - `
`, - ) - - const template = '
' - expect(code).toMatchSnapshot() - expect(code).contains(JSON.stringify(template)) - expect([...ir.template.keys()]).toMatchObject([template]) - expect(ir.block.effect).lengthOf(0) - }) - test('checkbox with static indeterminate', () => { const { code } = compileWithElementTransform( ``, @@ -599,7 +587,7 @@ describe('compiler: element transform', () => { `
`, ) - const template = '
' + const template = '
' expect(code).toMatchSnapshot() expect(code).contains(JSON.stringify(template)) expect([...ir.template.keys()]).toMatchObject([template]) @@ -612,7 +600,7 @@ describe('compiler: element transform', () => { ) const template = - '
' + '
' expect(code).toMatchSnapshot() expect(code).contains(JSON.stringify(template)) expect([...ir.template.keys()]).toMatchObject([template]) @@ -1112,9 +1100,10 @@ describe('compiler: element transform', () => { const t = `` const { code, ir } = compileWithElementTransform(t) expect(code).toMatchSnapshot() - expect(code).contains('_template("", true, 1)') - expect([...ir.template.keys()]).toMatchObject(['']) - expect(ir.template.get('')).toBe(1) + const expectedTemplate = '' + expect(code).contains(`_template("${expectedTemplate}", true, 1)`) + expect([...ir.template.keys()]).toMatchObject([expectedTemplate]) + expect(ir.template.get(expectedTemplate)).toBe(1) }) test('MathML', () => { @@ -1125,4 +1114,116 @@ describe('compiler: element transform', () => { expect([...ir.template.keys()]).toMatchObject(['x']) expect(ir.template.get('x')).toBe(2) }) + + describe('static props quoting', () => { + test('unquoted when value has no special chars', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains whitespace', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains >', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains <', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains =', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains single quote', () => { + const { code, ir } = compileWithElementTransform(`
`) + + const template = `
` + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('quoted when value contains backtick', () => { + const { code, ir } = compileWithElementTransform( + '
', + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('escapes double quotes in value', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('mixed quoting with boolean attribute', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = + '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + + test('space omitted after quoted attribute', () => { + const { code, ir } = compileWithElementTransform( + `
`, + ) + + const template = + '
' + expect(code).toMatchSnapshot() + expect(code).contains(JSON.stringify(template)) + expect([...ir.template.keys()]).toMatchObject([template]) + }) + }) }) diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index bb20787e67..81ae8295a3 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -283,6 +283,11 @@ function resolveSetupReference(name: string, context: TransformContext) { // keys cannot be a part of the template and need to be set dynamically const dynamicKeys = ['indeterminate'] +// The attribute value can remain unquoted if it doesn't contain ASCII whitespace +// or any of " ' ` = < or >. +// https://html.spec.whatwg.org/multipage/introduction.html#intro-early-example +const NEEDS_QUOTES_RE = /[\s"'`=<>]/ + function transformNativeElement( node: PlainElementNode, propsResult: PropsResult, @@ -313,6 +318,9 @@ function transformNativeElement( getEffectIndex, ) } else { + // tracks if previous attribute was quoted, allowing space omission + // e.g. `class="foo"id="bar"` is valid, `class=foo id=bar` needs space + let prevWasQuoted = false for (const prop of propsResult[1]) { const { key, values } = prop // handling asset imports @@ -321,18 +329,28 @@ function transformNativeElement( values[0].content.includes(imported.exp.content), ) ) { + if (!prevWasQuoted) template += ` ` // add start and end markers to the import expression, so it can be replaced // with string concatenation in the generator, see genTemplates - template += ` ${key.content}="${IMPORT_EXP_START}${values[0].content}${IMPORT_EXP_END}"` + template += `${key.content}="${IMPORT_EXP_START}${values[0].content}${IMPORT_EXP_END}"` + prevWasQuoted = true } else if ( key.isStatic && values.length === 1 && (values[0].isStatic || values[0].content === "''") && !dynamicKeys.includes(key.content) ) { - template += ` ${key.content}` - if (values[0].content) - template += `="${values[0].content === "''" ? '' : values[0].content}"` + if (!prevWasQuoted) template += ` ` + const value = values[0].content === "''" ? '' : values[0].content + template += key.content + + if (value) { + template += (prevWasQuoted = NEEDS_QUOTES_RE.test(value)) + ? `="${value.replace(/"/g, '"')}"` + : `=${value}` + } else { + prevWasQuoted = false + } } else { dynamicProps.push(key.content) context.registerEffect( diff --git a/packages/runtime-vapor/__tests__/dom/template.spec.ts b/packages/runtime-vapor/__tests__/dom/template.spec.ts index 792c55242b..7c5ab3d2dd 100644 --- a/packages/runtime-vapor/__tests__/dom/template.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/template.spec.ts @@ -40,4 +40,25 @@ describe('api: template', () => { expect(nthChild(root, 2)).toBe(root.childNodes[2]) expect(next(b)).toBe(root.childNodes[2]) }) + + test('attribute quote omission', () => { + { + const t = template('
') + const root = t() as HTMLElement + + expect(root.attributes).toHaveLength(3) + expect(root.getAttribute('id')).toBe('foo') + expect(root.getAttribute('class')).toBe('bar') + expect(root.getAttribute('alt')).toBe('`<="foo') + } + + { + const t = template('
') + const root = t() as HTMLElement + + expect(root.attributes).toHaveLength(2) + expect(root.getAttribute('id')).toBe('foo>bar') + expect(root.getAttribute('class')).toBe('has whitespace') + } + }) })