From fc0df0d9afd5c961a50cd1e4c6d647eb90819572 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 4 Oct 2025 21:30:02 -0700 Subject: [PATCH 1/3] fix(compiler-ssr): expand v-model `option` selected inclusion logic --- .../compiler-ssr/__tests__/ssrVModel.spec.ts | 111 +++++++++++++++ .../compiler-ssr/src/transforms/ssrVModel.ts | 129 ++++++++++++++++-- 2 files changed, 228 insertions(+), 12 deletions(-) diff --git a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts index 8a439dbf4b5..11c5cbf0fc4 100644 --- a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts @@ -292,6 +292,117 @@ describe('ssr: v-model', () => { _push(\`\`) }" `) + + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + + expect( + compileWithWrapper(``) + .code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) }) test('', () => { diff --git a/packages/compiler-ssr/src/transforms/ssrVModel.ts b/packages/compiler-ssr/src/transforms/ssrVModel.ts index cbe5b2b42a3..707bf8442d8 100644 --- a/packages/compiler-ssr/src/transforms/ssrVModel.ts +++ b/packages/compiler-ssr/src/transforms/ssrVModel.ts @@ -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, @@ -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, + ]), ]), createSimpleExpression(' selected', true), createSimpleExpression('', true), @@ -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 @@ -198,3 +265,41 @@ function findValueBinding(node: PlainElementNode): ExpressionNode { : createSimpleExpression(valueBinding.value!.content, true) : createSimpleExpression(`null`, false) } + +function collapseTextBetweenComments( + 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 +} From 15d6f922df89216b4a0455559329d8721fcff644 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 4 Oct 2025 21:56:15 -0700 Subject: [PATCH 2/3] fix: single text element trailing whitespace --- .../compiler-ssr/__tests__/ssrVModel.spec.ts | 16 ++++++++++++++++ .../compiler-ssr/src/transforms/ssrVModel.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts index 11c5cbf0fc4..8afdda39b3d 100644 --- a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts @@ -309,6 +309,22 @@ describe('ssr: v-model', () => { }" `) + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + expect( compileWithWrapper( ``, diff --git a/packages/compiler-ssr/src/transforms/ssrVModel.ts b/packages/compiler-ssr/src/transforms/ssrVModel.ts index 707bf8442d8..5213c199500 100644 --- a/packages/compiler-ssr/src/transforms/ssrVModel.ts +++ b/packages/compiler-ssr/src/transforms/ssrVModel.ts @@ -239,7 +239,8 @@ function findOptionValue(node: PlainElementNode): OptionValue { let content = x.content if (i === 0) { content = content.trimStart() - } else if (i === relevantNodes.length - 1) { + } + if (i === relevantNodes.length - 1) { content = content.trimEnd() } return createSimpleExpression(content, true) From cced68b97d8c5c8f43f3f9dbcfc133b02c9f65b0 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 11 Oct 2025 20:27:40 -0700 Subject: [PATCH 3/3] fix: support select multiple --- .../compiler-ssr/__tests__/ssrVModel.spec.ts | 108 ++++++++++++++---- .../compiler-ssr/src/transforms/ssrVModel.ts | 85 ++++++-------- .../__tests__/ssrDirectives.spec.ts | 22 ++++ 3 files changed, 145 insertions(+), 70 deletions(-) diff --git a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts index 8afdda39b3d..d931e16e6a4 100644 --- a/packages/compiler-ssr/__tests__/ssrVModel.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVModel.spec.ts @@ -298,13 +298,15 @@ describe('ssr: v-model', () => { ``, ).code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\`\`) }" `) @@ -314,13 +316,15 @@ describe('ssr: v-model', () => { ``, ).code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\`\`) }" `) @@ -330,13 +334,15 @@ describe('ssr: v-model', () => { ``, ).code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\`\`) @@ -362,23 +368,35 @@ describe('ssr: v-model', () => { `, ).code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + expect( compileWithWrapper( ``, ).code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs, ssrInterpolate: _ssrInterpolate } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\`\`) @@ -409,16 +449,40 @@ describe('ssr: v-model', () => { compileWithWrapper(``) .code, ).toMatchInlineSnapshot(` - "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { _push(\`\`) }" `) + + expect( + compileWithWrapper( + ``, + ).code, + ).toMatchInlineSnapshot(` + "const { ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrLooseContain: _ssrLooseContain, ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) }) test('', () => { diff --git a/packages/compiler-ssr/src/transforms/ssrVModel.ts b/packages/compiler-ssr/src/transforms/ssrVModel.ts index 5213c199500..30bf96a4224 100644 --- a/packages/compiler-ssr/src/transforms/ssrVModel.ts +++ b/packages/compiler-ssr/src/transforms/ssrVModel.ts @@ -62,22 +62,17 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => { plainNode.ssrCodegenNode!.elements.push( createConditionalExpression( createCallExpression(context.helper(SSR_INCLUDE_BOOLEAN_ATTR), [ - 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, - ]), + 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), @@ -199,26 +194,19 @@ export const ssrTransformModel: DirectiveTransform = (dir, node, context) => { } } -interface OptionValue { - node: ExpressionNode | TemplateLiteral - maybeArray: boolean -} - -function findOptionValue(node: PlainElementNode): OptionValue { +function findOptionValue( + node: PlainElementNode, +): ExpressionNode | TemplateLiteral { const valueBinding = findProp(node, 'value') if (valueBinding) { - return { - node: - valueBinding.type === NodeTypes.DIRECTIVE - ? valueBinding.exp! - : createSimpleExpression(valueBinding.value!.content, true), - maybeArray: true, - } + return valueBinding.type === NodeTypes.DIRECTIVE + ? valueBinding.exp! + : createSimpleExpression(valueBinding.value!.content, true) } const textDir = findDir(node, 'text') if (textDir) { - return { node: textDir.exp!, maybeArray: false } + return textDir.exp! } if ( @@ -233,29 +221,30 @@ function findOptionValue(node: PlainElementNode): OptionValue { 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() - } - if (i === relevantNodes.length - 1) { - content = content.trimEnd() - } - return createSimpleExpression(content, true) - } else { - return x.content + const expressions = relevantNodes.map((x, i) => { + if (x.type === NodeTypes.TEXT) { + let content = x.content + if (i === 0) { + content = content.trimStart() } - }), - ) - if (textContentValue) { - return { node: textContentValue, maybeArray: false } + if (i === relevantNodes.length - 1) { + content = content.trimEnd() + } + return createSimpleExpression(content, true) + } else { + return x.content + } + }) + + if (expressions.length === 1) { + return expressions[0] + } else { + return createTemplateLiteral(expressions) } } } - return { node: createSimpleExpression(``, true), maybeArray: false } + return createSimpleExpression(``, true) } function findValueBinding(node: PlainElementNode): ExpressionNode { diff --git a/packages/server-renderer/__tests__/ssrDirectives.spec.ts b/packages/server-renderer/__tests__/ssrDirectives.spec.ts index dfdebe971f5..70a3a0b0941 100644 --- a/packages/server-renderer/__tests__/ssrDirectives.spec.ts +++ b/packages/server-renderer/__tests__/ssrDirectives.spec.ts @@ -122,6 +122,28 @@ describe('ssr: directives', () => { ``, ) + expect( + await renderToString( + createApp({ + data: () => ({ model: 'f0o', zero: 0 }), + template: ``, + }), + ), + ).toBe( + ``, + ) + + expect( + await renderToString( + createApp({ + data: () => ({ model: 'foo', opt1Val: 'foo', opt2Val: 'bar' }), + template: ``, + }), + ), + ).toBe( + ``, + ) + expect( await renderToString( createApp({