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 { compile } from '../src'
import { renderToString } from '@vue/server-renderer'
import { createApp } from '@vue/runtime-dom'
describe('ssr: element', () => {
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', () => {
expect(
compile(`<div>{{ hello }}<textarea v-bind="a"></textarea></div>`).code,

View File

@ -57,6 +57,7 @@ import {
type SSRTransformContext,
processChildren,
} from '../ssrCodegenTransform'
import { processSelectChildren } from '../utils'
// 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.
@ -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') {
// <input v-bind="obj" 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(
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) {
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') {
// Directive transforms.
const directiveTransform = context.directiveTransforms[prop.name]
@ -326,6 +350,13 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
const name = prop.name
if (node.tag === 'textarea' && name === 'value' && prop.value) {
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) {
if (name === 'key' || name === 'ref') {
continue
@ -399,12 +430,13 @@ function isTrueFalseValue(prop: DirectiveNode | AttributeNode) {
}
}
function isTextareaWithValue(
function isTagWithValueBind(
node: PlainElementNode,
targetTag: string,
prop: DirectiveNode,
): boolean {
return !!(
node.tag === 'textarea' &&
node.tag === targetTag &&
prop.name === 'bind' &&
isStaticArgOf(prop.arg, 'value')
)

View File

@ -2,27 +2,23 @@ import {
DOMErrorCodes,
type DirectiveTransform,
ElementTypes,
type ExpressionNode,
NodeTypes,
type PlainElementNode,
type TemplateChildNode,
createCallExpression,
createConditionalExpression,
createDOMCompilerError,
createInterpolation,
createObjectProperty,
createSimpleExpression,
findProp,
hasDynamicKeyVBind,
transformModel,
} from '@vue/compiler-dom'
import {
SSR_INCLUDE_BOOLEAN_ATTR,
SSR_LOOSE_CONTAIN,
SSR_LOOSE_EQUAL,
SSR_RENDER_DYNAMIC_MODEL,
} from '../runtimeHelpers'
import type { DirectiveTransformResult } from 'packages/compiler-core/src/transform'
import { findValueBinding, processSelectChildren } from '../utils'
export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
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) {
const res: DirectiveTransformResult = { props: [] }
const defaultProps = [
@ -173,7 +127,10 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
checkDuplicatedValue()
node.children = [createInterpolation(model, model.loc)]
} else if (node.tag === 'select') {
processSelectChildren(node.children)
processSelectChildren(context, node.children, {
type: 'dynamicValue',
value: model,
})
} else {
context.onError(
createDOMCompilerError(
@ -189,12 +146,3 @@ export const ssrTransformModel: DirectiveTransform = (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)
}
}