Skip to content

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

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 2 commits 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
156 changes: 156 additions & 0 deletions packages/compiler-ssr/__tests__/ssrElement.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler-ssr/__tests__/ssrVModel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ describe('ssr: v-model', () => {
}"
`)

expect(
compileWithWrapper(
`<select v-model="model" value="2"><option value="1"></option></select>`,
).code,
).toMatchInlineSnapshot(`
"const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${
_ssrRenderAttrs(_attrs)
}><select><option value="1"\${
(_ssrIncludeBooleanAttr((Array.isArray(_ctx.model))
? _ssrLooseContain(_ctx.model, "1")
: _ssrLooseEqual(_ctx.model, "1"))) ? " selected" : ""
}></option></select></div>\`)
}"
`)

expect(
compileWithWrapper(
`<select multiple v-model="model"><option value="1" selected></option><option value="2"></option></select>`,
Expand Down
45 changes: 42 additions & 3 deletions packages/compiler-ssr/src/transforms/ssrTransformElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -81,6 +82,10 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
const needTagForRuntime =
node.tag === 'textarea' || node.tag.indexOf('-') > 0

const hasVModel = node.props.some(
p => p.type === NodeTypes.DIRECTIVE && p.name === 'model',
)

// v-bind="obj", v-bind:[key] and custom directives can potentially
// overwrite other static attrs and can affect final rendering result,
// so when they are present we need to bail out to full `renderAttrs`
Expand Down Expand Up @@ -139,6 +144,25 @@ export const ssrTransformElement: NodeTransform = (node, context) => {
]),
)
}
} else if (node.tag === 'select') {
// v-model takes priority over value
if (!hasVModel) {
// <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
Expand Down Expand Up @@ -223,10 +247,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 && !hasVModel) {
processSelectChildren(context, node.children, {
type: 'dynamicValue',
value: prop.exp,
})
}
} else if (!needMergeProps && prop.name !== 'on') {
// Directive transforms.
const directiveTransform = context.directiveTransforms[prop.name]
Expand Down Expand Up @@ -326,6 +357,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 && !hasVModel) {
processSelectChildren(context, node.children, {
type: 'staticValue',
value: prop.value.content,
})
}
} else if (!needMergeProps) {
if (name === 'key' || name === 'ref') {
continue
Expand Down Expand Up @@ -399,12 +437,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')
)
Expand Down
62 changes: 5 additions & 57 deletions packages/compiler-ssr/src/transforms/ssrVModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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 = [
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Loading