diff --git a/config/vue3migration/compiler.js b/config/vue3migration/compiler.js index a2c82584227f8e5d67b302755b1639fb0a21749a..d6b6e1e7533a7c223f95b1d4294e664d73306c51 100644 --- a/config/vue3migration/compiler.js +++ b/config/vue3migration/compiler.js @@ -2,16 +2,20 @@ const { parse, compile: compilerDomCompile } = require('@vue/compiler-dom'); const COMMENT_NODE_TYPE = 3; -const getPropIndex = (node, prop) => node.props?.findIndex((p) => p.name === prop) ?? -1; +const hasProp = (node, prop) => node.props?.some((p) => p.name === prop); function modifyKeysInsideTemplateTag(templateNode) { + if (!templateNode.tag === 'template' || !hasProp(templateNode, 'for')) { + return; + } + let keyCandidate = null; for (const node of templateNode.children) { const keyBindingIndex = node.props ? node.props.findIndex((prop) => prop.arg && prop.arg.content === 'key') : -1; - if (keyBindingIndex !== -1 && getPropIndex(node, 'for') === -1) { + if (keyBindingIndex !== -1 && !hasProp(node, 'for')) { if (!keyCandidate) { keyCandidate = node.props[keyBindingIndex]; } @@ -24,40 +28,97 @@ function modifyKeysInsideTemplateTag(templateNode) { } } +function getSlotName(node) { + return node?.props?.find((prop) => prop.name === 'slot')?.arg?.content; +} + +function filterCommentNodeAndTrailingSpace(node, idx, list) { + if (node.type === COMMENT_NODE_TYPE) { + return false; + } + + if (node.content !== ' ') { + return true; + } + + if (list[idx - 1]?.type === COMMENT_NODE_TYPE) { + return false; + } + + return true; +} + +function filterCommentNodes(node) { + const { length: originalLength } = node.children; + // eslint-disable-next-line no-param-reassign + node.children = node.children.filter(filterCommentNodeAndTrailingSpace); + if (node.children.length !== originalLength) { + // trim remaining spaces + while (node.children.at(-1)?.content === ' ') { + node.children.pop(); + } + } +} + +function dropVOnceForChildrenInsideVIfBecauseOfIssue7725(node) { + // See https://github.com/vuejs/core/issues/7725 for details + if (!hasProp(node, 'if')) { + return; + } + + node.children?.forEach((child) => { + if (Array.isArray(child.props)) { + // eslint-disable-next-line no-param-reassign + child.props = child.props.filter((prop) => prop.name !== 'once'); + } + }); +} + +function fixSameSlotsInsideTemplateFailingWhenUsingWhitespacePreserveDueToIssue6063(node) { + // See https://github.com/vuejs/core/issues/6063 for details + // eslint-disable-next-line no-param-reassign + node.children = node.children.filter((child, idx) => { + if (child.content !== ' ') { + // We need to drop only comment nodes + return true; + } + + const previousNodeSlotName = getSlotName(node.children[idx - 1]); + const nextNodeSlotName = getSlotName(node.children[idx + 1]); + + if (previousNodeSlotName && previousNodeSlotName === nextNodeSlotName) { + // We have a space beween two slot entries with same slot name, we need to drop it + return false; + } + + return true; + }); +} + module.exports = { parse, compile(template, options) { const rootNode = parse(template, options); - // We do not want to switch to whitespace: collapse mode which is Vue.js 3 default - // It will be too devastating to codebase + const pendingNodes = [rootNode]; + while (pendingNodes.length) { + const currentNode = pendingNodes.pop(); + if (Array.isArray(currentNode.children)) { + // This one will be dropped all together with compiler when we drop Vue.js 2 support + modifyKeysInsideTemplateTag(currentNode); - // However, without `whitespace: condense` Vue will treat spaces between comments - // and nodes itself as text nodes, resulting in multi-root component - // For multi-root component passing classes / attributes fallthrough will not work + dropVOnceForChildrenInsideVIfBecauseOfIssue7725(currentNode); - // See https://github.com/vuejs/core/issues/7909 for details + // See https://github.com/vuejs/core/issues/7909 for details + // However, this issue applies not only to root-level nodes + // But on any level comments could change slot emptiness detection + // so we simply drop them + filterCommentNodes(currentNode); - // To fix that we simply drop all component comments only on top-level - rootNode.children = rootNode.children.filter((n) => n.type !== COMMENT_NODE_TYPE); + fixSameSlotsInsideTemplateFailingWhenUsingWhitespacePreserveDueToIssue6063(currentNode); - const pendingNodes = [rootNode]; - while (pendingNodes.length) { - const currentNode = pendingNodes.pop(); - if (getPropIndex(currentNode, 'for') !== -1) { - if (currentNode.tag === 'template') { - // This one will be dropped all together with compiler when we drop Vue.js 2 support - modifyKeysInsideTemplateTag(currentNode); - } - - // This one will be dropped when https://github.com/vuejs/core/issues/7725 will be fixed - const vOncePropIndex = getPropIndex(currentNode, 'once'); - if (vOncePropIndex !== -1) { - currentNode.props.splice(vOncePropIndex, 1); - } + currentNode.children.forEach((child) => pendingNodes.push(child)); } - - currentNode.children?.forEach((child) => pendingNodes.push(child)); } return compilerDomCompile(rootNode, options); diff --git a/jest.config.base.js b/jest.config.base.js index 3cbf2fdd61b6a8c20b765134c22e1cc73c39b985..d11b3a5c1e67079d412237ca627b6c4ce7c6e8c5 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -51,6 +51,7 @@ module.exports = (path, options = {}) => { experimentalCSSCompile: false, compiler: require.resolve('./config/vue3migration/compiler'), compilerOptions: { + whitespace: 'preserve', compatConfig: { MODE: 2, }, diff --git a/spec/frontend/vue3migration/compiler_spec.js b/spec/frontend/vue3migration/compiler_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3623f69fe07b2b82eaf744e3cfbc71c11a4a22f1 --- /dev/null +++ b/spec/frontend/vue3migration/compiler_spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils'; + +import SlotsWithSameName from './components/slots_with_same_name.vue'; +import VOnceInsideVIf from './components/v_once_inside_v_if.vue'; +import KeyInsideTemplate from './components/key_inside_template.vue'; +import CommentsOnRootLevel from './components/comments_on_root_level.vue'; +import SlotWithComment from './components/slot_with_comment.vue'; +import DefaultSlotWithComment from './components/default_slot_with_comment.vue'; + +describe('Vue.js 3 compiler edge cases', () => { + it('workarounds issue #6063 when same slot is used with whitespace preserve', () => { + expect(() => mount(SlotsWithSameName)).not.toThrow(); + }); + + it('workarounds issue #7725 when v-once is used inside v-if', () => { + expect(() => mount(VOnceInsideVIf)).not.toThrow(); + }); + + it('renders vue.js 2 component when key is inside template', () => { + const wrapper = mount(KeyInsideTemplate); + expect(wrapper.text()).toBe('12345'); + }); + + it('passes attributes to component with trailing comments on root level', () => { + const wrapper = mount(CommentsOnRootLevel, { propsData: { 'data-testid': 'test' } }); + expect(wrapper.html()).toBe('<div data-testid="test"></div>'); + }); + + it('treats empty slots with comments as empty', () => { + const wrapper = mount(SlotWithComment); + expect(wrapper.html()).toBe('<div>Simple</div>'); + }); + + it('treats empty default slot with comments as empty', () => { + const wrapper = mount(DefaultSlotWithComment); + expect(wrapper.html()).toBe('<div>Simple</div>'); + }); +}); diff --git a/spec/frontend/vue3migration/components/comments_on_root_level.vue b/spec/frontend/vue3migration/components/comments_on_root_level.vue new file mode 100644 index 0000000000000000000000000000000000000000..78222c059d56a325173c9a3a167481529d5876a4 --- /dev/null +++ b/spec/frontend/vue3migration/components/comments_on_root_level.vue @@ -0,0 +1,5 @@ +<template> + <!-- root level comment --> + <div><slot></slot></div> + <!-- root level comment --> +</template> diff --git a/spec/frontend/vue3migration/components/default_slot_with_comment.vue b/spec/frontend/vue3migration/components/default_slot_with_comment.vue new file mode 100644 index 0000000000000000000000000000000000000000..d2589104a5da2e9b7bb18e06c0532f1a161f68f4 --- /dev/null +++ b/spec/frontend/vue3migration/components/default_slot_with_comment.vue @@ -0,0 +1,18 @@ +<script> +import Simple from './simple.vue'; + +export default { + components: { + Simple, + }, +}; +</script> +<template> + <simple> + <!-- slot comment typical for gitlab-ui, for example --> + <!-- slot comment typical for gitlab-ui, for example --> + <slot></slot> + <!-- slot comment typical for gitlab-ui, for example --> + <!-- slot comment typical for gitlab-ui, for example --> + </simple> +</template> diff --git a/spec/frontend/vue3migration/components/key_inside_template.vue b/spec/frontend/vue3migration/components/key_inside_template.vue new file mode 100644 index 0000000000000000000000000000000000000000..af1f46c44e6cb0342b9bf0c5ca69ee0e48301135 --- /dev/null +++ b/spec/frontend/vue3migration/components/key_inside_template.vue @@ -0,0 +1,7 @@ +<template> + <div> + <template v-for="count in 5" + ><span :key="count">{{ count }}</span></template + > + </div> +</template> diff --git a/spec/frontend/vue3migration/components/simple.vue b/spec/frontend/vue3migration/components/simple.vue new file mode 100644 index 0000000000000000000000000000000000000000..1d9854b5b4df8fc0fad02290ab139e1253847d2f --- /dev/null +++ b/spec/frontend/vue3migration/components/simple.vue @@ -0,0 +1,10 @@ +<script> +export default { + name: 'Simple', +}; +</script> +<template> + <div> + <slot>{{ $options.name }}</slot> + </div> +</template> diff --git a/spec/frontend/vue3migration/components/slot_with_comment.vue b/spec/frontend/vue3migration/components/slot_with_comment.vue new file mode 100644 index 0000000000000000000000000000000000000000..56bb41e432f434540e22877ee77ec5509058b494 --- /dev/null +++ b/spec/frontend/vue3migration/components/slot_with_comment.vue @@ -0,0 +1,20 @@ +<script> +import Simple from './simple.vue'; + +export default { + components: { + Simple, + }, +}; +</script> +<template> + <simple> + <template #default> + <!-- slot comment typical for gitlab-ui, for example --> + <!-- slot comment typical for gitlab-ui, for example --> + <slot></slot> + <!-- slot comment typical for gitlab-ui, for example --> + <!-- slot comment typical for gitlab-ui, for example --> + </template> + </simple> +</template> diff --git a/spec/frontend/vue3migration/components/slots_with_same_name.vue b/spec/frontend/vue3migration/components/slots_with_same_name.vue new file mode 100644 index 0000000000000000000000000000000000000000..37604cd9f6efdc121818aab5ff976abe08967249 --- /dev/null +++ b/spec/frontend/vue3migration/components/slots_with_same_name.vue @@ -0,0 +1,14 @@ +<script> +import Simple from './simple.vue'; + +export default { + name: 'SlotsWithSameName', + components: { Simple }, +}; +</script> +<template> + <simple> + <template v-if="true" #default>{{ $options.name }}</template> + <template v-else #default>{{ $options.name }}</template> + </simple> +</template> diff --git a/spec/frontend/vue3migration/components/v_once_inside_v_if.vue b/spec/frontend/vue3migration/components/v_once_inside_v_if.vue new file mode 100644 index 0000000000000000000000000000000000000000..708aa7a96c260e5456b0b061e8099c5591c9f52e --- /dev/null +++ b/spec/frontend/vue3migration/components/v_once_inside_v_if.vue @@ -0,0 +1,12 @@ +<script> +export default { + name: 'VOnceInsideVIf', +}; +</script> +<template> + <div> + <template v-if="true"> + <div v-once>{{ $options.name }}</div> + </template> + </div> +</template> diff --git a/spec/frontend/vue_compat_test_setup.js b/spec/frontend/vue_compat_test_setup.js index 8c0346e619836b71d6627d23ec4480f70d7a64e7..6eba9465c802fd4cbc75346290dd0567e1f5f93b 100644 --- a/spec/frontend/vue_compat_test_setup.js +++ b/spec/frontend/vue_compat_test_setup.js @@ -63,7 +63,7 @@ if (global.document) { }; let compatH; - Vue.config.compilerOptions.whitespace = 'condense'; + Vue.config.compilerOptions.whitespace = 'preserve'; Vue.createApp({ compatConfig: { MODE: 3,