fix(compiler-ssr): add selected option attribute from select value

This commit is contained in:
Alex Snezhko 2025-06-28 16:18:53 -07:00
parent ba391f5fdf
commit 67ad86174d
4 changed files with 311 additions and 60 deletions

View File

@ -1,5 +1,7 @@
import { getCompiledString } from './utils' import { getCompiledString } from './utils'
import { compile } from '../src' import { compile } from '../src'
import { renderToString } from '@vue/server-renderer'
import { createApp } from '@vue/runtime-dom'
describe('ssr: element', () => { describe('ssr: element', () => {
test('basic elements', () => { test('basic elements', () => {
@ -71,6 +73,160 @@ describe('ssr: element', () => {
`) `)
}) })
test('<select> with dynamic value assigns `selected` option attribute', async () => {
expect(
getCompiledString(
`<select :value="selectValue"><option value="1"></option></select>`,
),
).toMatchInlineSnapshot(`
"\`<select><option value="1"\${
(_ssrIncludeBooleanAttr((Array.isArray(_ctx.selectValue))
? _ssrLooseContain(_ctx.selectValue, "1")
: _ssrLooseEqual(_ctx.selectValue, "1"))) ? " selected" : ""
}></option></select>\`"
`)
expect(
await renderToString(
createApp({
data: () => ({ selected: 2 }),
template: `<div><select :value="selected"><option value="1">1</option><option value="2">2</option></select></div>`,
}),
),
).toMatchInlineSnapshot(
`"<div><select><option value="1">1</option><option value="2" selected>2</option></select></div>"`,
)
})
test('<select> with static value assigns `selected` option attribute', async () => {
expect(
getCompiledString(
`<select value="selectValue"><option value="1"></option></select>`,
),
).toMatchInlineSnapshot(`
"\`<select><option value="1"\${
(_ssrIncludeBooleanAttr(_ssrLooseEqual("selectValue", "1"))) ? " selected" : ""
}></option></select>\`"
`)
expect(
await renderToString(
createApp({
template: `<div><select value="2"><option value="1">1</option><option value="2">2</option></select></div>`,
}),
),
).toMatchInlineSnapshot(
`"<div><select><option value="1">1</option><option value="2" selected>2</option></select></div>"`,
)
})
test('<select> with dynamic v-bind assigns `selected` option attribute', async () => {
expect(
compile(`<select v-bind="obj"><option value="1"></option></select>`)
.code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual } = require("vue/server-renderer")
return function ssrRender(_ctx, _push, _parent, _attrs) {
let _temp0
_push(\`<select\${
_ssrRenderAttrs(_temp0 = _mergeProps(_ctx.obj, _attrs))
}><option value="1"\${
(_ssrIncludeBooleanAttr(("value" in _temp0)
? (Array.isArray(_temp0.value))
? _ssrLooseContain(_temp0.value, "1")
: _ssrLooseEqual(_temp0.value, "1")
: false)) ? " selected" : ""
}></option></select>\`)
}"
`)
expect(
await renderToString(
createApp({
data: () => ({ obj: { value: 2 } }),
template: `<div><select v-bind="obj"><option value="1">1</option><option value="2">2</option></select></div>`,
}),
),
).toMatchInlineSnapshot(
`"<div><select value="2"><option value="1">1</option><option value="2" selected>2</option></select></div>"`,
)
})
test('<select> with dynamic v-bind and dynamic value bind assigns `selected` option attribute', async () => {
expect(
compile(
`<select v-bind="obj" :value="selectValue"><option value="1"></option></select>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual } = require("vue/server-renderer")
return function ssrRender(_ctx, _push, _parent, _attrs) {
let _temp0
_push(\`<select\${
_ssrRenderAttrs(_temp0 = _mergeProps(_ctx.obj, { value: _ctx.selectValue }, _attrs))
}><option value="1"\${
(_ssrIncludeBooleanAttr(("value" in _temp0)
? (Array.isArray(_temp0.value))
? _ssrLooseContain(_temp0.value, "1")
: _ssrLooseEqual(_temp0.value, "1")
: false)) ? " selected" : ""
}></option></select>\`)
}"
`)
expect(
await renderToString(
createApp({
data: () => ({ obj: { value: 1 } }),
template: `<div><select v-bind="obj" :value="2"><option value="1">1</option><option value="2">2</option></select></div>`,
}),
),
).toMatchInlineSnapshot(
`"<div><select value="2"><option value="1">1</option><option value="2" selected>2</option></select></div>"`,
)
})
test('<select> with dynamic v-bind and static value bind assigns `selected` option attribute', async () => {
expect(
compile(
`<select v-bind="obj" value="selectValue"><option value="1"></option></select>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual } = require("vue/server-renderer")
return function ssrRender(_ctx, _push, _parent, _attrs) {
let _temp0
_push(\`<select\${
_ssrRenderAttrs(_temp0 = _mergeProps(_ctx.obj, { value: "selectValue" }, _attrs))
}><option value="1"\${
(_ssrIncludeBooleanAttr(("value" in _temp0)
? (Array.isArray(_temp0.value))
? _ssrLooseContain(_temp0.value, "1")
: _ssrLooseEqual(_temp0.value, "1")
: false)) ? " selected" : ""
}></option></select>\`)
}"
`)
expect(
await renderToString(
createApp({
data: () => ({ obj: { value: 1 } }),
template: `<div><select v-bind="obj" value="2"><option value="1">1</option><option value="2">2</option></select></div>`,
}),
),
).toMatchInlineSnapshot(
`"<div><select value="2"><option value="1">1</option><option value="2" selected>2</option></select></div>"`,
)
})
test('multiple _ssrInterpolate at parent and child import dependency once', () => { test('multiple _ssrInterpolate at parent and child import dependency once', () => {
expect( expect(
compile(`<div>{{ hello }}<textarea v-bind="a"></textarea></div>`).code, compile(`<div>{{ hello }}<textarea v-bind="a"></textarea></div>`).code,

View File

@ -57,6 +57,7 @@ import {
type SSRTransformContext, type SSRTransformContext,
processChildren, processChildren,
} from '../ssrCodegenTransform' } from '../ssrCodegenTransform'
import { processSelectChildren } from '../utils'
// for directives with children overwrite (e.g. v-html & v-text), we need to // for directives with children overwrite (e.g. v-html & v-text), we need to
// store the raw children so that they can be added in the 2nd pass. // store the raw children so that they can be added in the 2nd pass.
@ -139,6 +140,22 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
]), ]),
) )
} }
} else if (node.tag === 'select') {
// <select> with dynamic v-bind. We don't know if the final props
// will contain .value, so we will have to do something special:
// assign the merged props to a temp variable, and check whether
// it contains value (if yes, mark options selected).
const tempId = `_temp${context.temps++}`
propsExp.arguments = [
createAssignmentExpression(
createSimpleExpression(tempId, false),
mergedProps,
),
]
processSelectChildren(context, node.children, {
type: 'dynamicVBind',
tempId,
})
} else if (node.tag === 'input') { } else if (node.tag === 'input') {
// <input v-bind="obj" v-model> // <input v-bind="obj" v-model>
// we need to determine the props to render for the dynamic v-model // we need to determine the props to render for the dynamic v-model
@ -223,10 +240,17 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
context.onError( context.onError(
createCompilerError(ErrorCodes.X_V_SLOT_MISPLACED, prop.loc), createCompilerError(ErrorCodes.X_V_SLOT_MISPLACED, prop.loc),
) )
} else if (isTextareaWithValue(node, prop) && prop.exp) { } else if (isTagWithValueBind(node, 'textarea', prop) && prop.exp) {
if (!needMergeProps) { if (!needMergeProps) {
node.children = [createInterpolation(prop.exp, prop.loc)] node.children = [createInterpolation(prop.exp, prop.loc)]
} }
} else if (isTagWithValueBind(node, 'select', prop) && prop.exp) {
if (!needMergeProps) {
processSelectChildren(context, node.children, {
type: 'dynamicValue',
value: prop.exp,
})
}
} else if (!needMergeProps && prop.name !== 'on') { } else if (!needMergeProps && prop.name !== 'on') {
// Directive transforms. // Directive transforms.
const directiveTransform = context.directiveTransforms[prop.name] const directiveTransform = context.directiveTransforms[prop.name]
@ -326,6 +350,13 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
const name = prop.name const name = prop.name
if (node.tag === 'textarea' && name === 'value' && prop.value) { if (node.tag === 'textarea' && name === 'value' && prop.value) {
rawChildrenMap.set(node, escapeHtml(prop.value.content)) rawChildrenMap.set(node, escapeHtml(prop.value.content))
} else if (node.tag === 'select' && name === 'value' && prop.value) {
if (!needMergeProps) {
processSelectChildren(context, node.children, {
type: 'staticValue',
value: prop.value.content,
})
}
} else if (!needMergeProps) { } else if (!needMergeProps) {
if (name === 'key' || name === 'ref') { if (name === 'key' || name === 'ref') {
continue continue
@ -399,12 +430,13 @@ function isTrueFalseValue(prop: DirectiveNode | AttributeNode) {
} }
} }
function isTextareaWithValue( function isTagWithValueBind(
node: PlainElementNode, node: PlainElementNode,
targetTag: string,
prop: DirectiveNode, prop: DirectiveNode,
): boolean { ): boolean {
return !!( return !!(
node.tag === 'textarea' && node.tag === targetTag &&
prop.name === 'bind' && prop.name === 'bind' &&
isStaticArgOf(prop.arg, 'value') isStaticArgOf(prop.arg, 'value')
) )

View File

@ -2,27 +2,23 @@ import {
DOMErrorCodes, DOMErrorCodes,
type DirectiveTransform, type DirectiveTransform,
ElementTypes, ElementTypes,
type ExpressionNode,
NodeTypes, NodeTypes,
type PlainElementNode,
type TemplateChildNode,
createCallExpression, createCallExpression,
createConditionalExpression, createConditionalExpression,
createDOMCompilerError, createDOMCompilerError,
createInterpolation, createInterpolation,
createObjectProperty, createObjectProperty,
createSimpleExpression,
findProp, findProp,
hasDynamicKeyVBind, hasDynamicKeyVBind,
transformModel, transformModel,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
import { import {
SSR_INCLUDE_BOOLEAN_ATTR,
SSR_LOOSE_CONTAIN, SSR_LOOSE_CONTAIN,
SSR_LOOSE_EQUAL, SSR_LOOSE_EQUAL,
SSR_RENDER_DYNAMIC_MODEL, SSR_RENDER_DYNAMIC_MODEL,
} from '../runtimeHelpers' } from '../runtimeHelpers'
import type { DirectiveTransformResult } from 'packages/compiler-core/src/transform' import type { DirectiveTransformResult } from 'packages/compiler-core/src/transform'
import { findValueBinding, processSelectChildren } from '../utils'
export const ssrTransformModel: DirectiveTransform = (dir, node, context) => { export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
const model = dir.exp! const model = dir.exp!
@ -39,48 +35,6 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
} }
} }
const processSelectChildren = (children: TemplateChildNode[]) => {
children.forEach(child => {
if (child.type === NodeTypes.ELEMENT) {
processOption(child as PlainElementNode)
} else if (child.type === NodeTypes.FOR) {
processSelectChildren(child.children)
} else if (child.type === NodeTypes.IF) {
child.branches.forEach(b => processSelectChildren(b.children))
}
})
}
function processOption(plainNode: PlainElementNode) {
if (plainNode.tag === 'option') {
if (plainNode.props.findIndex(p => p.name === 'selected') === -1) {
const value = findValueBinding(plainNode)
plainNode.ssrCodegenNode!.elements.push(
createConditionalExpression(
createCallExpression(context.helper(SSR_INCLUDE_BOOLEAN_ATTR), [
createConditionalExpression(
createCallExpression(`Array.isArray`, [model]),
createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [
model,
value,
]),
createCallExpression(context.helper(SSR_LOOSE_EQUAL), [
model,
value,
]),
),
]),
createSimpleExpression(' selected', true),
createSimpleExpression('', true),
false /* no newline */,
),
)
}
} else if (plainNode.tag === 'optgroup') {
processSelectChildren(plainNode.children)
}
}
if (node.tagType === ElementTypes.ELEMENT) { if (node.tagType === ElementTypes.ELEMENT) {
const res: DirectiveTransformResult = { props: [] } const res: DirectiveTransformResult = { props: [] }
const defaultProps = [ const defaultProps = [
@ -173,7 +127,10 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
checkDuplicatedValue() checkDuplicatedValue()
node.children = [createInterpolation(model, model.loc)] node.children = [createInterpolation(model, model.loc)]
} else if (node.tag === 'select') { } else if (node.tag === 'select') {
processSelectChildren(node.children) processSelectChildren(context, node.children, {
type: 'dynamicValue',
value: model,
})
} else { } else {
context.onError( context.onError(
createDOMCompilerError( createDOMCompilerError(
@ -189,12 +146,3 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
return transformModel(dir, node, context) return transformModel(dir, node, context)
} }
} }
function findValueBinding(node: PlainElementNode): ExpressionNode {
const valueBinding = findProp(node, 'value')
return valueBinding
? valueBinding.type === NodeTypes.DIRECTIVE
? valueBinding.exp!
: createSimpleExpression(valueBinding.value!.content, true)
: createSimpleExpression(`null`, false)
}

View File

@ -0,0 +1,115 @@
import {
type ExpressionNode,
NodeTypes,
type PlainElementNode,
type TemplateChildNode,
type TransformContext,
createCallExpression,
createConditionalExpression,
createSimpleExpression,
findProp,
} from '@vue/compiler-core'
import {
SSR_INCLUDE_BOOLEAN_ATTR,
SSR_LOOSE_CONTAIN,
SSR_LOOSE_EQUAL,
} from './runtimeHelpers'
export function findValueBinding(node: PlainElementNode): ExpressionNode {
const valueBinding = findProp(node, 'value')
return valueBinding
? valueBinding.type === NodeTypes.DIRECTIVE
? valueBinding.exp!
: createSimpleExpression(valueBinding.value!.content, true)
: createSimpleExpression(`null`, false)
}
export type SelectValue =
| {
type: 'staticValue'
value: string
}
| {
type: 'dynamicValue'
value: ExpressionNode
}
| {
type: 'dynamicVBind'
tempId: string
}
export const processSelectChildren = (
context: TransformContext,
children: TemplateChildNode[],
selectValue: SelectValue,
): void => {
children.forEach(child => {
if (child.type === NodeTypes.ELEMENT) {
processOption(context, child as PlainElementNode, selectValue)
} else if (child.type === NodeTypes.FOR) {
processSelectChildren(context, child.children, selectValue)
} else if (child.type === NodeTypes.IF) {
child.branches.forEach(b =>
processSelectChildren(context, b.children, selectValue),
)
}
})
}
export function processOption(
context: TransformContext,
plainNode: PlainElementNode,
selectValue: SelectValue,
): void {
if (plainNode.tag === 'option') {
if (plainNode.props.findIndex(p => p.name === 'selected') === -1) {
const value = findValueBinding(plainNode)
function createDynamicSelectExpression(selectValue: ExpressionNode) {
return createConditionalExpression(
createCallExpression(`Array.isArray`, [selectValue]),
createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [
selectValue,
value,
]),
createCallExpression(context.helper(SSR_LOOSE_EQUAL), [
selectValue,
value,
]),
)
}
plainNode.ssrCodegenNode!.elements.push(
createConditionalExpression(
createCallExpression(context.helper(SSR_INCLUDE_BOOLEAN_ATTR), [
selectValue.type === 'staticValue'
? createCallExpression(context.helper(SSR_LOOSE_EQUAL), [
createSimpleExpression(selectValue.value, true),
value,
])
: selectValue.type === 'dynamicValue'
? createDynamicSelectExpression(selectValue.value)
: createConditionalExpression(
createSimpleExpression(
`"value" in ${selectValue.tempId}`,
false,
),
createDynamicSelectExpression(
createSimpleExpression(
`${selectValue.tempId}.value`,
false,
),
),
createSimpleExpression('false', false),
),
]),
createSimpleExpression(' selected', true),
createSimpleExpression('', true),
false /* no newline */,
),
)
}
} else if (plainNode.tag === 'optgroup') {
processSelectChildren(context, plainNode.children, selectValue)
}
}