Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
111 changes: 111 additions & 0 deletions packages/compiler-ssr/__tests__/ssrVModel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,117 @@ describe('ssr: v-model', () => {
_push(\`<!--]--></optgroup></select></div>\`)
}"
`)

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

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

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

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${
_ssrRenderAttrs(_attrs)
}><select><option\${
(_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.model, \`\${_ctx.myValue}\`))) ? " selected" : ""
}>\${
_ssrInterpolate(_ctx.myValue)
}</option></select></div>\`)
}"
`)

expect(
compileWithWrapper(
`<select v-model="model">
<option>
A
B
<!--comment-->
<!--comment-->
C
{{ myValue1 }}
D
<!--comment-->
{{ myValue2 }}
<!--comment-->E
<!--comment-->
</option>
</select>`,
).code,
).toMatchInlineSnapshot(`
"const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${
_ssrRenderAttrs(_attrs)
}><select><option\${
(_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.model, \`\${
"A B C "
}\${
_ctx.myValue1
}\${
" D "
}\${
_ctx.myValue2
}\${
" E"
}\`))) ? " selected" : ""
}> A B <!--comment--><!--comment--> C \${
_ssrInterpolate(_ctx.myValue1)
} D <!--comment--> \${
_ssrInterpolate(_ctx.myValue2)
} <!--comment-->E <!--comment--></option></select></div>\`)
}"
`)

expect(
compileWithWrapper(
`<select v-model="model"><option v-text="'foo'"></option></select>`,
).code,
).toMatchInlineSnapshot(`
"const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${
_ssrRenderAttrs(_attrs)
}><select><option\${
(_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.model, 'foo'))) ? " selected" : ""
}>\${
_ssrInterpolate('foo')
}</option></select></div>\`)
}"
`)

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

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

test('<input type="radio">', () => {
Expand Down
129 changes: 117 additions & 12 deletions packages/compiler-ssr/src/transforms/ssrVModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import {
NodeTypes,
type PlainElementNode,
type TemplateChildNode,
type TemplateLiteral,
type TextNode,
createCallExpression,
createConditionalExpression,
createDOMCompilerError,
createInterpolation,
createObjectProperty,
createSimpleExpression,
createTemplateLiteral,
findDir,
findProp,
hasDynamicKeyVBind,
transformModel,
Expand Down Expand Up @@ -54,21 +58,26 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
function processOption(plainNode: PlainElementNode) {
if (plainNode.tag === 'option') {
if (plainNode.props.findIndex(p => p.name === 'selected') === -1) {
const value = findValueBinding(plainNode)
const value = findOptionValue(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,
]),
),
value.maybeArray
? createConditionalExpression(
createCallExpression(`Array.isArray`, [model]),
createCallExpression(context.helper(SSR_LOOSE_CONTAIN), [
model,
value.node,
]),
createCallExpression(context.helper(SSR_LOOSE_EQUAL), [
model,
value.node,
]),
)
: createCallExpression(context.helper(SSR_LOOSE_EQUAL), [
model,
value.node,
]),
Comment on lines +66 to +80
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restore array-model handling for text-derived option values

When an <select multiple v-model="array"> option relies on collapsed text / interpolation (no explicit value attribute), findOptionValue() sets maybeArray = false, so we never run the Array.isArray branch. As a result the SSR output compares the array model against a string template literal via ssrLooseEqual, which always fails and the selected attribute is omitted. This regresses multi-select parity for the very cases this PR extends. Please mark these derived values as maybeArray (or otherwise keep the array containment path) so multiple-selection continues to work.

🤖 Prompt for AI Agents
In packages/compiler-ssr/src/transforms/ssrVModel.ts around lines 66-80, the
generated code skips the Array.isArray containment branch for option values
derived from collapsed text/interpolation (maybeArray was set false), causing
multi-selects to never mark selected; restore array-model handling by treating
these derived text/interpolation option values as maybeArray (or otherwise
ensure the Array.isArray conditional call path is used) so the generated
expression keeps the Array.isArray ? ssrLooseContain : ssrLooseEqual logic for
those cases; implement by setting maybeArray = true for derived/text-based
option values (or by forcing the conditional expression to include the
Array.isArray branch) so SSR selected attributes are produced correctly for
multiple selects.

]),
createSimpleExpression(' selected', true),
createSimpleExpression('', true),
Expand Down Expand Up @@ -190,6 +199,64 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => {
}
}

interface OptionValue {
node: ExpressionNode | TemplateLiteral
maybeArray: boolean
}

function findOptionValue(node: PlainElementNode): OptionValue {
const valueBinding = findProp(node, 'value')
if (valueBinding) {
return {
node:
valueBinding.type === NodeTypes.DIRECTIVE
? valueBinding.exp!
: createSimpleExpression(valueBinding.value!.content, true),
maybeArray: true,
}
}

const textDir = findDir(node, 'text')
if (textDir) {
return { node: textDir.exp!, maybeArray: false }
}

if (
node.children.every(
x =>
x.type === NodeTypes.TEXT ||
x.type === NodeTypes.COMMENT ||
x.type === NodeTypes.INTERPOLATION,
)
) {
const relevantNodes = collapseTextBetweenComments(node.children).filter(
x => x.type !== NodeTypes.COMMENT,
)
if (relevantNodes.length) {
const textContentValue = createTemplateLiteral(
relevantNodes.map((x, i) => {
if (x.type === NodeTypes.TEXT) {
let content = x.content
if (i === 0) {
content = content.trimStart()
} else if (i === relevantNodes.length - 1) {
content = content.trimEnd()
}
return createSimpleExpression(content, true)
} else {
return x.content
}
}),
)
if (textContentValue) {
return { node: textContentValue, maybeArray: false }
}
}
}

return { node: createSimpleExpression(``, true), maybeArray: false }
}

function findValueBinding(node: PlainElementNode): ExpressionNode {
const valueBinding = findProp(node, 'value')
return valueBinding
Expand All @@ -198,3 +265,41 @@ function findValueBinding(node: PlainElementNode): ExpressionNode {
: createSimpleExpression(valueBinding.value!.content, true)
: createSimpleExpression(`null`, false)
}

function collapseTextBetweenComments<T extends TemplateChildNode>(
children: T[],
) {
const result: (T | TextNode)[] = []
let prevTextNode: TextNode | undefined
for (let i = 0; i < children.length; i++) {
const child = children[i]
if (child.type === NodeTypes.TEXT) {
if (prevTextNode) {
const prevContent = prevTextNode.content
let thisContent = child.content
if (prevContent.endsWith(' ') && thisContent.startsWith(' ')) {
thisContent = thisContent.slice(1)
}
const combined: TextNode = {
...prevTextNode,
content: prevContent + thisContent,
}
prevTextNode = combined
} else {
prevTextNode = child
}
} else if (child.type === NodeTypes.COMMENT) {
continue
} else {
if (prevTextNode) {
result.push(prevTextNode)
prevTextNode = undefined
}
result.push(child)
}
}
if (prevTextNode) {
result.push(prevTextNode)
}
return result
}
Loading